diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..25df048 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..057d540 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +node_modules +web/node_modules +web/build +data +*.md +plans/ +.claude/ +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d006b65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +web/node_modules/ +web/build/ +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b914db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /build/web +COPY web/package.json web/package-lock.json* ./ +RUN npm ci --no-audit + +COPY web/ ./ +RUN npm run build + +# Stage 2: Build Go binary +FROM golang:1.24-alpine AS backend-builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +COPY go.mod go.sum ./ +ENV GOTOOLCHAIN=auto +RUN go mod download + +COPY . . +# Copy built frontend into the expected embed location. +COPY --from=frontend-builder /build/web/build ./web/build + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /docker-watcher ./cmd/server + +# Stage 3: Minimal runtime image +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +# Create non-root user. +RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app + +WORKDIR /app + +COPY --from=backend-builder /docker-watcher /app/docker-watcher + +# Data directory for SQLite database. +RUN mkdir -p /app/data && chown -R app:app /app + +USER app + +EXPOSE 8080 + +ENV DATA_DIR=/app/data +ENV LISTEN_ADDR=:8080 + +ENTRYPOINT ["/app/docker-watcher"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d17f702 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: build build-frontend build-backend dev clean + +# Build everything: frontend first, then backend (which embeds frontend). +build: build-frontend build-backend + +# Build the SvelteKit frontend to web/build/. +build-frontend: + cd web && npm install && npm run build + +# Build the Go binary (embeds web/build/ via go:embed). +build-backend: + go build -o docker-watcher ./cmd/server + +# Run in development mode with hot reload. +# Requires air (go install github.com/air-verse/air@latest). +dev: + air -c .air.toml 2>/dev/null || go run ./cmd/server + +# Clean build artifacts. +clean: + rm -rf web/build web/node_modules/.vite docker-watcher diff --git a/PLAN.md b/PLAN.md index af7d1bb..836fc8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,34 +2,39 @@ ## Overview -A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. +A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project. DNS is handled by a Cloudflare wildcard record (`*.dolgolyov-family.by`) — no per-project DNS management needed. ## Architecture -``` +```text Gitea CI → pushes image → Registry + │ ↓ + │ Docker Watcher (Go) + │ ├── Secret webhook URL (instant) + │ └── Registry poller (fallback) + │ ↓ + └── or: POST /api/webhook/ + with {"image": "registry/org/app:tag"} ↓ - Docker Watcher (Go) - ├── Webhook listener (instant) - └── Registry poller (fallback) - ↓ - Match tag → project + stage - ↓ - ┌── auto_deploy? ──┐ - ↓ ↓ - Deploy now Notify, wait for - manual trigger (UI) - ↓ ↓ - Pull image - Stop old container - Start new container on shared network - ↓ - NPM API: create/update proxy host - → stage-dev-project.example.com - ↓ - Health check - → success: done, notify - → failure: rollback, alert + Known project? ──────────────────┐ + ↓ yes ↓ no + Match tag → stage Auto-create project + ↓ with defaults from + auto_deploy? image inspection + ↓ yes ↓ no (EXPOSE, labels) + Deploy now Notify, wait ↓ + ↓ for UI trigger Deploy with defaults + ↓ ↓ + Pull image + Start new container on shared network + (old container stays if multi-instance) + ↓ + NPM API: create proxy host (if first deploy for this subdomain) + (DNS already handled by Cloudflare wildcard *.domain) + ↓ + Health check + → success: done, notify + → failure: remove new container, alert ``` ## Decisions @@ -39,79 +44,138 @@ Gitea CI → pushes image → Registry | Language | Go | Single binary, excellent Docker SDK, low resource usage | | Web UI | SvelteKit (embedded in Go binary) | User's existing stack, lightweight | | Reverse proxy | Nginx Proxy Manager | Already deployed, API available | +| DNS | Cloudflare wildcard `*.{domain}` | One-time setup, all subdomains auto-resolve | | Routing | Subdomain-based | No sub-path issues with SPAs | -| Image detection | Webhook + polling | Webhook for speed, polling as fallback | -| Config | YAML | Version-controlled, easy to edit | +| Image detection | Secret webhook URL + polling | Webhook for speed, polling as fallback | +| Config storage | SQLite (YAML for initial seed only) | Editable via UI, no manual file editing | +| Credentials | Encrypted in SQLite (AES-256) | Single ENCRYPTION_KEY env var | +| Webhook auth | Secret UUID in URL | No tokens needed, simple CI integration | +| Multi-instance | Yes | Multiple tags of same project can run simultaneously | | Deployment target | Same TrueNAS host | Docker socket mounted | ## Subdomain Convention -| Stage | Pattern | Example | -|-------|---------|---------| -| Dev | `stage-dev-{project}.{domain}` | `stage-dev-web-app-launcher.dolgolyov-family.by` | -| Release | `stage-rel-{project}.{domain}` | `stage-rel-web-app-launcher.dolgolyov-family.by` | +| Type | Pattern | Example | +|------|---------|---------| +| Dev (default) | `stage-dev-{project}.{domain}` | `stage-dev-web-app-launcher.dolgolyov-family.by` | +| Dev (specific tag) | `stage-dev-{project}-{tag}.{domain}` | `stage-dev-web-app-launcher-abc123.dolgolyov-family.by` | +| Release (default) | `stage-rel-{project}.{domain}` | `stage-rel-web-app-launcher.dolgolyov-family.by` | +| Release (specific tag) | `stage-rel-{project}-{tag}.{domain}` | `stage-rel-web-app-launcher-v1-2-0.dolgolyov-family.by` | | Production | `{custom}.{domain}` | `launcher.dolgolyov-family.by` | -## Config Format +Tags are sanitized for DNS: dots → dashes, lowercase, truncated to fit DNS limits. + +## Configuration + +### First Launch + +```text +YAML seed file exists? → import into SQLite → done +No YAML? → empty state, configure everything via UI +``` + +After import, all configuration lives in SQLite and is managed via the Web UI. +YAML is never read again unless user clicks "Re-import config" or "Export config". + +### Seed Config Format (optional) ```yaml global: domain: dolgolyov-family.by + server_ip: 93.84.96.191 network: staging-net + subdomain_pattern: "stage-{stage}-{project}" + notification_url: https://notify.dolgolyov-family.by/webhook npm: url: http://npm:81 email: docker-watcher@dolgolyov-family.by - password_env: NPM_PASSWORD - subdomain_pattern: "stage-{stage}-{project}" - notification_url: https://notify.dolgolyov-family.by/webhook - + password: "npm-password-here" registries: gitea: url: https://git.dolgolyov-family.by type: gitea - token_env: GITEA_TOKEN - # github: - # url: https://ghcr.io - # type: github - # token_env: GITHUB_TOKEN + token: "gitea-token-here" projects: web-app-launcher: registry: gitea - image: gitea.dolgolyov-family.by/alexei/web-app-launcher + image: git.dolgolyov-family.by/alexei/web-app-launcher port: 3000 healthcheck: /api/health - env_file: ./envs/web-app-launcher.env - volumes: [] + env: + NODE_ENV: production stages: dev: tag_pattern: "dev-*" auto_deploy: true + max_instances: 5 rel: tag_pattern: "v*" auto_deploy: false + max_instances: 2 prod: tag_pattern: "v*" auto_deploy: false confirm: true promote_from: rel - subdomain: launcher # overrides pattern → launcher.dolgolyov-family.by + max_instances: 2 + subdomain: launcher ``` +## Web UI Sections + +### Dashboard + +Overview of all projects with their running instances: +- Project name, running instance count, latest activity +- Quick status indicators (healthy / stopped / failing) +- "Quick Deploy" button for ad-hoc image deployment + +### Project Detail + +Per-project view with stages and instances: +- Each stage shows all running instances with: tag, status, URL, uptime +- Controls per instance: Stop, Start, Restart, Remove +- "Deploy new version" dropdown — lists available tags from registry +- Deploy history log + +### Quick Deploy + +For deploying images not yet configured as projects: +1. Paste image URL (e.g., `git.dolgolyov-family.by/alexei/my-app:dev-abc123`) +2. Docker Watcher pulls and inspects image (EXPOSE port, HEALTHCHECK, labels) +3. Pre-fills form with sensible defaults (project name, port, stage, subdomain) +4. User reviews, tweaks, clicks "Deploy" +5. Project is auto-created in the DB for future use + +### Settings + +- **Registries** — add/edit/delete registries, test connection +- **Credentials** — NPM, registry tokens (encrypted, shown as `••••••••`) +- **Global** — domain, server IP, Docker network, subdomain pattern, polling interval +- **Notifications** — webhook URL +- **Webhook URL** — shows the secret deploy URL, "Regenerate" button + +### Projects Config + +- Add / edit / delete projects via UI +- Configure image, port, healthcheck, env vars, volumes per project +- Add / remove stages, set tag patterns, auto-deploy, subdomain overrides, max instances + ## Project Structure -``` +```text docker-watcher/ ├── cmd/ │ └── server/ │ └── main.go # Entry point ├── internal/ │ ├── config/ -│ │ ├── config.go # YAML parsing, validation +│ │ ├── config.go # YAML seed parsing │ │ └── config_test.go │ ├── docker/ │ │ ├── client.go # Docker Engine API wrapper -│ │ ├── container.go # Create, start, stop, remove +│ │ ├── container.go # Create, start, stop, remove, inspect │ │ └── client_test.go │ ├── npm/ │ │ ├── client.go # NPM API client (auth, CRUD proxy hosts) @@ -133,33 +197,51 @@ docker-watcher/ │ │ ├── notifier.go # Webhook notifications │ │ └── notifier_test.go │ ├── webhook/ -│ │ ├── handler.go # Receive push events from Gitea/GitHub +│ │ ├── handler.go # Secret URL webhook receiver │ │ └── handler_test.go │ ├── api/ │ │ ├── router.go # HTTP API for web UI -│ │ ├── projects.go # Project endpoints -│ │ ├── deploys.go # Deploy endpoints +│ │ ├── projects.go # Project CRUD endpoints +│ │ ├── registries.go # Registry CRUD endpoints +│ │ ├── settings.go # Global settings endpoints +│ │ ├── instances.go # Instance start/stop/restart/remove +│ │ ├── deploys.go # Deploy + quick deploy endpoints │ │ └── middleware.go # Auth, logging, CORS -│ └── store/ -│ ├── store.go # Deploy history, state (SQLite) -│ └── store_test.go +│ ├── store/ +│ │ ├── store.go # SQLite schema, migrations +│ │ ├── projects.go # Project queries +│ │ ├── instances.go # Instance queries +│ │ ├── registries.go # Registry queries +│ │ ├── settings.go # Settings queries +│ │ ├── deploys.go # Deploy history queries +│ │ └── store_test.go +│ └── crypto/ +│ └── crypto.go # AES-256 encrypt/decrypt for credentials ├── web/ # SvelteKit frontend │ ├── src/ │ │ ├── routes/ │ │ │ ├── +page.svelte # Dashboard │ │ │ ├── projects/ +│ │ │ │ ├── +page.svelte # Projects list + add │ │ │ │ └── [id]/ -│ │ │ │ └── +page.svelte # Project detail + deploy controls +│ │ │ │ └── +page.svelte # Project detail + instances +│ │ │ ├── deploy/ +│ │ │ │ └── +page.svelte # Quick deploy │ │ │ └── settings/ -│ │ │ └── +page.svelte # Config view +│ │ │ ├── +page.svelte # Global settings +│ │ │ ├── registries/ +│ │ │ │ └── +page.svelte +│ │ │ └── credentials/ +│ │ │ └── +page.svelte │ │ ├── lib/ │ │ │ ├── api.ts # API client -│ │ │ └── types.ts # Shared types +│ │ │ ├── types.ts # Shared types +│ │ │ └── components/ # Reusable UI components │ │ └── app.html │ ├── package.json │ ├── svelte.config.js │ └── vite.config.ts -├── docker-watcher.example.yaml +├── docker-watcher.example.yaml # Example seed config ├── Dockerfile ├── docker-compose.yml ├── go.mod @@ -168,52 +250,136 @@ docker-watcher/ ## Implementation Phases -### Phase 1: Foundation +### Phase 1: Foundation ✅ -Core infrastructure — config, Docker client, NPM client. +Core infrastructure — store, config import, Docker client, NPM client. 1. **Go project init** — go.mod, directory structure, dependencies -2. **Config loader** — parse YAML, validate, resolve env vars -3. **Docker client** — connect to socket, pull image, list/start/stop/remove containers, manage networks -4. **NPM client** — authenticate (JWT), create/update/delete proxy hosts, list existing hosts -5. **Store** — SQLite for deploy history and state tracking +2. **SQLite store** — schema, migrations, CRUD for projects/registries/settings/instances/deploys +3. **Crypto** — AES-256 encrypt/decrypt for credential storage +4. **Config seed loader** — parse YAML, import into SQLite on first launch +5. **Docker client** — connect to socket, pull image, inspect image, list/start/stop/remove containers, manage networks +6. **NPM client** — authenticate (JWT), create/update/delete proxy hosts, list existing hosts -### Phase 2: Detection & Deployment +### Phase 2: Detection & Deployment (Registry & Poller ✅, Webhook ✅, Deployer ✅) The core loop — detecting new images and deploying them. -6. **Registry client** — Gitea registry API: list tags, compare with last known -7. **Poller** — periodic check for new tags matching configured patterns -8. **Webhook handler** — HTTP endpoint receiving push events from Gitea CI -9. **Deployer** — orchestrate: pull → stop old → start new → NPM update → health check -10. **Health checker** — HTTP GET with retries and timeout -11. **Rollback** — on health check failure: stop new, restart old, revert NPM -12. **Notifications** — send webhook on deploy success/failure +8. **Registry client** ✅ — Gitea registry API: list tags for an image, detect new tags +9. **Poller** ✅ — periodic check for new tags matching configured patterns +10. **Secret webhook handler** ✅ — UUID-based URL, receives image push notifications, auto-creates unknown projects +11. **Deployer** ✅ — orchestrate: pull → start container → NPM proxy → health check +12. **Multi-instance support** ✅ — multiple versions per project/stage, tag-based subdomains, max_instances limit +13. **Health checker** ✅ — HTTP GET with retries and timeout (3 retries, 5s interval, 10s timeout) +14. **Rollback** ✅ — on health check failure: remove new container, clean up NPM, alert +15. **Notifications** ✅ — send webhook on deploy success/failure (fire-and-forget) ### Phase 3: Web UI -Dashboard for visibility and manual control. +Full dashboard for visibility, manual control, and configuration. -13. **API layer** — REST endpoints for projects, stages, deploys, manual trigger -14. **SvelteKit app** — dashboard, project detail, deploy log, manual deploy button -15. **Embed in Go** — build SvelteKit to static, embed with `go:embed`, serve from Go -16. **Real-time updates** — SSE or WebSocket for deploy progress +16. **API layer** — REST endpoints for all CRUD operations + deploy/control actions +17. **SvelteKit dashboard** — project overview, instance status, quick status indicators +18. **Project detail view** — stages, instances, controls (stop/start/restart/remove), deploy history +19. **Quick Deploy page** — paste image URL, auto-inspect, pre-fill form, one-click deploy +20. **Settings pages** — registries, credentials, global settings, webhook URL management +21. **Project config pages** — add/edit/delete projects and stages via UI +22. **Embed in Go** ✅ — build SvelteKit to static, embed with `go:embed`, serve from Go +23. **Real-time updates** ✅ — SSE for deploy progress and instance status changes -### Phase 4: Hardening +### Phase 4: Volumes & Environment (Phase 13) -- COMPLETED -17. **Blue-green deploys** — start new, health check, swap, stop old (zero downtime) -18. **Promote flow** — enforce `promote_from` for production deploys -19. **Auth on dashboard** — basic auth or token-based -20. **Graceful shutdown** — drain in-progress deploys on SIGTERM -21. **Structured logging** — JSON logs with deploy context +Persistent storage and app-specific configuration for deployed containers. + +24. **Environment variables per project** — key/value pairs stored in SQLite, sensitive values encrypted +25. **Per-stage env overrides** — e.g., `NODE_ENV=development` for dev, `NODE_ENV=production` for prod +26. **Volume mounts per project** — configurable source/target paths with shared/isolated modes +27. **Shared volumes** — all instances of a project mount the same host path (for stateless apps or shared uploads) +28. **Isolated volumes** — each instance gets its own subdirectory: `{source}/{stage}-{tag}/` → `{target}` (for stateful apps with local DBs/files) +29. **UI for volumes & env** — project settings page with key/value editor, volume list, shared/isolated toggle, per-stage override support + +#### Phase 13 Handoff Notes + +- New tables: `stage_env` (id, stage_id, key, value, encrypted, timestamps), `volumes` (id, project_id, source, target, mode, timestamps) +- `stage_env` has UNIQUE(stage_id, key) constraint to prevent duplicate keys per stage +- Volume mode is either "shared" or "isolated"; default is "shared" +- Encrypted env values are encrypted with `crypto.Encrypt` before storage and decrypted at deploy time +- API masks encrypted env values as "••••••••" in responses +- Env merge order in deployer: project-level JSON `env` field parsed first, then stage-level `stage_env` records overlay (stage wins on key conflict) +- `computeVolumeMounts` appends `/{stage}-{tag}/` to source for isolated volumes +- Docker `ContainerConfig` now has `Mounts []mount.Mount` field, passed to `HostConfig.Mounts` +- Both `executeDeploy` and `blueGreenDeploy` updated to use `mergeEnvVars` and `computeVolumeMounts` +- API routes: GET/POST `/api/projects/{id}/stages/{stage}/env`, PUT/DELETE `.../env/{envId}`, GET/POST `/api/projects/{id}/volumes`, PUT/DELETE `.../volumes/{volId}` +- Frontend pages: `/projects/[id]/env` (per-stage env editor with inherited/overridden indicators), `/projects/[id]/volumes` (volume editor with shared/isolated toggle) +- Project detail page now has navigation links to env and volumes pages + +Volume config per project: +```yaml +env: + NODE_ENV: production + DATABASE_URL: postgres://db:5432/myapp # shared external DB + SECRET_KEY: "..." # encrypted in SQLite +volumes: + - source: /data/my-app/uploads + target: /app/uploads + mode: shared # all instances share this path + - source: /data/my-app/data + target: /app/data + mode: isolated # auto-appends /{stage}-{tag}/ to source +``` + +Stage-level env overrides: +```yaml +stages: + dev: + env: + NODE_ENV: development # overrides project-level + DATABASE_URL: postgres://db:5432/myapp_dev + prod: + env: + NODE_ENV: production # uses project-level default +``` + +### Phase 5: Hardening (Phase 12) -- COMPLETED + +30. **Blue-green deploys** -- start new, health check, swap, stop old (zero downtime) +31. **Promote flow** -- enforce `promote_from` for production deploys +32. **Auth on dashboard** -- two modes, configurable via settings: + - **Local auth** -- username/password stored in SQLite (bcrypt hashed), JWT session tokens + - **OAuth2 / OpenID Connect** -- integration with any OIDC provider (configurable client ID/secret/discovery URL) +33. **Graceful shutdown** -- drain in-progress deploys on SIGTERM, close DB, stop poller +34. **Structured logging** -- JSON logs via `log/slog` with deploy context +35. **Config export** -- download current SQLite state as YAML +36. **Dockerfile** -- multi-stage build (Node.js 20 + Go 1.23 build, alpine runtime) +37. **docker-compose.yml** -- production-ready compose with volumes, network, env +38. **Auth middleware** -- protects all /api/* routes except webhook and auth endpoints +39. **Auth settings UI** -- settings page to toggle auth mode, configure OIDC, manage users +40. **Login page** -- username/password form with OIDC SSO option +41. **Final wiring** -- all services properly initialized and shut down in main.go + +#### Phase 12 Handoff Notes + +- Auth: `auth.LocalAuth` handles JWT generation/validation, `auth.OIDCProvider` handles OIDC flow +- Default admin user created on first launch (ADMIN_PASSWORD env var, default: "admin") +- JWT secret derived from ENCRYPTION_KEY via HMAC-SHA256 +- Blue-green: triggered automatically when stage has `max_instances=1`; otherwise standard deploy +- Promote: validated in `TriggerDeploy` before deploy begins +- Graceful shutdown: `deployer.Drain()` waits for in-progress deploys; poller stopped; HTTP server drained; DB closed +- Structured logging: all API, deployer, and main.go use `log/slog` JSON handler +- New dependencies: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`, `github.com/coreos/go-oidc/v3`, `golang.org/x/oauth2` +- New tables: `users` (id, username, password_hash, email, role, timestamps), `auth_settings` (single-row: auth_mode, OIDC config) +- Auth middleware applied to all `/api/*` routes except `/api/auth/login`, `/api/auth/oidc/*`, `/api/webhook/*`, `/api/config/export` +- Frontend: token stored in `localStorage`, sent as `Authorization: Bearer` header +- Run `go mod tidy` after checkout to resolve transitive dependencies ## Key Dependencies (Go) - `github.com/docker/docker` — Docker Engine API - `github.com/go-chi/chi` or `net/http` — HTTP routing -- `gopkg.in/yaml.v3` — YAML config -- `github.com/mattn/go-sqlite3` or `modernc.org/sqlite` — SQLite (CGo-free) +- `gopkg.in/yaml.v3` — YAML seed config +- `modernc.org/sqlite` — SQLite (CGo-free) - `github.com/robfig/cron` — Polling scheduler +- `github.com/google/uuid` — Webhook secret URL generation ## Docker Compose (self-deployment) @@ -227,12 +393,10 @@ services: - "8080:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock - - ./docker-watcher.yaml:/app/config.yaml:ro - - ./envs:/app/envs:ro - - ./data:/app/data # SQLite DB + - ./docker-watcher.yaml:/app/seed.yaml:ro # optional, first launch only + - ./data:/app/data # SQLite DB environment: - - NPM_PASSWORD=${NPM_PASSWORD} - - GITEA_TOKEN=${GITEA_TOKEN} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # protects all credentials in DB networks: - staging-net @@ -241,16 +405,108 @@ networks: external: true ``` -## API Endpoints (Phase 3) +## API Endpoints +```text +# Projects +GET /api/projects — list all projects with instance counts +POST /api/projects — create project +GET /api/projects/:id — project detail + stages + instances +PUT /api/projects/:id — update project config +DELETE /api/projects/:id — delete project + all instances + +# Stages +POST /api/projects/:id/stages — add stage to project +PUT /api/projects/:id/stages/:stage — update stage config +DELETE /api/projects/:id/stages/:stage — delete stage + its instances + +# Stage Env Overrides +GET /api/projects/:id/stages/:stage/env — list stage env vars (secrets masked) +POST /api/projects/:id/stages/:stage/env — create stage env var +PUT /api/projects/:id/stages/:stage/env/:envId — update stage env var +DELETE /api/projects/:id/stages/:stage/env/:envId — delete stage env var + +# Project Volumes +GET /api/projects/:id/volumes — list project volumes +POST /api/projects/:id/volumes — create project volume +PUT /api/projects/:id/volumes/:volId — update project volume +DELETE /api/projects/:id/volumes/:volId — delete project volume + +# Instances (running containers) +GET /api/projects/:id/stages/:stage/instances — list instances for stage +POST /api/projects/:id/stages/:stage/instances — deploy new instance (pick tag) +DELETE /api/projects/:id/stages/:stage/instances/:iid — remove instance (container + NPM proxy) +POST /api/projects/:id/stages/:stage/instances/:iid/stop — stop container +POST /api/projects/:id/stages/:stage/instances/:iid/start — start stopped container +POST /api/projects/:id/stages/:stage/instances/:iid/restart — restart container + +# Quick Deploy +POST /api/deploy/inspect — pull + inspect image, return defaults +POST /api/deploy/quick — create project + deploy in one step + +# Registry +GET /api/registries — list registries +POST /api/registries — add registry +PUT /api/registries/:id — update registry +DELETE /api/registries/:id — delete registry +POST /api/registries/:id/test — test connection +GET /api/registries/:id/tags/:image — list available tags + +# Settings +GET /api/settings — get global settings +PUT /api/settings — update global settings +GET /api/settings/webhook-url — get secret webhook URL +POST /api/settings/webhook-url/regenerate — regenerate webhook URL + +# Deploy history +GET /api/deploys — recent deploys across all projects +GET /api/deploys/:id/logs — deploy log stream (SSE) + +# Webhook (secret URL — no auth needed) +POST /api/webhook/:secret-uuid — receive image push notification ``` -GET /api/projects — list all projects with current status -GET /api/projects/:id — project detail + stage statuses -GET /api/projects/:id/stages/:stage — stage detail + deploy history -POST /api/projects/:id/stages/:stage/deploy — trigger manual deploy -POST /api/projects/:id/stages/:stage/rollback — rollback to previous -GET /api/deploys — recent deploys across all projects -GET /api/deploys/:id/logs — deploy log stream (SSE) -POST /api/webhook/gitea — Gitea push event receiver -POST /api/webhook/github — GitHub push event receiver (future) + +## User Workflows + +### Auto-Deploy (zero effort) + +```text +Push code → CI builds → pushes tag → Docker Watcher detects → +auto_deploy: true → deployed → notification with URL +``` + +### Manual Deploy via UI (one click) + +```text +Open dashboard → project → stage → "Deploy new version" → +pick tag from dropdown → click Deploy +``` + +### Quick Deploy (new project, paste image URL) + +```text +Open dashboard → "Quick Deploy" → paste image URL → +review auto-filled defaults → click Deploy → +project auto-created + deployed +``` + +### Deploy via CI Webhook (zero effort after CI setup) + +```text +# In .gitea/workflows/build.yml +- name: Notify Docker Watcher + run: | + curl -X POST https://watcher.dolgolyov-family.by/api/webhook/d8f2a1e9-... \ + -d '{"image": "git.dolgolyov-family.by/alexei/my-app:dev-${{ github.sha }}"}' +``` + +Known project → deploys per stage config. +Unknown project → auto-creates with defaults from image inspection, deploys. + +### Production Deploy (two clicks) + +```text +Open dashboard → project → prod stage → "Deploy new version" → +dropdown shows only tags running in "rel" stage (promote_from) → +pick tag → confirmation dialog → Deploy ``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ec88781 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "errors" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + dockerwatcher "github.com/alexei/docker-watcher" + "github.com/alexei/docker-watcher/internal/api" + "github.com/alexei/docker-watcher/internal/auth" + "github.com/alexei/docker-watcher/internal/config" + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/deployer" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/events" + "github.com/alexei/docker-watcher/internal/health" + "github.com/alexei/docker-watcher/internal/logging" + "github.com/alexei/docker-watcher/internal/notify" + "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/registry" + "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/webhook" +) + +func main() { + // Initialize structured JSON logging. + logging.Setup() + + dataDir := envOrDefault("DATA_DIR", "./data") + + if err := os.MkdirAll(dataDir, 0o755); err != nil { + slog.Error("create data directory", "error", err) + os.Exit(1) + } + + // Open database. + dbPath := filepath.Join(dataDir, "docker-watcher.db") + db, err := store.New(dbPath) + if err != nil { + slog.Error("open store", "error", err) + os.Exit(1) + } + defer db.Close() + + // Import seed config on first launch (idempotent). + seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml") + if err := config.ImportSeed(db, seedPath); err != nil { + slog.Error("seed import", "error", err) + os.Exit(1) + } + + // Derive encryption key from environment. + encKey, err := crypto.KeyFromEnv() + if err != nil { + slog.Warn("encryption key not set, using default", "warning", err.Error()) + encKey = crypto.DeriveKey("docker-watcher-default-key") + } + + // Ensure default admin user exists on first launch. + if err := ensureDefaultAdmin(db); err != nil { + slog.Error("ensure default admin", "error", err) + os.Exit(1) + } + + // Initialize Docker client. + dockerClient, err := docker.New() + if err != nil { + slog.Error("create docker client", "error", err) + os.Exit(1) + } + defer dockerClient.Close() + + // Read settings for NPM URL and polling interval. + settings, err := db.GetSettings() + if err != nil { + slog.Error("get settings", "error", err) + os.Exit(1) + } + + // Initialize NPM client. + npmURL := envOrDefault("NPM_URL", settings.NpmURL) + npmClient := npm.New(npmURL) + + // Initialize services. + healthChecker := health.New() + notifier := notify.New() + eventBus := events.New() + + dep := deployer.New(dockerClient, npmClient, db, healthChecker, notifier, eventBus, encKey) + + // Initialize webhook handler. + webhookHandler := webhook.NewHandler(db, dep, dockerClient) + + // Ensure webhook secret exists. + _, err = webhook.EnsureWebhookSecret(db) + if err != nil { + slog.Error("ensure webhook secret", "error", err) + os.Exit(1) + } + slog.Info("webhook secret configured (use /api/settings/webhook-url to retrieve)") + + // Initialize registry poller. + poller := registry.NewPoller(db, dep, encKey) + pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval) + if pollingInterval != "" { + if err := poller.Start(pollingInterval); err != nil { + slog.Warn("failed to start poller", "error", err) + } + } + + // Build API server. + apiServer := api.NewServer(db, dockerClient, dep, webhookHandler, eventBus, encKey) + router := apiServer.Router() + + // Serve embedded static files for the SPA frontend. + // The embed.FS has "web/build" as a prefix, so we sub it to get the root. + webBuildFS, err := fs.Sub(dockerwatcher.WebBuildFS, "web/build") + if err != nil { + slog.Warn("embedded frontend not available", "error", err) + } else { + staticHandler := api.StaticHandler(webBuildFS) + // Handle all non-API routes with the static file server. + router.NotFound(staticHandler.ServeHTTP) + } + + // Start HTTP server. + addr := envOrDefault("LISTEN_ADDR", ":8080") + httpServer := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 30 * time.Second, + // WriteTimeout is disabled (0) to support SSE long-lived connections. + // Individual non-SSE handlers should use context timeouts as needed. + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + + // Graceful shutdown. + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGTERM) + + go func() { + slog.Info("Docker Watcher started", "addr", addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server error", "error", err) + os.Exit(1) + } + }() + + <-done + slog.Info("shutting down...") + + // Stop accepting new work. + poller.Stop() + + // Drain in-progress deploys. + dep.Drain() + + // Shut down HTTP server. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + slog.Error("HTTP server shutdown error", "error", err) + } + + // Close database. + if err := db.Close(); err != nil { + slog.Error("database close error", "error", err) + } + + slog.Info("Docker Watcher stopped") +} + +// envOrDefault reads an environment variable or returns the fallback value. +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// ensureDefaultAdmin creates a default admin user on first launch if no users exist. +// The password comes from ADMIN_PASSWORD env var, defaulting to "admin". +func ensureDefaultAdmin(db *store.Store) error { + count, err := db.UserCount() + if err != nil { + return err + } + if count > 0 { + return nil // Users already exist, skip. + } + + password := envOrDefault("ADMIN_PASSWORD", "admin") + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + + _, err = db.CreateUser(store.User{ + Username: "admin", + PasswordHash: hash, + Email: "", + Role: "admin", + }) + if err != nil { + // Ignore duplicate key errors (race condition on concurrent startup). + if errors.Is(err, store.ErrNotFound) { + return nil + } + return err + } + + slog.Info("default admin user created", "username", "admin") + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80722d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + docker-watcher: + build: . + image: docker-watcher:latest + container_name: docker-watcher + restart: unless-stopped + ports: + - "8080:8080" + volumes: + # Mount Docker socket for container management. + - /var/run/docker.sock:/var/run/docker.sock + # Persistent data (SQLite database). + - docker-watcher-data:/app/data + # Optional seed config (read on first launch only). + - ./docker-watcher.yaml:/app/docker-watcher.yaml:ro + environment: + # Required: protects all credentials stored in the database. + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env} + # Optional: default admin password on first launch (default: "admin"). + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} + # Optional: override seed file location. + - SEED_FILE=/app/docker-watcher.yaml + # Optional: override data directory. + - DATA_DIR=/app/data + # Optional: override listen address. + - LISTEN_ADDR=:8080 + # Optional: override NPM URL (otherwise uses value from settings). + # - NPM_URL=http://npm:81 + # Optional: override polling interval. + # - POLLING_INTERVAL=5m + networks: + - staging-net + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/auth/login"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + docker-watcher-data: + driver: local + +networks: + staging-net: + external: true diff --git a/docker-watcher.example.yaml b/docker-watcher.example.yaml new file mode 100644 index 0000000..072b5a4 --- /dev/null +++ b/docker-watcher.example.yaml @@ -0,0 +1,78 @@ +# Docker Watcher — Seed Configuration +# +# This file is read ONCE on first launch to populate the SQLite database. +# After import, all configuration is managed via the Web UI. +# The only required env var is ENCRYPTION_KEY (used to encrypt credentials in DB). +# +# Place this file as ./docker-watcher.yaml (or set SEED_FILE env var) +# and start Docker Watcher. Once imported, the file is never read again. + +global: + # Your base domain — must have a Cloudflare wildcard DNS record (*.domain) + domain: example.com + + # The IP address of your Docker host + server_ip: 192.168.1.100 + + # Docker network that containers will be attached to + network: staging-net + + # Pattern for generating subdomains. Available placeholders: {stage}, {project} + subdomain_pattern: "stage-{stage}-{project}" + + # Webhook URL for deploy notifications (optional) + notification_url: https://notify.example.com/webhook + + # Nginx Proxy Manager connection + npm: + url: http://npm:81 + email: admin@example.com + password: "your-npm-password-here" + +# Container registries — referenced by name in project definitions +registries: + gitea: + url: https://git.example.com + type: gitea + token: "your-registry-token-here" + + # github: + # url: https://ghcr.io + # type: github + # token: "ghp_your-github-token-here" + +# Projects to deploy — each project has an image and one or more stages +projects: + my-web-app: + registry: gitea + image: git.example.com/org/my-web-app + port: 3000 + healthcheck: /api/health + + # Environment variables passed to the container + env: + NODE_ENV: production + + # Volume mounts (host:container) + # volumes: + # /data/uploads: /app/uploads + + stages: + dev: + tag_pattern: "dev-*" + auto_deploy: true + max_instances: 5 + + rel: + tag_pattern: "v*" + auto_deploy: false + max_instances: 2 + + prod: + tag_pattern: "v*" + auto_deploy: false + confirm: true + promote_from: rel + max_instances: 2 + # Custom subdomain (instead of the pattern-generated one) + subdomain: my-app diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..54c9737 --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module github.com/alexei/docker-watcher + +go 1.24.0 + +toolchain go1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/moby/moby/api v1.54.0 + github.com/moby/moby/client v0.3.0 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.28.0 + golang.org/x/oauth2 v0.25.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) + +// Prevent the +incompatible monorepo from being pulled (conflicts with moby/moby/client submodule). +replace github.com/moby/moby => github.com/moby/moby/client v0.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4374b63 --- /dev/null +++ b/go.sum @@ -0,0 +1,125 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.0 h1:7kbUgyiKcoBhm0UrWbdrMs7RX8dnwzURKVbZGy2GnL0= +github.com/moby/moby/api v1.54.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.3.0 h1:UUGL5okry+Aomj3WhGt9Aigl3ZOxZGqR7XPo+RLPlKs= +github.com/moby/moby/client v0.3.0/go.mod h1:HJgFbJRvogDQjbM8fqc1MCEm4mIAGMLjXbgwoZp6jCQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..a099cc8 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,331 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/auth" + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" +) + +// login handles POST /api/auth/login. +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var req auth.LoginRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Username == "" || req.Password == "" { + respondError(w, http.StatusBadRequest, "username and password are required") + return + } + + user, err := s.store.GetUserByUsername(req.Username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondError(w, http.StatusUnauthorized, "invalid credentials") + return + } + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil { + respondError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + token, err := s.localAuth.GenerateToken(auth.Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, token) +} + +// currentUser handles GET /api/auth/me — returns the authenticated user. +func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + respondError(w, http.StatusUnauthorized, "not authenticated") + return + } + + user, err := s.store.GetUserByID(claims.UserID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, user) +} + +// oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider. +func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) { + if s.oidcProvider == nil { + respondError(w, http.StatusBadRequest, "OIDC is not configured") + return + } + + // Generate random state. + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate state") + return + } + state := hex.EncodeToString(stateBytes) + + // Store state in a short-lived cookie for validation on callback. + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: "/api/auth/oidc", + MaxAge: 300, // 5 minutes + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound) +} + +// oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens. +func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { + if s.oidcProvider == nil { + respondError(w, http.StatusBadRequest, "OIDC is not configured") + return + } + + // Validate state parameter. + stateCookie, err := r.Cookie("oidc_state") + if err != nil || stateCookie.Value == "" { + respondError(w, http.StatusBadRequest, "missing OIDC state") + return + } + + if r.URL.Query().Get("state") != stateCookie.Value { + respondError(w, http.StatusBadRequest, "invalid OIDC state") + return + } + + // Clear the state cookie. + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: "", + Path: "/api/auth/oidc", + MaxAge: -1, + }) + + code := r.URL.Query().Get("code") + if code == "" { + respondError(w, http.StatusBadRequest, "missing authorization code") + return + } + + userInfo, err := s.oidcProvider.Exchange(r.Context(), code) + if err != nil { + slog.Error("OIDC exchange failed", "error", err) + respondError(w, http.StatusInternalServerError, "OIDC authentication failed") + return + } + + // Find or create local user linked to the OIDC identity. + username := userInfo.Username + if username == "" { + username = userInfo.Email + } + if username == "" { + username = userInfo.Subject + } + + user, err := s.store.GetUserByUsername(username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + // Auto-create user from OIDC. + user, err = s.store.CreateUser(store.User{ + Username: username, + Email: userInfo.Email, + Role: "viewer", // OIDC users default to viewer; admin promotes via settings + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + return + } + } else { + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + } + + token, err := s.localAuth.GenerateToken(auth.Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error()) + return + } + + // Redirect to frontend with token in query parameter. + // The frontend extracts the token and stores it in localStorage. + http.Redirect(w, r, "/?token="+token.Token, http.StatusFound) +} + +// getAuthSettings handles GET /api/auth/settings. +func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) { + as, err := s.store.GetAuthSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.Error()) + return + } + // Mask the client secret for the response. + if as.OIDCClientSecret != "" { + as.OIDCClientSecret = "********" + } + respondJSON(w, http.StatusOK, as) +} + +// updateAuthSettings handles PUT /api/auth/settings. +func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) { + var req store.AuthSettings + if !decodeJSON(w, r, &req) { + return + } + + if req.AuthMode != "local" && req.AuthMode != "oidc" { + respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'") + return + } + + // If client secret is masked, preserve the existing encrypted value. + if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" { + existing, err := s.store.GetAuthSettings() + if err == nil { + req.OIDCClientSecret = existing.OIDCClientSecret + } + } else { + // Encrypt the new client secret before storage. + encrypted, err := crypto.Encrypt(s.encKey, req.OIDCClientSecret) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt OIDC client secret") + return + } + // Keep plaintext for OIDC init below, store encrypted. + plaintextSecret := req.OIDCClientSecret + req.OIDCClientSecret = encrypted + defer func() { req.OIDCClientSecret = plaintextSecret }() + } + + if err := s.store.UpdateAuthSettings(req); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error()) + return + } + + // Re-initialize OIDC provider if mode is oidc and config is present. + if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" { + s.initOIDCProvider(r.Context(), req) + } + + respondJSON(w, http.StatusOK, req) +} + +// listUsers handles GET /api/auth/users. +func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.store.GetAllUsers() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list users: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, users) +} + +// createUser handles POST /api/auth/users. +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &req) { + return + } + + if req.Username == "" || req.Password == "" { + respondError(w, http.StatusBadRequest, "username and password are required") + return + } + + if req.Role == "" { + req.Role = "viewer" + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error()) + return + } + + user, err := s.store.CreateUser(store.User{ + Username: req.Username, + PasswordHash: hash, + Email: req.Email, + Role: req.Role, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, user) +} + +// deleteUser handles DELETE /api/auth/users/{uid}. +func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "uid") + + // Prevent deleting the last admin. + user, err := s.store.GetUserByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "user") + return + } + respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error()) + return + } + + if user.Role == "admin" { + users, err := s.store.GetAllUsers() + if err == nil { + adminCount := 0 + for _, u := range users { + if u.Role == "admin" { + adminCount++ + } + } + if adminCount <= 1 { + respondError(w, http.StatusBadRequest, "cannot delete the last admin user") + return + } + } + } + + if err := s.store.DeleteUser(id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} diff --git a/internal/api/config_export.go b/internal/api/config_export.go new file mode 100644 index 0000000..4a0f396 --- /dev/null +++ b/internal/api/config_export.go @@ -0,0 +1,21 @@ +package api + +import ( + "net/http" + + "github.com/alexei/docker-watcher/internal/config" +) + +// exportConfig handles GET /api/config/export — downloads current state as YAML. +func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) { + data, err := config.ExportConfig(s.store) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to export config: "+err.Error()) + return + } + + w.Header().Set("Content-Type", "application/x-yaml") + w.Header().Set("Content-Disposition", "attachment; filename=docker-watcher.yaml") + w.WriteHeader(http.StatusOK) + w.Write(data) +} diff --git a/internal/api/deploys.go b/internal/api/deploys.go new file mode 100644 index 0000000..46c2b82 --- /dev/null +++ b/internal/api/deploys.go @@ -0,0 +1,180 @@ +package api + +import ( + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/alexei/docker-watcher/internal/store" +) + +// listDeploys handles GET /api/deploys. +func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := 50 + if limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + + deploys, err := s.store.GetRecentDeploys(limit) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list deploys: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, deploys) +} + +// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go. +// The new handler supports both SSE streaming and JSON fallback via Accept header. + +// inspectRequest is the expected JSON body for POST /api/deploy/inspect. +type inspectRequest struct { + Image string `json:"image"` +} + +// inspectResponse is the response body for POST /api/deploy/inspect. +type inspectResponse struct { + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` +} + +// inspectImage handles POST /api/deploy/inspect. +// Pulls the image and inspects it for EXPOSE ports and healthcheck config. +func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { + var req inspectRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + + ctx := r.Context() + + // Pull the image first so it's available locally for inspection. + // Split image:tag for the pull call. + imageRef, tag := splitImageTag(req.Image) + if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil { + slog.Warn("pull image for inspect", "image", req.Image, "error", err) + // Try to inspect anyway in case the image is already local. + } + + info, err := s.docker.InspectImage(ctx, req.Image) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to inspect image: "+err.Error()) + return + } + + port := extractPort(info.ExposedPorts) + + respondJSON(w, http.StatusOK, inspectResponse{ + Image: req.Image, + Port: port, + Healthcheck: info.Healthcheck, + }) +} + +// quickDeployRequest is the expected JSON body for POST /api/deploy/quick. +type quickDeployRequest struct { + Name string `json:"name"` + Image string `json:"image"` + Tag string `json:"tag"` + Registry string `json:"registry"` + Port int `json:"port"` +} + +// quickDeploy handles POST /api/deploy/quick. +// Creates a project, a default stage, and triggers a deploy in one call. +func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { + var req quickDeployRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + if req.Tag == "" { + req.Tag = "latest" + } + if req.Name == "" { + // Derive name from image. + parts := strings.Split(req.Image, "/") + req.Name = parts[len(parts)-1] + } + + // Create project. + project, err := s.store.CreateProject(store.Project{ + Name: req.Name, + Image: req.Image, + Registry: req.Registry, + Port: req.Port, + Env: "{}", + Volumes: "{}", + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error()) + return + } + + // Create default stage. + stage, err := s.store.CreateStage(store.Stage{ + ProjectID: project.ID, + Name: "dev", + TagPattern: "*", + AutoDeploy: true, + MaxInstances: 1, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error()) + return + } + + // Trigger deploy asynchronously. + deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error()) + return + } + + respondJSON(w, http.StatusAccepted, map[string]any{ + "project": project, + "stage": stage, + "tag": req.Tag, + "deploy_id": deployID, + "status": "deploying", + }) +} + +// splitImageTag splits "image:tag" into image and tag parts. +// Returns the full string and empty tag if no colon separator is found. +func splitImageTag(ref string) (string, string) { + if idx := strings.LastIndex(ref, ":"); idx != -1 { + afterColon := ref[idx+1:] + if !strings.Contains(afterColon, "/") { + return ref[:idx], afterColon + } + } + return ref, "" +} + +// extractPort parses the first exposed port from Docker EXPOSE entries. +// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found. +func extractPort(exposedPorts []string) int { + if len(exposedPorts) == 0 { + return 0 + } + raw := exposedPorts[0] + if idx := strings.Index(raw, "/"); idx != -1 { + raw = raw[:idx] + } + port, _ := strconv.Atoi(raw) + return port +} diff --git a/internal/api/instances.go b/internal/api/instances.go new file mode 100644 index 0000000..81a3d9b --- /dev/null +++ b/internal/api/instances.go @@ -0,0 +1,194 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// listInstances handles GET /api/projects/{id}/stages/{stage}/instances. +func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + instances, err := s.store.GetInstancesByStageID(stageID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list instances: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, instances) +} + +// deployRequest is the expected JSON body for triggering a deploy. +type deployRequest struct { + ImageTag string `json:"image_tag"` +} + +// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances (trigger deploy). +func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + stageID := chi.URLParam(r, "stage") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req deployRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.ImageTag == "" { + respondError(w, http.StatusBadRequest, "image_tag is required") + return + } + + deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error()) + return + } + respondJSON(w, http.StatusAccepted, map[string]string{ + "status": "deploying", + "deploy_id": deployID, + "project_id": projectID, + "stage_id": stageID, + "image_tag": req.ImageTag, + }) +} + +// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}. +func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { + instanceID := chi.URLParam(r, "iid") + + inst, err := s.store.GetInstanceByID(instanceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "instance") + return + } + respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error()) + return + } + + // Remove the Docker container if it has one. + if inst.ContainerID != "" { + if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil { + slog.Error("remove container", "container_id", inst.ContainerID, "error", err) + } + } + + // Delete instance record. + if err := s.store.DeleteInstance(instanceID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete instance: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID}) +} + +// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop. +func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "stop") +} + +// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start. +func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "start") +} + +// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart. +func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "restart") +} + +// controlInstance performs a stop/start/restart action on an instance's container. +func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) { + instanceID := chi.URLParam(r, "iid") + + inst, err := s.store.GetInstanceByID(instanceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "instance") + return + } + respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error()) + return + } + + if inst.ContainerID == "" { + respondError(w, http.StatusBadRequest, "instance has no container") + return + } + + ctx := r.Context() + var controlErr error + var newStatus string + + switch action { + case "stop": + controlErr = s.docker.StopContainer(ctx, inst.ContainerID, 10) + newStatus = "stopped" + case "start": + controlErr = s.docker.StartContainer(ctx, inst.ContainerID) + newStatus = "running" + case "restart": + controlErr = s.docker.RestartContainer(ctx, inst.ContainerID, 10) + newStatus = "running" + default: + respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action)) + return + } + + if controlErr != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to %s instance: %v", action, controlErr)) + return + } + + // Update status in store. + if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil { + slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "instance_id": instanceID, + "action": action, + "status": newStatus, + }) +} + +// DeployTriggerer is the interface for triggering deployments. +type DeployTriggerer interface { + TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error + AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..07f7345 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,81 @@ +package api + +import ( + "log/slog" + "net/http" + "runtime/debug" + "time" +) + +// logging is an HTTP middleware that logs every request with method, path, +// status code, and duration. +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(wrapped, r) + + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.status, + "duration", time.Since(start).String(), + ) + }) +} + +// recovery is an HTTP middleware that catches panics and returns a 500 response. +func recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + slog.Error("panic recovered", "error", err, "stack", string(debug.Stack())) + respondError(w, http.StatusInternalServerError, "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +// cors is an HTTP middleware that sets permissive CORS headers for development. +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// jsonContentType is an HTTP middleware that sets the default Content-Type to JSON. +func jsonContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +// statusRecorder wraps http.ResponseWriter to capture the status code. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +// Flush delegates to the underlying ResponseWriter if it supports http.Flusher (needed for SSE). +func (r *statusRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..029f19d --- /dev/null +++ b/internal/api/projects.go @@ -0,0 +1,153 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// projectRequest is the expected JSON body for creating/updating a project. +type projectRequest struct { + Name string `json:"name"` + Registry string `json:"registry"` + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` + Env string `json:"env"` + Volumes string `json:"volumes"` +} + +// listProjects handles GET /api/projects. +func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { + projects, err := s.store.GetAllProjects() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list projects: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, projects) +} + +// createProject handles POST /api/projects. +func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { + var req projectRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + if req.Env == "" { + req.Env = "{}" + } + if req.Volumes == "" { + req.Volumes = "{}" + } + + project, err := s.store.CreateProject(store.Project{ + Name: req.Name, + Registry: req.Registry, + Image: req.Image, + Port: req.Port, + Healthcheck: req.Healthcheck, + Env: req.Env, + Volumes: req.Volumes, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, project) +} + +// getProject handles GET /api/projects/{id}. +func (s *Server) getProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + project, err := s.store.GetProjectByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + // Also fetch stages for this project. + stages, err := s.store.GetStagesByProjectID(id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get stages: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "project": project, + "stages": stages, + }) +} + +// updateProject handles PUT /api/projects/{id}. +func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + existing, err := s.store.GetProjectByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + var req projectRequest + if !decodeJSON(w, r, &req) { + return + } + + // Apply updates to existing project, preserving fields not provided. + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.Image != "" { + updated.Image = req.Image + } + updated.Registry = req.Registry + updated.Port = req.Port + updated.Healthcheck = req.Healthcheck + if req.Env != "" { + updated.Env = req.Env + } + if req.Volumes != "" { + updated.Volumes = req.Volumes + } + + if err := s.store.UpdateProject(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update project: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteProject handles DELETE /api/projects/{id}. +func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteProject(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete project: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} diff --git a/internal/api/registries.go b/internal/api/registries.go new file mode 100644 index 0000000..30f8870 --- /dev/null +++ b/internal/api/registries.go @@ -0,0 +1,334 @@ +package api + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/registry" + "github.com/alexei/docker-watcher/internal/store" +) + +// registryRequest is the expected JSON body for creating/updating a registry. +type registryRequest struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Token string `json:"token"` + Owner string `json:"owner"` +} + +// listRegistries handles GET /api/registries. +func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) { + registries, err := s.store.GetAllRegistries() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.Error()) + return + } + + // Strip tokens from response for security. + type safeRegistry struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + HasToken bool `json:"has_token"` + Owner string `json:"owner"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + safe := make([]safeRegistry, len(registries)) + for i, reg := range registries { + safe[i] = safeRegistry{ + ID: reg.ID, + Name: reg.Name, + URL: reg.URL, + Type: reg.Type, + HasToken: reg.Token != "", + Owner: reg.Owner, + CreatedAt: reg.CreatedAt, + UpdatedAt: reg.UpdatedAt, + } + } + respondJSON(w, http.StatusOK, safe) +} + +// createRegistry handles POST /api/registries. +func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) { + var req registryRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.URL == "" { + respondError(w, http.StatusBadRequest, "url is required") + return + } + if req.Type == "" { + req.Type = "generic" + } + + // Encrypt the token if provided. + encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error()) + return + } + + reg, err := s.store.CreateRegistry(store.Registry{ + Name: req.Name, + URL: req.URL, + Type: req.Type, + Token: encToken, + Owner: req.Owner, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, map[string]string{ + "id": reg.ID, + "name": reg.Name, + }) +} + +// updateRegistry handles PUT /api/registries/{id}. +func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + existing, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + var req registryRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.URL != "" { + updated.URL = req.URL + } + if req.Type != "" { + updated.Type = req.Type + } + // Owner can be set to empty string intentionally, so always update it. + updated.Owner = req.Owner + + // Only re-encrypt if a new token is provided. + if req.Token != "" { + encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error()) + return + } + updated.Token = encToken + } + + if err := s.store.UpdateRegistry(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{ + "id": updated.ID, + "name": updated.Name, + }) +} + +// deleteRegistry handles DELETE /api/registries/{id}. +func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteRegistry(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} + +// testRegistryRequest is the expected JSON body for POST /api/registries/{id}/test. +type testRegistryRequest struct { + Image string `json:"image"` +} + +// testRegistry handles POST /api/registries/{id}/test. +// Creates a temp registry client and attempts to list tags. +func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + // Body is optional — if no image provided, just test connectivity. + var req testRegistryRequest + if r.Body != nil && r.ContentLength > 0 { + if !decodeJSON(w, r, &req) { + return + } + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return + } + token = decrypted + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + // If no image provided, just verify we can create a client (basic connectivity test). + if req.Image == "" { + respondJSON(w, http.StatusOK, map[string]any{ + "message": "registry client created successfully", + }) + return + } + + tags, err := client.ListTags(r.Context(), req.Image) + if err != nil { + respondError(w, http.StatusBadGateway, "registry test failed: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "success": true, + "tags": len(tags), + }) +} + +// listRegistryTags handles GET /api/registries/{id}/tags/{image}. +func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + image := chi.URLParam(r, "*") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return + } + token = decrypted + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + tags, err := client.ListTags(r.Context(), image) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list tags: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, tags) +} + +// listRegistryImages handles GET /api/registries/{id}/images. +// Returns all container images available in the registry for the configured owner. +func (s *Server) listRegistryImages(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + if reg.Owner == "" { + respondError(w, http.StatusBadRequest, "registry has no owner configured; set the owner in registry settings") + return + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return + } + token = decrypted + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + // Support comma-separated owners (e.g., "alexei,team-org,other-user"). + owners := strings.Split(reg.Owner, ",") + var allImages []registry.RegistryImage + for _, owner := range owners { + owner = strings.TrimSpace(owner) + if owner == "" { + continue + } + images, err := client.ListImages(r.Context(), owner) + if err != nil { + slog.Warn("failed to list images for owner", "owner", owner, "error", err) + continue + } + allImages = append(allImages, images...) + } + if allImages == nil { + allImages = []registry.RegistryImage{} + } + + respondJSON(w, http.StatusOK, allImages) +} diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..2eedd8b --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "reflect" +) + +// envelope is the standard API response wrapper. +type envelope struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// respondJSON writes a JSON success response with the given status code and data. +// Nil slices are converted to empty arrays to avoid "null" in JSON output. +func respondJSON(w http.ResponseWriter, status int, data any) { + // Convert nil slices to empty arrays so JSON encodes as [] not null. + if data != nil { + v := reflect.ValueOf(data) + if v.Kind() == reflect.Slice && v.IsNil() { + data = reflect.MakeSlice(v.Type(), 0, 0).Interface() + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil { + slog.Error("encode response", "error", err) + } +} + +// respondError writes a JSON error response with the given status code and message. +func respondError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(envelope{Success: false, Error: msg}); err != nil { + slog.Error("encode error response", "error", err) + } +} + +// respondNotFound writes a 404 JSON error response for the given entity type. +func respondNotFound(w http.ResponseWriter, entity string) { + respondError(w, http.StatusNotFound, entity+" not found") +} + +// decodeJSON reads and decodes the request body into the given value. +// Returns false and writes a 400 error response if decoding fails. +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return false + } + return true +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..19144bf --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + "log/slog" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/auth" + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/events" + "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/webhook" +) + +// Server holds all dependencies for the API layer. +type Server struct { + store *store.Store + docker *docker.Client + deployer DeployTriggerer + webhook *webhook.Handler + eventBus *events.Bus + encKey [32]byte + localAuth *auth.LocalAuth + oidcProvider *auth.OIDCProvider +} + +// NewServer creates a new API Server with all required dependencies. +func NewServer( + st *store.Store, + dockerClient *docker.Client, + deployer DeployTriggerer, + webhookHandler *webhook.Handler, + eventBus *events.Bus, + encKey [32]byte, +) *Server { + localAuth := auth.NewLocalAuth(encKey) + + s := &Server{ + store: st, + docker: dockerClient, + deployer: deployer, + webhook: webhookHandler, + eventBus: eventBus, + encKey: encKey, + localAuth: localAuth, + } + + // Try to initialize OIDC provider from stored settings. + authSettings, err := st.GetAuthSettings() + if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" { + s.initOIDCProvider(context.Background(), authSettings) + } + + return s +} + +// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal. +func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) { + // Decrypt the OIDC client secret if it's encrypted. + clientSecret := as.OIDCClientSecret + if clientSecret != "" { + if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil { + clientSecret = decrypted + } + // If decrypt fails, assume it's already plaintext (migration scenario). + } + provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{ + IssuerURL: as.OIDCIssuerURL, + ClientID: as.OIDCClientID, + ClientSecret: clientSecret, + RedirectURL: as.OIDCRedirectURL, + }) + if err != nil { + slog.Warn("failed to initialize OIDC provider", "error", err) + return + } + s.oidcProvider = provider + slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL) +} + +// Router returns a chi router with all API routes mounted. +func (s *Server) Router() chi.Router { + r := chi.NewRouter() + + // Global middleware. + r.Use(recovery) + r.Use(logging) + r.Use(cors) + + r.Route("/api", func(r chi.Router) { + // JSON content type only for API routes (not static files). + r.Use(jsonContentType) + + // Public auth endpoints (no auth required). + r.Post("/auth/login", s.login) + r.Get("/auth/oidc/login", s.oidcLogin) + r.Get("/auth/oidc/callback", s.oidcCallback) + + // Webhook handler (uses its own secret-based auth). + r.Mount("/webhook", s.webhook.Route()) + + // Protected routes: require valid JWT. + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(s.localAuth)) + + // Config export (protected — reveals project/infra details). + r.Get("/config/export", s.exportConfig) + + // Auth management. + r.Get("/auth/me", s.currentUser) + r.Get("/auth/settings", s.getAuthSettings) + r.Put("/auth/settings", s.updateAuthSettings) + r.Get("/auth/users", s.listUsers) + r.Post("/auth/users", s.createUser) + r.Delete("/auth/users/{uid}", s.deleteUser) + + // Project endpoints. + r.Get("/projects", s.listProjects) + r.Post("/projects", s.createProject) + r.Route("/projects/{id}", func(r chi.Router) { + r.Get("/", s.getProject) + r.Put("/", s.updateProject) + r.Delete("/", s.deleteProject) + + // Stage endpoints. + r.Post("/stages", s.createStage) + r.Put("/stages/{stage}", s.updateStage) + r.Delete("/stages/{stage}", s.deleteStage) + + // Stage env override endpoints. + r.Get("/stages/{stage}/env", s.listStageEnv) + r.Post("/stages/{stage}/env", s.createStageEnv) + r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv) + r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv) + + // Instance endpoints. + r.Get("/stages/{stage}/instances", s.listInstances) + r.Post("/stages/{stage}/instances", s.deployInstance) + r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) + + // Instance control endpoints. + r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) + r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) + r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) + + // Volume endpoints. + r.Get("/volumes", s.listVolumes) + r.Post("/volumes", s.createVolume) + r.Put("/volumes/{volId}", s.updateVolume) + r.Delete("/volumes/{volId}", s.deleteVolume) + }) + + // Deploy endpoints. + r.Get("/deploys", s.listDeploys) + r.Get("/deploys/{id}/logs", s.streamDeployLogs) + + // SSE endpoint for real-time instance status and deploy events. + r.Get("/events", s.streamEvents) + + // Quick deploy endpoints. + r.Post("/deploy/inspect", s.inspectImage) + r.Post("/deploy/quick", s.quickDeploy) + + // Registry endpoints. + r.Get("/registries", s.listRegistries) + r.Post("/registries", s.createRegistry) + r.Route("/registries/{id}", func(r chi.Router) { + r.Put("/", s.updateRegistry) + r.Delete("/", s.deleteRegistry) + r.Post("/test", s.testRegistry) + r.Get("/tags/*", s.listRegistryTags) + r.Get("/images", s.listRegistryImages) + }) + + // Settings endpoints. + r.Get("/settings", s.getSettings) + r.Put("/settings", s.updateSettings) + r.Get("/settings/webhook-url", s.getWebhookURL) + r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) + }) + }) + + return r +} diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..bbcf69a --- /dev/null +++ b/internal/api/settings.go @@ -0,0 +1,142 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/webhook" +) + +// settingsRequest is the expected JSON body for updating settings. +type settingsRequest struct { + Domain string `json:"domain"` + ServerIP string `json:"server_ip"` + Network string `json:"network"` + SubdomainPattern string `json:"subdomain_pattern"` + NotificationURL string `json:"notification_url"` + NpmURL string `json:"npm_url"` + NpmEmail string `json:"npm_email"` + NpmPassword string `json:"npm_password"` + PollingInterval string `json:"polling_interval"` +} + +// getSettings handles GET /api/settings. +func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + // Return settings without sensitive fields. + respondJSON(w, http.StatusOK, map[string]any{ + "domain": settings.Domain, + "server_ip": settings.ServerIP, + "network": settings.Network, + "subdomain_pattern": settings.SubdomainPattern, + "notification_url": settings.NotificationURL, + "npm_url": settings.NpmURL, + "npm_email": settings.NpmEmail, + "has_npm_password": settings.NpmPassword != "", + "polling_interval": settings.PollingInterval, + "updated_at": settings.UpdatedAt, + }) +} + +// updateSettings handles PUT /api/settings. +func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { + var req settingsRequest + if !decodeJSON(w, r, &req) { + return + } + + existing, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + updated := existing + if req.Domain != "" { + updated.Domain = req.Domain + } + if req.ServerIP != "" { + updated.ServerIP = req.ServerIP + } + if req.Network != "" { + updated.Network = req.Network + } + if req.SubdomainPattern != "" { + updated.SubdomainPattern = req.SubdomainPattern + } + // Allow clearing notification URL. + updated.NotificationURL = req.NotificationURL + if req.NpmURL != "" { + updated.NpmURL = req.NpmURL + } + if req.NpmEmail != "" { + updated.NpmEmail = req.NpmEmail + } + if req.NpmPassword != "" { + encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error()) + return + } + updated.NpmPassword = encPassword + } + if req.PollingInterval != "" { + updated.PollingInterval = req.PollingInterval + } + + if err := s.store.UpdateSettings(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// getWebhookURL handles GET /api/settings/webhook-url. +func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + webhookURL := "" + if settings.WebhookSecret != "" && settings.Domain != "" { + webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "webhook_url": webhookURL, + }) +} + +// regenerateWebhookSecret handles POST /api/settings/regenerate. +func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) { + secret, err := webhook.RegenerateWebhookSecret(s.store) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error()) + return + } + + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + webhookURL := "" + if settings.Domain != "" { + webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "webhook_url": webhookURL, + "webhook_secret": secret, + }) +} + diff --git a/internal/api/sse.go b/internal/api/sse.go new file mode 100644 index 0000000..fb2a2b5 --- /dev/null +++ b/internal/api/sse.go @@ -0,0 +1,192 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/events" + "github.com/alexei/docker-watcher/internal/store" +) + +// streamDeployLogs handles GET /api/deploys/{id}/logs. +// It supports both SSE streaming and JSON fallback based on the Accept header. +// +// SSE mode (Accept: text/event-stream): +// +// Streams deploy log events in real-time. Existing logs are sent first, +// then new logs are pushed as they arrive via the event bus. +// +// JSON mode (default): +// +// Returns all existing deploy logs as a JSON array. +func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) { + deployID := chi.URLParam(r, "id") + + // Verify deploy exists. + deploy, err := s.store.GetDeployByID(deployID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "deploy") + return + } + respondError(w, http.StatusInternalServerError, "failed to get deploy: "+err.Error()) + return + } + + // JSON fallback: return existing logs as array. + accept := r.Header.Get("Accept") + if !strings.Contains(accept, "text/event-stream") { + logs, err := s.store.GetDeployLogs(deployID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get deploy logs: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, logs) + return + } + + // SSE mode. + flusher, ok := w.(http.Flusher) + if !ok { + respondError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Send existing logs first. + existingLogs, err := s.store.GetDeployLogs(deployID) + if err != nil { + slog.Error("get existing deploy logs", "error", err) + } else { + for _, entry := range existingLogs { + writeSSE(w, flusher, events.Event{ + Type: events.EventDeployLog, + Payload: events.DeployLogPayload{ + DeployID: deployID, + Message: entry.Message, + Level: entry.Level, + }, + }) + } + } + + // If deploy is already finished, send completion and close. + if isTerminalStatus(deploy.Status) { + writeSSE(w, flusher, events.Event{ + Type: events.EventDeployStatus, + Payload: events.DeployStatusPayload{ + DeployID: deployID, + ProjectID: deploy.ProjectID, + StageID: deploy.StageID, + ImageTag: deploy.ImageTag, + Status: deploy.Status, + Error: deploy.Error, + }, + }) + return + } + + // Subscribe to new deploy log events for this deploy. + sub := s.eventBus.Subscribe(func(evt events.Event) bool { + switch payload := evt.Payload.(type) { + case events.DeployLogPayload: + return payload.DeployID == deployID + case events.DeployStatusPayload: + return payload.DeployID == deployID + default: + return false + } + }) + defer s.eventBus.Unsubscribe(sub) + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case evt, ok := <-sub: + if !ok { + return + } + writeSSE(w, flusher, evt) + + // Close stream when deploy reaches terminal status. + if evt.Type == events.EventDeployStatus { + if payload, ok := evt.Payload.(events.DeployStatusPayload); ok { + if isTerminalStatus(payload.Status) { + return + } + } + } + } + } +} + +// streamEvents handles GET /api/events. +// It streams instance status changes and deploy status changes via SSE. +func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + respondError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Subscribe to instance status and deploy status events. + sub := s.eventBus.Subscribe(func(evt events.Event) bool { + return evt.Type == events.EventInstanceStatus || evt.Type == events.EventDeployStatus + }) + defer s.eventBus.Unsubscribe(sub) + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case evt, ok := <-sub: + if !ok { + return + } + writeSSE(w, flusher, evt) + } + } +} + +// writeSSE writes a single SSE event to the response writer and flushes. +func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) { + data, err := json.Marshal(evt) + if err != nil { + slog.Error("marshal SSE event", "error", err) + return + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() +} + +// isTerminalStatus returns true if the deploy status is final. +func isTerminalStatus(status string) bool { + switch status { + case "success", "failed", "rolled_back": + return true + default: + return false + } +} diff --git a/internal/api/stage_env.go b/internal/api/stage_env.go new file mode 100644 index 0000000..5d8e746 --- /dev/null +++ b/internal/api/stage_env.go @@ -0,0 +1,176 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" +) + +// stageEnvRequest is the expected JSON body for creating/updating a stage env override. +type stageEnvRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Encrypted *bool `json:"encrypted"` +} + +// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env. +func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + envs, err := s.store.GetStageEnvByStageID(stageID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list stage env: "+err.Error()) + return + } + + // Mask encrypted values in the response. + masked := make([]store.StageEnv, len(envs)) + for i, env := range envs { + masked[i] = env + if env.Encrypted { + masked[i].Value = "••••••••" + } + } + + respondJSON(w, http.StatusOK, masked) +} + +// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env. +func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req stageEnvRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Key == "" { + respondError(w, http.StatusBadRequest, "key is required") + return + } + + encrypted := false + if req.Encrypted != nil { + encrypted = *req.Encrypted + } + + value := req.Value + if encrypted && value != "" { + enc, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error()) + return + } + value = enc + } + + env, err := s.store.CreateStageEnv(store.StageEnv{ + StageID: stageID, + Key: req.Key, + Value: value, + Encrypted: encrypted, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage env: "+err.Error()) + return + } + + // Mask encrypted value in the response. + if env.Encrypted { + env.Value = "••••••••" + } + + respondJSON(w, http.StatusCreated, env) +} + +// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}. +func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) { + envID := chi.URLParam(r, "envId") + + existing, err := s.store.GetStageEnvByID(envID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage env") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage env: "+err.Error()) + return + } + + var req stageEnvRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Key != "" { + updated.Key = req.Key + } + if req.Encrypted != nil { + updated.Encrypted = *req.Encrypted + } + + // Only update value if provided (allows updating key/encrypted without changing the value). + if req.Value != "" { + value := req.Value + if updated.Encrypted { + enc, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error()) + return + } + value = enc + } + updated.Value = value + } + + if err := s.store.UpdateStageEnv(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update stage env: "+err.Error()) + return + } + + // Mask encrypted value in the response. + if updated.Encrypted { + updated.Value = "••••••••" + } + + respondJSON(w, http.StatusOK, updated) +} + +// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}. +func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) { + envID := chi.URLParam(r, "envId") + if err := s.store.DeleteStageEnv(envID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage env") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete stage env: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": envID}) +} diff --git a/internal/api/stages.go b/internal/api/stages.go new file mode 100644 index 0000000..25219fc --- /dev/null +++ b/internal/api/stages.go @@ -0,0 +1,137 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// stageRequest is the expected JSON body for creating/updating a stage. +type stageRequest struct { + Name string `json:"name"` + TagPattern string `json:"tag_pattern"` + AutoDeploy *bool `json:"auto_deploy"` + MaxInstances *int `json:"max_instances"` + Confirm *bool `json:"confirm"` + PromoteFrom string `json:"promote_from"` + Subdomain string `json:"subdomain"` +} + +// createStage handles POST /api/projects/{id}/stages. +func (s *Server) createStage(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + var req stageRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.TagPattern == "" { + req.TagPattern = "*" + } + + autoDeploy := false + if req.AutoDeploy != nil { + autoDeploy = *req.AutoDeploy + } + maxInstances := 1 + if req.MaxInstances != nil { + maxInstances = *req.MaxInstances + } + confirm := false + if req.Confirm != nil { + confirm = *req.Confirm + } + + stage, err := s.store.CreateStage(store.Stage{ + ProjectID: projectID, + Name: req.Name, + TagPattern: req.TagPattern, + AutoDeploy: autoDeploy, + MaxInstances: maxInstances, + Confirm: confirm, + PromoteFrom: req.PromoteFrom, + Subdomain: req.Subdomain, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, stage) +} + +// updateStage handles PUT /api/projects/{id}/stages/{stage}. +func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + existing, err := s.store.GetStageByID(stageID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req stageRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.TagPattern != "" { + updated.TagPattern = req.TagPattern + } + if req.AutoDeploy != nil { + updated.AutoDeploy = *req.AutoDeploy + } + if req.MaxInstances != nil { + updated.MaxInstances = *req.MaxInstances + } + if req.Confirm != nil { + updated.Confirm = *req.Confirm + } + updated.PromoteFrom = req.PromoteFrom + updated.Subdomain = req.Subdomain + + if err := s.store.UpdateStage(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update stage: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteStage handles DELETE /api/projects/{id}/stages/{stage}. +func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + if err := s.store.DeleteStage(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete stage: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID}) +} diff --git a/internal/api/static.go b/internal/api/static.go new file mode 100644 index 0000000..ff2bdfe --- /dev/null +++ b/internal/api/static.go @@ -0,0 +1,47 @@ +package api + +import ( + "io" + "io/fs" + "net/http" + "strings" +) + +// StaticHandler serves embedded SPA files with fallback to index.html +// for all non-API routes (SPA client-side routing support). +func StaticHandler(webFS fs.FS) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip API routes — they are handled by the API router. + if strings.HasPrefix(r.URL.Path, "/api") { + http.NotFound(w, r) + return + } + + // Try to serve the exact file from the embedded FS. + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + // Check if file exists in the embedded FS. + if f, err := webFS.Open(path); err == nil { + stat, statErr := f.Stat() + f.Close() + if statErr == nil && !stat.IsDir() { + // Serve the actual file. Use http.ServeContent for correct MIME detection. + file, _ := webFS.Open(path) + defer file.Close() + http.ServeContent(w, r, stat.Name(), stat.ModTime(), file.(io.ReadSeeker)) + return + } + } + + // File not found: serve index.html for SPA client-side routing. + indexFile, _ := webFS.Open("index.html") + if indexFile != nil { + defer indexFile.Close() + stat, _ := indexFile.Stat() + http.ServeContent(w, r, "index.html", stat.ModTime(), indexFile.(io.ReadSeeker)) + } + }) +} diff --git a/internal/api/volumes.go b/internal/api/volumes.go new file mode 100644 index 0000000..01c52e6 --- /dev/null +++ b/internal/api/volumes.go @@ -0,0 +1,143 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// volumeRequest is the expected JSON body for creating/updating a volume. +type volumeRequest struct { + Source string `json:"source"` + Target string `json:"target"` + Mode string `json:"mode"` +} + +// listVolumes handles GET /api/projects/{id}/volumes. +func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + vols, err := s.store.GetVolumesByProjectID(projectID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, vols) +} + +// createVolume handles POST /api/projects/{id}/volumes. +func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + var req volumeRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Source == "" { + respondError(w, http.StatusBadRequest, "source is required") + return + } + if req.Target == "" { + respondError(w, http.StatusBadRequest, "target is required") + return + } + if req.Mode == "" { + req.Mode = "shared" + } + if req.Mode != "shared" && req.Mode != "isolated" { + respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + return + } + + vol, err := s.store.CreateVolume(store.Volume{ + ProjectID: projectID, + Source: req.Source, + Target: req.Target, + Mode: req.Mode, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, vol) +} + +// updateVolume handles PUT /api/projects/{id}/volumes/{volId}. +func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { + volID := chi.URLParam(r, "volId") + + existing, err := s.store.GetVolumeByID(volID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "volume") + return + } + respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error()) + return + } + + var req volumeRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Source != "" { + updated.Source = req.Source + } + if req.Target != "" { + updated.Target = req.Target + } + if req.Mode != "" { + if req.Mode != "shared" && req.Mode != "isolated" { + respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + return + } + updated.Mode = req.Mode + } + + if err := s.store.UpdateVolume(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}. +func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) { + volID := chi.URLParam(r, "volId") + if err := s.store.DeleteVolume(volID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "volume") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) +} diff --git a/internal/auth/local.go b/internal/auth/local.go new file mode 100644 index 0000000..bd1cd37 --- /dev/null +++ b/internal/auth/local.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// ErrInvalidCredentials indicates that the supplied username/password is wrong. +var ErrInvalidCredentials = errors.New("invalid credentials") + +// ErrInvalidToken indicates that the JWT is invalid or expired. +var ErrInvalidToken = errors.New("invalid or expired token") + +// TokenExpiry is the lifetime of a JWT session token. +const TokenExpiry = 24 * time.Hour + +// jwtClaims extends jwt.RegisteredClaims with application-specific fields. +type jwtClaims struct { + jwt.RegisteredClaims + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +// LocalAuth handles password hashing and JWT token management for local auth mode. +type LocalAuth struct { + jwtSecret []byte +} + +// NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key +// using HMAC-SHA256. +func NewLocalAuth(encKey [32]byte) *LocalAuth { + mac := hmac.New(sha256.New, encKey[:]) + mac.Write([]byte("docker-watcher-jwt-secret")) + return &LocalAuth{ + jwtSecret: mac.Sum(nil), + } +} + +// HashPassword hashes a plaintext password using bcrypt. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(hash), nil +} + +// CheckPassword compares a plaintext password against a bcrypt hash. +func CheckPassword(hash, password string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return ErrInvalidCredentials + } + return nil +} + +// GenerateToken creates a signed JWT for the given user claims. +func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) { + expiresAt := time.Now().Add(TokenExpiry) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "docker-watcher", + }, + UserID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + }) + + signed, err := token.SignedString(la.jwtSecret) + if err != nil { + return SessionToken{}, fmt.Errorf("sign token: %w", err) + } + + return SessionToken{ + Token: signed, + ExpiresAt: expiresAt, + }, nil +} + +// ValidateToken parses and validates a JWT, returning the embedded claims. +func (la *LocalAuth) ValidateToken(tokenString string) (Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return la.jwtSecret, nil + }) + if err != nil { + return Claims{}, ErrInvalidToken + } + + claims, ok := token.Claims.(*jwtClaims) + if !ok || !token.Valid { + return Claims{}, ErrInvalidToken + } + + return Claims{ + UserID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + }, nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..c0416f0 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "net/http" + "strings" +) + +// contextKey is the type for context value keys used by the auth package. +type contextKey string + +const claimsKey contextKey = "auth_claims" + +// Middleware returns an HTTP middleware that protects routes by requiring a valid JWT. +// It extracts the token from the Authorization header (Bearer scheme) or the "token" +// query parameter (for SSE connections). +// Unauthenticated requests receive a 401 JSON response. +func Middleware(la *LocalAuth) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := extractToken(r) + if tokenStr == "" { + http.Error(w, `{"success":false,"error":"authentication required"}`, http.StatusUnauthorized) + return + } + + claims, err := la.ValidateToken(tokenStr) + if err != nil { + http.Error(w, `{"success":false,"error":"invalid or expired token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), claimsKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// AdminOnly returns an HTTP middleware that requires the authenticated user to have +// the "admin" role. +func AdminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := ClaimsFromContext(r.Context()) + if !ok || claims.Role != "admin" { + http.Error(w, `{"success":false,"error":"admin access required"}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// ClaimsFromContext retrieves the authenticated user's claims from the request context. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + claims, ok := ctx.Value(claimsKey).(Claims) + return claims, ok +} + +// extractToken gets the JWT from the Authorization header or "token" query param. +func extractToken(r *http.Request) string { + // Try Authorization: Bearer + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + // Fall back to query parameter (used by SSE and browser-based connections). + return r.URL.Query().Get("token") +} diff --git a/internal/auth/models.go b/internal/auth/models.go new file mode 100644 index 0000000..3a182aa --- /dev/null +++ b/internal/auth/models.go @@ -0,0 +1,42 @@ +package auth + +import "time" + +// User represents an authenticated user stored in the database. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Email string `json:"email"` + Role string `json:"role"` // admin, viewer + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AuthSettings holds the authentication configuration (single-row pattern). +type AuthSettings struct { + AuthMode string `json:"auth_mode"` // local, oidc + OIDCClientID string `json:"oidc_client_id"` + OIDCClientSecret string `json:"-"` + OIDCIssuerURL string `json:"oidc_issuer_url"` + OIDCRedirectURL string `json:"oidc_redirect_url"` +} + +// Claims represents the JWT token claims. +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +// SessionToken is the response sent to the client after successful authentication. +type SessionToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// LoginRequest is the expected JSON body for the login endpoint. +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..edc6782 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// OIDCProvider wraps an OIDC provider and OAuth2 configuration. +type OIDCProvider struct { + provider *oidc.Provider + oauth2Config oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// OIDCConfig holds the configuration needed to set up an OIDC provider. +type OIDCConfig struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string +} + +// OIDCUserInfo represents the user information extracted from an OIDC ID token. +type OIDCUserInfo struct { + Subject string `json:"sub"` + Email string `json:"email"` + Username string `json:"preferred_username"` + Name string `json:"name"` +} + +// NewOIDCProvider initializes an OIDC provider using the discovery URL. +func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (*OIDCProvider, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("create oidc provider: %w", err) + } + + oauth2Config := oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + + return &OIDCProvider{ + provider: provider, + oauth2Config: oauth2Config, + verifier: verifier, + }, nil +} + +// AuthCodeURL returns the URL to redirect the user to for OIDC authentication. +func (op *OIDCProvider) AuthCodeURL(state string) string { + return op.oauth2Config.AuthCodeURL(state) +} + +// Exchange trades an authorization code for tokens and returns the user info +// extracted from the ID token. +func (op *OIDCProvider) Exchange(ctx context.Context, code string) (OIDCUserInfo, error) { + token, err := op.oauth2Config.Exchange(ctx, code) + if err != nil { + return OIDCUserInfo{}, fmt.Errorf("exchange code: %w", err) + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return OIDCUserInfo{}, fmt.Errorf("no id_token in response") + } + + idToken, err := op.verifier.Verify(ctx, rawIDToken) + if err != nil { + return OIDCUserInfo{}, fmt.Errorf("verify id_token: %w", err) + } + + var userInfo OIDCUserInfo + if err := idToken.Claims(&userInfo); err != nil { + return OIDCUserInfo{}, fmt.Errorf("parse id_token claims: %w", err) + } + + return userInfo, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e66327b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// SeedConfig represents the top-level YAML seed configuration. +type SeedConfig struct { + Global GlobalConfig `yaml:"global"` + Registries map[string]RegistryDef `yaml:"registries"` + Projects map[string]ProjectDef `yaml:"projects"` +} + +// GlobalConfig holds domain-wide settings from the seed file. +type GlobalConfig struct { + Domain string `yaml:"domain"` + ServerIP string `yaml:"server_ip"` + Network string `yaml:"network"` + SubdomainPattern string `yaml:"subdomain_pattern"` + NotificationURL string `yaml:"notification_url"` + Npm NpmConfig `yaml:"npm"` +} + +// NpmConfig holds Nginx Proxy Manager connection details. +type NpmConfig struct { + URL string `yaml:"url"` + Email string `yaml:"email"` + Password string `yaml:"password"` +} + +// RegistryDef defines a container registry from the seed file. +type RegistryDef struct { + URL string `yaml:"url"` + Type string `yaml:"type"` + Token string `yaml:"token"` +} + +// ProjectDef defines a project from the seed file. +type ProjectDef struct { + Registry string `yaml:"registry"` + Image string `yaml:"image"` + Port int `yaml:"port"` + Healthcheck string `yaml:"healthcheck"` + Env map[string]string `yaml:"env"` + Volumes map[string]string `yaml:"volumes"` + Stages map[string]StageDef `yaml:"stages"` +} + +// StageDef defines a deployment stage from the seed file. +type StageDef struct { + TagPattern string `yaml:"tag_pattern"` + AutoDeploy bool `yaml:"auto_deploy"` + MaxInstances int `yaml:"max_instances"` + Confirm bool `yaml:"confirm"` + PromoteFrom string `yaml:"promote_from"` + Subdomain string `yaml:"subdomain"` +} + +// LoadSeedFile reads and parses the YAML seed config from the given path. +func LoadSeedFile(path string) (SeedConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return SeedConfig{}, fmt.Errorf("read seed file: %w", err) + } + + return ParseSeed(data) +} + +// ParseSeed parses raw YAML bytes into a SeedConfig. +func ParseSeed(data []byte) (SeedConfig, error) { + var cfg SeedConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return SeedConfig{}, fmt.Errorf("parse yaml: %w", err) + } + + if err := validate(cfg); err != nil { + return SeedConfig{}, fmt.Errorf("validate seed config: %w", err) + } + + return cfg, nil +} + +// validate checks that required fields are present in the seed config. +func validate(cfg SeedConfig) error { + if cfg.Global.Domain == "" { + return fmt.Errorf("global.domain is required") + } + + for name, proj := range cfg.Projects { + if proj.Image == "" { + return fmt.Errorf("project %q: image is required", name) + } + if proj.Registry != "" { + if _, ok := cfg.Registries[proj.Registry]; !ok { + return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry) + } + } + for stageName, stage := range proj.Stages { + if stage.TagPattern == "" { + return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName) + } + if stage.MaxInstances < 0 { + return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName) + } + } + } + + return nil +} diff --git a/internal/config/export.go b/internal/config/export.go new file mode 100644 index 0000000..76f9f0a --- /dev/null +++ b/internal/config/export.go @@ -0,0 +1,118 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/alexei/docker-watcher/internal/store" + "gopkg.in/yaml.v3" +) + +// ExportConfig reads the current database state and produces a SeedConfig YAML +// representation. Credential fields (tokens, passwords) are exported as placeholder +// strings since they are encrypted in the database. +func ExportConfig(db *store.Store) ([]byte, error) { + cfg, err := buildSeedConfig(db) + if err != nil { + return nil, fmt.Errorf("build seed config: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal yaml: %w", err) + } + + return data, nil +} + +// buildSeedConfig constructs a SeedConfig from the current database state. +func buildSeedConfig(db *store.Store) (SeedConfig, error) { + settings, err := db.GetSettings() + if err != nil { + return SeedConfig{}, fmt.Errorf("get settings: %w", err) + } + + registries, err := db.GetAllRegistries() + if err != nil { + return SeedConfig{}, fmt.Errorf("get registries: %w", err) + } + + projects, err := db.GetAllProjects() + if err != nil { + return SeedConfig{}, fmt.Errorf("get projects: %w", err) + } + + cfg := SeedConfig{ + Global: GlobalConfig{ + Domain: settings.Domain, + ServerIP: settings.ServerIP, + Network: settings.Network, + SubdomainPattern: settings.SubdomainPattern, + NotificationURL: settings.NotificationURL, + Npm: NpmConfig{ + URL: settings.NpmURL, + Email: settings.NpmEmail, + Password: "CHANGE_ME", // Encrypted value, export placeholder. + }, + }, + Registries: make(map[string]RegistryDef), + Projects: make(map[string]ProjectDef), + } + + for _, reg := range registries { + cfg.Registries[reg.Name] = RegistryDef{ + URL: reg.URL, + Type: reg.Type, + Token: "CHANGE_ME", // Encrypted value, export placeholder. + } + } + + for _, proj := range projects { + stages, err := db.GetStagesByProjectID(proj.ID) + if err != nil { + return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err) + } + + stageDefs := make(map[string]StageDef) + for _, st := range stages { + stageDefs[st.Name] = StageDef{ + TagPattern: st.TagPattern, + AutoDeploy: st.AutoDeploy, + MaxInstances: st.MaxInstances, + Confirm: st.Confirm, + PromoteFrom: st.PromoteFrom, + Subdomain: st.Subdomain, + } + } + + envMap := parseJSONMap(proj.Env) + volMap := parseJSONMap(proj.Volumes) + + cfg.Projects[proj.Name] = ProjectDef{ + Registry: proj.Registry, + Image: proj.Image, + Port: proj.Port, + Healthcheck: proj.Healthcheck, + Env: envMap, + Volumes: volMap, + Stages: stageDefs, + } + } + + return cfg, nil +} + +// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure. +func parseJSONMap(jsonStr string) map[string]string { + if jsonStr == "" || jsonStr == "{}" { + return nil + } + var m map[string]string + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return nil + } + if len(m) == 0 { + return nil + } + return m +} diff --git a/internal/config/seed.go b/internal/config/seed.go new file mode 100644 index 0000000..5c27649 --- /dev/null +++ b/internal/config/seed.go @@ -0,0 +1,194 @@ +package config + +import ( + "encoding/json" + "fmt" + "log" + "os" + "time" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" + "github.com/google/uuid" +) + +// ImportSeed loads the seed YAML file and imports its contents into the store. +// Import is idempotent: it is skipped if any projects or registries already exist. +// Credential fields (registry tokens, NPM password) are encrypted before storage. +func ImportSeed(db *store.Store, seedPath string) error { + if _, err := os.Stat(seedPath); os.IsNotExist(err) { + log.Printf("No seed file at %s, skipping import", seedPath) + return nil + } + + populated, err := isPopulated(db) + if err != nil { + return fmt.Errorf("check if db is populated: %w", err) + } + if populated { + log.Println("Database already has data, skipping seed import") + return nil + } + + cfg, err := LoadSeedFile(seedPath) + if err != nil { + return fmt.Errorf("load seed file: %w", err) + } + + encKey, err := crypto.KeyFromEnv() + if err != nil { + return fmt.Errorf("encryption key: %w", err) + } + + if err := importAll(db, cfg, encKey); err != nil { + return fmt.Errorf("import seed: %w", err) + } + + log.Printf("Seed config imported from %s", seedPath) + return nil +} + +// isPopulated returns true if the store already contains projects or registries. +func isPopulated(db *store.Store) (bool, error) { + projects, err := db.GetAllProjects() + if err != nil { + return false, fmt.Errorf("get projects: %w", err) + } + if len(projects) > 0 { + return true, nil + } + + registries, err := db.GetAllRegistries() + if err != nil { + return false, fmt.Errorf("get registries: %w", err) + } + return len(registries) > 0, nil +} + +// now returns the current time formatted for SQLite storage. +func now() string { + return time.Now().UTC().Format("2006-01-02 15:04:05") +} + +// importAll runs the full seed import inside a database transaction. +// Uses raw SQL within the transaction so all inserts are atomic. +func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error { + tx, err := db.DB().Begin() + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op + + timestamp := now() + + // Import registries first — projects reference them by name. + for name, regDef := range cfg.Registries { + encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token) + if err != nil { + return fmt.Errorf("encrypt registry %q token: %w", name, err) + } + + id := uuid.New().String() + _, err = tx.Exec( + `INSERT INTO registries (id, name, url, type, token, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, name, regDef.URL, regDef.Type, encToken, timestamp, timestamp, + ) + if err != nil { + return fmt.Errorf("insert registry %q: %w", name, err) + } + } + + // Import projects and their stages. + for name, projDef := range cfg.Projects { + envJSON, err := mapToJSON(projDef.Env) + if err != nil { + return fmt.Errorf("encode env for project %q: %w", name, err) + } + volJSON, err := mapToJSON(projDef.Volumes) + if err != nil { + return fmt.Errorf("encode volumes for project %q: %w", name, err) + } + + projectID := uuid.New().String() + _, err = tx.Exec( + `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + projectID, name, projDef.Registry, projDef.Image, projDef.Port, + projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp, + ) + if err != nil { + return fmt.Errorf("insert project %q: %w", name, err) + } + + for stageName, stageDef := range projDef.Stages { + maxInstances := stageDef.MaxInstances + if maxInstances == 0 { + maxInstances = 1 + } + + stageID := uuid.New().String() + _, err = tx.Exec( + `INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + stageID, projectID, stageName, stageDef.TagPattern, + boolToInt(stageDef.AutoDeploy), maxInstances, + boolToInt(stageDef.Confirm), stageDef.PromoteFrom, + stageDef.Subdomain, timestamp, timestamp, + ) + if err != nil { + return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err) + } + } + } + + // Import global settings — encrypt NPM password. + encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password) + if err != nil { + return fmt.Errorf("encrypt npm password: %w", err) + } + + subdomainPattern := cfg.Global.SubdomainPattern + if subdomainPattern == "" { + subdomainPattern = "stage-{stage}-{project}" + } + + _, err = tx.Exec( + `UPDATE settings SET + domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, + npm_url=?, npm_email=?, npm_password=?, updated_at=? + WHERE id = 1`, + cfg.Global.Domain, cfg.Global.ServerIP, cfg.Global.Network, + subdomainPattern, cfg.Global.NotificationURL, + cfg.Global.Npm.URL, cfg.Global.Npm.Email, encNpmPassword, timestamp, + ) + if err != nil { + return fmt.Errorf("update settings: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +// boolToInt converts a bool to an integer for SQLite storage. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps. +func mapToJSON(m map[string]string) (string, error) { + if m == nil { + return "{}", nil + } + b, err := json.Marshal(m) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..ac8ccb4 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,96 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" +) + +// ErrNoKey is returned when ENCRYPTION_KEY is not set. +var ErrNoKey = errors.New("ENCRYPTION_KEY environment variable is not set") + +// DeriveKey computes a 32-byte AES-256 key from the given passphrase using SHA-256. +// This is acceptable when ENCRYPTION_KEY is a high-entropy random string (e.g., 32+ hex chars). +// For human-chosen passphrases, consider Argon2id or PBKDF2 with a salt instead. +func DeriveKey(passphrase string) [32]byte { + return sha256.Sum256([]byte(passphrase)) +} + +// KeyFromEnv reads ENCRYPTION_KEY from the environment and derives a 32-byte key. +func KeyFromEnv() ([32]byte, error) { + raw := os.Getenv("ENCRYPTION_KEY") + if raw == "" { + return [32]byte{}, ErrNoKey + } + return DeriveKey(raw), nil +} + +// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. +// The returned ciphertext is hex-encoded: nonce || ciphertext+tag. +func Encrypt(key [32]byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(sealed), nil +} + +// Decrypt decrypts a hex-encoded ciphertext produced by Encrypt. +func Decrypt(key [32]byte, ciphertextHex string) (string, error) { + data, err := hex.DecodeString(ciphertextHex) + if err != nil { + return "", fmt.Errorf("decode hex: %w", err) + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce := data[:nonceSize] + ciphertext := data[nonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} + +// EncryptIfNotEmpty encrypts the value only if it is non-empty. +// Returns empty string for empty input. +func EncryptIfNotEmpty(key [32]byte, value string) (string, error) { + if value == "" { + return "", nil + } + return Encrypt(key, value) +} diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go new file mode 100644 index 0000000..84bf6b4 --- /dev/null +++ b/internal/deployer/bluegreen.go @@ -0,0 +1,175 @@ +package deployer + +import ( + "context" + "fmt" + "log/slog" + + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/store" + "github.com/google/uuid" +) + +// blueGreenDeploy performs a zero-downtime deployment: +// 1. Start new container (green) +// 2. Health check green +// 3. Swap NPM proxy to point to green +// 4. Stop old container (blue) +// +// If the new container fails health check, it is removed and the old one stays. +func (d *Deployer) blueGreenDeploy( + ctx context.Context, + project store.Project, + stage store.Stage, + settings store.Settings, + deployID string, + imageTag string, +) (string, int, string, error) { + // Find existing running instance for this stage (the "blue" instance). + existingInstances, err := d.store.GetInstancesByStageID(stage.ID) + if err != nil { + return "", 0, "", fmt.Errorf("get existing instances: %w", err) + } + + var blueInstance *store.Instance + for _, inst := range existingInstances { + if inst.Status == "running" { + instCopy := inst + blueInstance = &instCopy + break + } + } + + // Step 1: Pull image. + if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") + d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info") + + authConfig, err := d.buildRegistryAuth(project) + if err != nil { + return "", 0, "", fmt.Errorf("build registry auth: %w", err) + } + + if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { + return "", 0, "", fmt.Errorf("pull image: %w", err) + } + d.logDeploy(deployID, "Image pulled successfully", "info") + + // Step 2: Ensure network. + networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) + if err != nil { + return "", 0, "", fmt.Errorf("ensure network: %w", err) + } + + // Step 3: Create and start green container. + if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") + + instanceID := uuid.New().String() + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + containerName := docker.ContainerName(project.Name, stage.Name, imageTag) + portStr := fmt.Sprintf("%d/tcp", project.Port) + envVars := d.mergeEnvVars(project, stage.ID) + mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag) + + containerCfg := docker.ContainerConfig{ + Name: containerName, + Image: project.Image + ":" + imageTag, + Env: envVars, + ExposedPorts: []string{portStr}, + NetworkName: settings.Network, + NetworkID: networkID, + Project: project.Name, + Stage: stage.Name, + InstanceID: instanceID, + Mounts: mounts, + } + + d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info") + containerID, err := d.docker.CreateContainer(ctx, containerCfg) + if err != nil { + return "", 0, instanceID, fmt.Errorf("create container: %w", err) + } + + // Create instance record. + inst, err := d.store.CreateInstanceWithID(store.Instance{ + ID: instanceID, + StageID: stage.ID, + ProjectID: project.ID, + ContainerID: containerID, + ImageTag: imageTag, + Subdomain: subdomain, + Status: "stopped", + Port: project.Port, + }) + if err != nil { + return containerID, 0, instanceID, fmt.Errorf("create instance record: %w", err) + } + instanceID = inst.ID + + if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { + slog.Warn("link deploy to instance", "error", err) + } + + d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info") + if err := d.docker.StartContainer(ctx, containerID); err != nil { + return containerID, 0, instanceID, fmt.Errorf("start container: %w", err) + } + + if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { + slog.Warn("update instance status", "error", err) + } + d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") + + // Step 4: Health check the green container. + if project.Healthcheck != "" { + if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") + + healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) + d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info") + + if err := d.health.Check(ctx, healthURL); err != nil { + return containerID, 0, instanceID, fmt.Errorf("health check green: %w", err) + } + d.logDeploy(deployID, "Blue-green: green health check passed", "info") + } + + // Step 5: Swap NPM proxy to green. + if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") + + npmProxyID, err := d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + if err != nil { + return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err) + } + + inst.NpmProxyID = npmProxyID + inst.Subdomain = subdomain + if err := d.store.UpdateInstance(inst); err != nil { + slog.Warn("update instance with proxy ID", "error", err) + } + + d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info") + + // Step 6: Stop the blue container. + if blueInstance != nil { + d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue instance %s (tag: %s)", blueInstance.ID, blueInstance.ImageTag), "info") + if err := d.removeInstance(ctx, *blueInstance, settings); err != nil { + // Non-fatal: log but continue. Green is already serving traffic. + d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue instance: %v", err), "warn") + } else { + d.logDeploy(deployID, "Blue-green: blue instance removed", "info") + } + } + + return containerID, npmProxyID, instanceID, nil +} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go new file mode 100644 index 0000000..503480b --- /dev/null +++ b/internal/deployer/deployer.go @@ -0,0 +1,792 @@ +package deployer + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "path/filepath" + "sort" + "sync" + "sync/atomic" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/events" + "github.com/alexei/docker-watcher/internal/health" + "github.com/alexei/docker-watcher/internal/notify" + "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/store" + "github.com/moby/moby/api/types/mount" + "github.com/google/uuid" +) + +// Deployer orchestrates the full deployment flow: pull image, create container, +// start, configure proxy, health check, and handle rollback on failure. +// It implements both webhook.DeployTriggerer and registry.DeployTriggerer. +type Deployer struct { + docker *docker.Client + npm *npm.Client + store *store.Store + health *health.Checker + notifier *notify.Notifier + eventBus EventPublisher + encKey [32]byte + + // Graceful shutdown: tracks in-progress deploys. + activeWg sync.WaitGroup + shuttingDown atomic.Bool +} + +// EventPublisher is the interface for publishing events to the event bus. +type EventPublisher interface { + Publish(evt events.Event) +} + +// New creates a new Deployer with all required dependencies. +func New( + dockerClient *docker.Client, + npmClient *npm.Client, + st *store.Store, + checker *health.Checker, + notifier *notify.Notifier, + eventBus EventPublisher, + encKey [32]byte, +) *Deployer { + return &Deployer{ + docker: dockerClient, + npm: npmClient, + store: st, + health: checker, + notifier: notifier, + eventBus: eventBus, + encKey: encKey, + } +} + +// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown. +func (d *Deployer) Drain() { + d.shuttingDown.Store(true) + slog.Info("deployer: draining in-progress deploys") + d.activeWg.Wait() + slog.Info("deployer: all deploys drained") +} + +// AsyncTriggerDeploy creates a deploy record and returns the deploy ID immediately, +// then runs the full deploy pipeline in a background goroutine. Use this from HTTP handlers +// to avoid blocking the request. Progress is streamed via SSE. +func (d *Deployer) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) { + if d.shuttingDown.Load() { + return "", fmt.Errorf("deployer is shutting down, rejecting new deploy") + } + + // Validate inputs synchronously so the caller gets immediate feedback. + project, err := d.store.GetProjectByID(projectID) + if err != nil { + return "", fmt.Errorf("get project: %w", err) + } + stage, err := d.store.GetStageByID(stageID) + if err != nil { + return "", fmt.Errorf("get stage: %w", err) + } + if err := d.validatePromoteFrom(stage, imageTag); err != nil { + return "", fmt.Errorf("promote validation: %w", err) + } + + // Create deploy record synchronously so caller gets the ID. + deploy, err := d.store.CreateDeploy(store.Deploy{ + ProjectID: projectID, + StageID: stageID, + ImageTag: imageTag, + Status: "pending", + }) + if err != nil { + return "", fmt.Errorf("create deploy record: %w", err) + } + + // Run the actual deploy in the background. + d.activeWg.Add(1) + go func() { + defer d.activeWg.Done() + // Use a detached context so client disconnect doesn't abort the deploy. + bgCtx := context.Background() + if err := d.runDeploy(bgCtx, project, stage, deploy.ID, imageTag); err != nil { + slog.Error("async deploy failed", "deploy_id", deploy.ID, "error", err) + } + }() + + return deploy.ID, nil +} + +// runDeploy is the internal deploy pipeline used by AsyncTriggerDeploy. +// It assumes the deploy record already exists and project/stage are validated. +func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage store.Stage, deployID string, imageTag string) error { + settings, err := d.store.GetSettings() + if err != nil { + if updateErr := d.store.UpdateDeployStatus(deployID, "failed", err.Error()); updateErr != nil { + slog.Warn("update deploy status", "error", updateErr) + } + return fmt.Errorf("get settings: %w", err) + } + + slog.Info("starting deploy", + "deploy_id", deployID, + "project", project.Name, + "stage", stage.Name, + "tag", imageTag, + ) + d.logDeploy(deployID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info") + + // Enforce max_instances before deploying. + if err := d.enforceMaxInstances(ctx, stage, deployID, settings); err != nil { + d.logDeploy(deployID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error") + } + + var containerID string + var npmProxyID int + var instanceID string + var deployErr error + + if stage.MaxInstances == 1 { + containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag) + } else { + containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag) + } + + if deployErr != nil { + d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error()) + d.rollback(ctx, deployID, containerID, npmProxyID, instanceID) + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_failure", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Error: deployErr.Error(), + }) + + return fmt.Errorf("deploy failed: %w", deployErr) + } + + if err := d.store.UpdateDeployStatus(deployID, "success", ""); err != nil { + slog.Warn("update deploy status to success", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "success", "") + + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain) + + d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info") + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_success", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Subdomain: subdomain, + URL: fullURL, + }) + + return nil +} + +// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook). +// It orchestrates the full flow: pull image -> create container -> start -> configure proxy -> health check. +// On failure, it rolls back (removes container, deletes proxy host, updates status). +func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error { + if d.shuttingDown.Load() { + return fmt.Errorf("deployer is shutting down, rejecting new deploy") + } + + d.activeWg.Add(1) + defer d.activeWg.Done() + + // Load project and stage from store. + project, err := d.store.GetProjectByID(projectID) + if err != nil { + return fmt.Errorf("get project: %w", err) + } + + stage, err := d.store.GetStageByID(stageID) + if err != nil { + return fmt.Errorf("get stage: %w", err) + } + + // Validate promote_from constraint. + if err := d.validatePromoteFrom(stage, imageTag); err != nil { + return fmt.Errorf("promote validation: %w", err) + } + + settings, err := d.store.GetSettings() + if err != nil { + return fmt.Errorf("get settings: %w", err) + } + + // Create deploy record. + deploy, err := d.store.CreateDeploy(store.Deploy{ + ProjectID: projectID, + StageID: stageID, + ImageTag: imageTag, + Status: "pending", + }) + if err != nil { + return fmt.Errorf("create deploy record: %w", err) + } + + slog.Info("starting deploy", + "deploy_id", deploy.ID, + "project", project.Name, + "stage", stage.Name, + "tag", imageTag, + ) + d.logDeploy(deploy.ID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info") + + // Enforce max_instances before deploying. + if err := d.enforceMaxInstances(ctx, stage, deploy.ID, settings); err != nil { + d.logDeploy(deploy.ID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error") + // Non-fatal: continue with deploy. + } + + // Choose deploy strategy: blue-green if stage has max_instances=1 and an existing instance. + var containerID string + var npmProxyID int + var instanceID string + var deployErr error + + if stage.MaxInstances == 1 { + containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + } else { + // Execute the standard deploy pipeline. Track state for rollback. + containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag) + } + + if deployErr != nil { + d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") + d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "failed", deployErr.Error()) + d.rollback(ctx, deploy.ID, containerID, npmProxyID, instanceID) + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_failure", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Error: deployErr.Error(), + }) + + return fmt.Errorf("deploy failed: %w", deployErr) + } + + // Mark deploy as successful. + if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil { + slog.Warn("update deploy status to success", "error", err) + } + d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "") + + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain) + + d.logDeploy(deploy.ID, fmt.Sprintf("Deploy successful: %s", fullURL), "info") + + d.notifier.Send(settings.NotificationURL, notify.Event{ + Type: "deploy_success", + Project: project.Name, + Stage: stage.Name, + ImageTag: imageTag, + Subdomain: subdomain, + URL: fullURL, + }) + + return nil +} + +// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state. +// It returns (containerID, npmProxyID, instanceID, error). +func (d *Deployer) executeDeploy( + ctx context.Context, + project store.Project, + stage store.Stage, + settings store.Settings, + deployID string, + imageTag string, +) (string, int, string, error) { + var containerID string + var npmProxyID int + var instanceID string + + // Step 1: Pull image. + if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "") + d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info") + + authConfig, err := d.buildRegistryAuth(project) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("build registry auth: %w", err) + } + + if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("pull image: %w", err) + } + d.logDeploy(deployID, "Image pulled successfully", "info") + + // Step 2: Ensure network exists. + networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("ensure network: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info") + + // Step 3: Create and start container. + if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "") + + // Pre-generate instance ID so it can be set as a container label. + instanceID = uuid.New().String() + subdomain := d.buildSubdomain(project, stage, settings, imageTag) + + containerName := docker.ContainerName(project.Name, stage.Name, imageTag) + portStr := fmt.Sprintf("%d/tcp", project.Port) + envVars := d.mergeEnvVars(project, stage.ID) + mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag) + + containerCfg := docker.ContainerConfig{ + Name: containerName, + Image: project.Image + ":" + imageTag, + Env: envVars, + ExposedPorts: []string{portStr}, + NetworkName: settings.Network, + NetworkID: networkID, + Project: project.Name, + Stage: stage.Name, + InstanceID: instanceID, + Mounts: mounts, + } + + d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info") + containerID, err = d.docker.CreateContainer(ctx, containerCfg) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("create container: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info") + + // Create instance record in store with the pre-generated ID. + inst, err := d.store.CreateInstanceWithID(store.Instance{ + ID: instanceID, + StageID: stage.ID, + ProjectID: project.ID, + ContainerID: containerID, + ImageTag: imageTag, + Subdomain: subdomain, + Status: "stopped", + Port: project.Port, + }) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("create instance record: %w", err) + } + instanceID = inst.ID + + // Link deploy to instance. + if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil { + slog.Warn("link deploy to instance", "error", err) + } + + d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info") + if err := d.docker.StartContainer(ctx, containerID); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("start container: %w", err) + } + + if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { + slog.Warn("update instance status to running", "error", err) + } + d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") + d.logDeploy(deployID, "Container started", "info") + + // Step 4: Configure NPM proxy. + if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") + + npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + if err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err) + } + + // Update instance with NPM proxy ID. + inst.NpmProxyID = npmProxyID + inst.Subdomain = subdomain + if err := d.store.UpdateInstance(inst); err != nil { + slog.Warn("update instance with proxy ID", "error", err) + } + + // Step 5: Health check. + if project.Healthcheck != "" { + if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil { + slog.Warn("update deploy status", "error", err) + } + d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "") + + healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck) + d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info") + + if err := d.health.Check(ctx, healthURL); err != nil { + return containerID, npmProxyID, instanceID, fmt.Errorf("health check: %w", err) + } + d.logDeploy(deployID, "Health check passed", "info") + } else { + d.logDeploy(deployID, "No health check configured, skipping", "info") + } + + return containerID, npmProxyID, instanceID, nil +} + +// configureProxy creates or updates an NPM proxy host for the deployed container. +// It authenticates to NPM using credentials from settings, then creates the proxy. +// Returns the NPM proxy host ID. +func (d *Deployer) configureProxy( + ctx context.Context, + deployID string, + settings store.Settings, + containerName string, + containerPort int, + subdomain string, +) (int, error) { + // Authenticate to NPM. + npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) + if err != nil { + return 0, fmt.Errorf("decrypt npm password: %w", err) + } + + if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { + return 0, fmt.Errorf("authenticate to npm: %w", err) + } + + fqdn := subdomain + "." + settings.Domain + d.logDeploy(deployID, fmt.Sprintf("Configuring proxy: %s -> %s:%d", fqdn, containerName, containerPort), "info") + + // Check if a proxy host already exists for this domain. + existing, found, err := d.npm.FindProxyHostByDomain(ctx, fqdn) + if err != nil { + return 0, fmt.Errorf("find existing proxy host: %w", err) + } + + proxyConfig := npm.ProxyHostConfig{ + DomainNames: []string{fqdn}, + ForwardScheme: "http", + ForwardHost: containerName, + ForwardPort: containerPort, + BlockExploits: true, + AllowWebsocket: true, + HTTP2Support: true, + Meta: npm.Meta{}, + Locations: []any{}, + } + + if found { + d.logDeploy(deployID, fmt.Sprintf("Updating existing proxy host %d for %s", existing.ID, fqdn), "info") + host, err := d.npm.UpdateProxyHost(ctx, existing.ID, proxyConfig) + if err != nil { + return 0, fmt.Errorf("update proxy host: %w", err) + } + d.logDeploy(deployID, "Proxy host updated", "info") + return host.ID, nil + } + + d.logDeploy(deployID, fmt.Sprintf("Creating new proxy host for %s", fqdn), "info") + host, err := d.npm.CreateProxyHost(ctx, proxyConfig) + if err != nil { + return 0, fmt.Errorf("create proxy host: %w", err) + } + d.logDeploy(deployID, fmt.Sprintf("Proxy host created (ID: %d)", host.ID), "info") + return host.ID, nil +} + +// enforceMaxInstances removes the oldest instances when the stage has reached its limit. +// This makes room for the new deployment. +func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, deployID string, settings store.Settings) error { + if stage.MaxInstances <= 0 { + return nil + } + + instances, err := d.store.GetInstancesByStageID(stage.ID) + if err != nil { + return fmt.Errorf("get instances for stage: %w", err) + } + + // Filter to running/stopped instances (not already failed/removing). + var active []store.Instance + for _, inst := range instances { + if inst.Status == "running" || inst.Status == "stopped" { + active = append(active, inst) + } + } + + // We need room for one more instance, so remove oldest when at limit. + removeCount := len(active) - stage.MaxInstances + 1 + if removeCount <= 0 { + return nil + } + + // Sort by created_at ascending (oldest first). + sort.Slice(active, func(i, j int) bool { + return active[i].CreatedAt < active[j].CreatedAt + }) + + for i := 0; i < removeCount && i < len(active); i++ { + inst := active[i] + d.logDeploy(deployID, fmt.Sprintf("Removing oldest instance %s (tag: %s) to enforce max_instances=%d", inst.ID, inst.ImageTag, stage.MaxInstances), "info") + + if err := d.removeInstance(ctx, inst, settings); err != nil { + d.logDeploy(deployID, fmt.Sprintf("Failed to remove instance %s: %v", inst.ID, err), "warn") + continue + } + d.logDeploy(deployID, fmt.Sprintf("Removed instance %s", inst.ID), "info") + } + + return nil +} + +// removeInstance stops and removes a container, deletes its NPM proxy host, +// and removes the instance record from the store. +func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, settings store.Settings) error { + // Mark as removing. + if err := d.store.UpdateInstanceStatus(inst.ID, "removing"); err != nil { + slog.Warn("update instance status to removing", "instance_id", inst.ID, "error", err) + } + + // Remove Docker container. + if inst.ContainerID != "" { + if err := d.docker.RemoveContainer(ctx, inst.ContainerID, true); err != nil { + slog.Warn("remove container", "container_id", inst.ContainerID, "error", err) + } + } + + // Delete NPM proxy host. + if inst.NpmProxyID > 0 { + npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) + if err != nil { + slog.Warn("decrypt npm password for proxy cleanup", "error", err) + } else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil { + slog.Warn("authenticate npm for proxy cleanup", "error", authErr) + } else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { + slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr) + } + } + + // Delete instance record. + if err := d.store.DeleteInstance(inst.ID); err != nil { + return fmt.Errorf("delete instance record: %w", err) + } + + return nil +} + +// buildSubdomain generates the subdomain for an instance based on settings and stage config. +func (d *Deployer) buildSubdomain(project store.Project, stage store.Stage, settings store.Settings, imageTag string) string { + return GenerateTaggedSubdomain(settings.SubdomainPattern, project.Name, stage.Name, imageTag, stage.Subdomain) +} + +// buildRegistryAuth constructs the Docker registry auth string for pulling images. +// If the project has a registry configured, it looks up the registry token. +func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) { + if project.Registry == "" { + return "", nil + } + + registries, err := d.store.GetAllRegistries() + if err != nil { + return "", fmt.Errorf("get registries: %w", err) + } + + for _, reg := range registries { + if reg.Name == project.Registry { + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(d.encKey, token) + if err != nil { + return "", fmt.Errorf("decrypt registry token: %w", err) + } + return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL) + } + return "", nil + } + } + + return "", nil +} + +// decryptNpmPassword decrypts the NPM password from settings. +// Returns empty string if the encrypted password is empty. +func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error) { + if encryptedPassword == "" { + return "", nil + } + return crypto.Decrypt(d.encKey, encryptedPassword) +} + +// parseEnvVars parses a JSON-encoded map into KEY=VALUE environment variable strings. +func (d *Deployer) parseEnvVars(envJSON string) []string { + if envJSON == "" || envJSON == "{}" { + return nil + } + + var envMap map[string]string + if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil { + slog.Warn("parse env vars", "error", err) + return nil + } + + vars := make([]string, 0, len(envMap)) + for k, v := range envMap { + vars = append(vars, k+"="+v) + } + return vars +} + +// mergeEnvVars builds the final environment variable list for a container: +// 1. Parse project-level env JSON +// 2. Overlay with stage-level env overrides (stage wins on key conflict) +// 3. Decrypt any encrypted (secret) values +// Returns a []string of KEY=VALUE pairs. +func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string { + // Step 1: Parse project-level env. + envMap := make(map[string]string) + if project.Env != "" && project.Env != "{}" { + var projectEnv map[string]string + if err := json.Unmarshal([]byte(project.Env), &projectEnv); err != nil { + slog.Warn("parse project env vars", "error", err) + } else { + for k, v := range projectEnv { + envMap[k] = v + } + } + } + + // Step 2: Overlay with stage-level overrides. + stageEnvs, err := d.store.GetStageEnvByStageID(stageID) + if err != nil { + slog.Warn("get stage env overrides", "stage_id", stageID, "error", err) + } else { + for _, se := range stageEnvs { + value := se.Value + if se.Encrypted { + // Step 3: Decrypt secret values. + decrypted, err := crypto.Decrypt(d.encKey, se.Value) + if err != nil { + slog.Warn("decrypt stage env value", "key", se.Key, "error", err) + continue + } + value = decrypted + } + envMap[se.Key] = value + } + } + + vars := make([]string, 0, len(envMap)) + for k, v := range envMap { + vars = append(vars, k+"="+v) + } + return vars +} + +// computeVolumeMounts builds Docker mount specifications from the project's volume config. +// For shared mode, source is used as-is. +// For isolated mode, source gets /{stage}-{tag}/ appended. +func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []mount.Mount { + vols, err := d.store.GetVolumesByProjectID(projectID) + if err != nil { + slog.Warn("get project volumes", "project_id", projectID, "error", err) + return nil + } + + if len(vols) == 0 { + return nil + } + + // Get base volume path from settings. + basePath := "" + if settings, err := d.store.GetSettings(); err == nil { + basePath = settings.BaseVolumePath + } + + mounts := make([]mount.Mount, 0, len(vols)) + for _, vol := range vols { + source := vol.Source + // Prepend base path if source is relative (doesn't start with /). + if basePath != "" && !filepath.IsAbs(source) { + source = filepath.Join(basePath, source) + } + if vol.Mode == "isolated" { + source = filepath.Join(source, fmt.Sprintf("%s-%s", stageName, imageTag)) + } + mounts = append(mounts, mount.Mount{ + Type: mount.TypeBind, + Source: source, + Target: vol.Target, + }) + } + return mounts +} + +// logDeploy appends a log entry for a deploy and publishes it on the event bus. +// Errors are logged to stderr but not propagated. +func (d *Deployer) logDeploy(deployID, message, level string) { + if err := d.store.AppendDeployLog(deployID, message, level); err != nil { + slog.Warn("append deploy log", "error", err) + } + if d.eventBus != nil { + d.eventBus.Publish(events.Event{ + Type: events.EventDeployLog, + Payload: events.DeployLogPayload{ + DeployID: deployID, + Message: message, + Level: level, + }, + }) + } +} + +// publishDeployStatus publishes a deploy status change event on the bus. +func (d *Deployer) publishDeployStatus(deployID, projectID, stageID, imageTag, status, deployErr string) { + if d.eventBus != nil { + d.eventBus.Publish(events.Event{ + Type: events.EventDeployStatus, + Payload: events.DeployStatusPayload{ + DeployID: deployID, + ProjectID: projectID, + StageID: stageID, + ImageTag: imageTag, + Status: status, + Error: deployErr, + }, + }) + } +} + +// publishInstanceStatus publishes an instance status change event on the bus. +func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status string) { + if d.eventBus != nil { + d.eventBus.Publish(events.Event{ + Type: events.EventInstanceStatus, + Payload: events.InstanceStatusPayload{ + InstanceID: instanceID, + ProjectID: projectID, + StageID: stageID, + Status: status, + }, + }) + } +} + +// truncateID safely truncates a Docker ID to 12 characters for display. +func truncateID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + diff --git a/internal/deployer/promote.go b/internal/deployer/promote.go new file mode 100644 index 0000000..043468d --- /dev/null +++ b/internal/deployer/promote.go @@ -0,0 +1,49 @@ +package deployer + +import ( + "fmt" + + "github.com/alexei/docker-watcher/internal/store" +) + +// validatePromoteFrom checks that a tag is running in the promote_from stage +// before allowing it to be deployed to the target stage. +// Returns nil if no promote_from is configured or if the tag is eligible. +func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error { + if stage.PromoteFrom == "" { + return nil + } + + // Look up the source stage by name within the same project. + stages, err := d.store.GetStagesByProjectID(stage.ProjectID) + if err != nil { + return fmt.Errorf("get stages for project: %w", err) + } + + var sourceStage *store.Stage + for _, s := range stages { + if s.Name == stage.PromoteFrom { + sCopy := s + sourceStage = &sCopy + break + } + } + + if sourceStage == nil { + return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom) + } + + // Check if the tag is running in the source stage. + instances, err := d.store.GetInstancesByStageID(sourceStage.ID) + if err != nil { + return fmt.Errorf("get instances for source stage: %w", err) + } + + for _, inst := range instances { + if inst.ImageTag == imageTag && (inst.Status == "running" || inst.Status == "stopped") { + return nil // Tag found in source stage, promotion is allowed. + } + } + + return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom) +} diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go new file mode 100644 index 0000000..8de53d5 --- /dev/null +++ b/internal/deployer/rollback.go @@ -0,0 +1,58 @@ +package deployer + +import ( + "context" + "fmt" + "log/slog" +) + +// rollback cleans up a failed deployment by removing the container, +// deleting the NPM proxy host, and updating the instance status. +// Errors during rollback are logged but do not prevent other cleanup steps. +func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, npmProxyID int, instanceID string) { + d.logDeploy(deployID, "Rolling back failed deployment", "warn") + + // Remove the container if it was created. + if containerID != "" { + if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil { + slog.Warn("rollback: remove container", "container_id", containerID, "error", err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error") + } else { + d.logDeploy(deployID, "Rollback: container removed", "info") + } + } + + // Delete the NPM proxy host if it was created. + if npmProxyID > 0 { + settings, err := d.store.GetSettings() + if err != nil { + slog.Warn("rollback: get settings for npm auth", "error", err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error") + } else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil { + slog.Warn("rollback: decrypt npm password", "error", err) + d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error") + } else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { + slog.Warn("rollback: authenticate npm", "error", err) + d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error") + } else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { + slog.Warn("rollback: delete proxy host", "proxy_id", npmProxyID, "error", err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error") + } else { + d.logDeploy(deployID, "Rollback: proxy host deleted", "info") + } + } + + // Update instance status to failed if it was created. + if instanceID != "" { + if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil { + slog.Warn("rollback: update instance status", "instance_id", instanceID, "error", err) + } + } + + // Mark deploy as rolled back. + if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil { + slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err) + } + + d.logDeploy(deployID, "Rollback complete", "info") +} diff --git a/internal/deployer/subdomain.go b/internal/deployer/subdomain.go new file mode 100644 index 0000000..646b58b --- /dev/null +++ b/internal/deployer/subdomain.go @@ -0,0 +1,84 @@ +package deployer + +import ( + "regexp" + "strings" +) + +// maxSubdomainLen is the maximum length of a single DNS label (RFC 1035). +const maxSubdomainLen = 63 + +// invalidDNSChars matches characters not allowed in a DNS label. +var invalidDNSChars = regexp.MustCompile(`[^a-z0-9-]`) + +// GenerateSubdomain builds a subdomain string from the given pattern and parameters. +// The pattern may contain {stage}, {project}, and {tag} placeholders. +// If the stage has a custom subdomain override, that value is used instead of the pattern. +func GenerateSubdomain(pattern, project, stage, tag, stageSubdomain string) string { + if stageSubdomain != "" { + return SanitizeDNSLabel(stageSubdomain) + } + + result := pattern + result = strings.ReplaceAll(result, "{stage}", stage) + result = strings.ReplaceAll(result, "{project}", project) + result = strings.ReplaceAll(result, "{tag}", tag) + + return SanitizeDNSLabel(result) +} + +// GenerateTaggedSubdomain builds a subdomain that includes the tag for multi-instance support. +// It appends "-{sanitized_tag}" to the base subdomain. +func GenerateTaggedSubdomain(pattern, project, stage, tag, stageSubdomain string) string { + base := GenerateSubdomain(pattern, project, stage, "", stageSubdomain) + sanitizedTag := SanitizeDNSLabel(tag) + + if sanitizedTag == "" { + return base + } + + combined := base + "-" + sanitizedTag + return truncateDNSLabel(combined) +} + +// SanitizeDNSLabel converts an arbitrary string into a valid DNS label. +// It lowercases, replaces dots and invalid characters with hyphens, +// collapses consecutive hyphens, trims leading/trailing hyphens, and truncates. +func SanitizeDNSLabel(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, ".", "-") + s = invalidDNSChars.ReplaceAllString(s, "-") + s = collapseHyphens(s) + s = strings.Trim(s, "-") + return truncateDNSLabel(s) +} + +// collapseHyphens replaces consecutive hyphens with a single hyphen. +func collapseHyphens(s string) string { + prev := false + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + if r == '-' { + if !prev { + b.WriteRune(r) + } + prev = true + } else { + b.WriteRune(r) + prev = false + } + } + return b.String() +} + +// truncateDNSLabel truncates a label to maxSubdomainLen characters, +// ensuring it does not end with a hyphen after truncation. +func truncateDNSLabel(s string) string { + if len(s) <= maxSubdomainLen { + return s + } + s = s[:maxSubdomainLen] + return strings.TrimRight(s, "-") +} diff --git a/internal/docker/client.go b/internal/docker/client.go new file mode 100644 index 0000000..5b29d40 --- /dev/null +++ b/internal/docker/client.go @@ -0,0 +1,50 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/moby/moby/client" +) + +// Labels applied to all containers managed by docker-watcher. +const ( + LabelProject = "docker-watcher.project" + LabelStage = "docker-watcher.stage" + LabelInstanceID = "docker-watcher.instance-id" +) + +// Client wraps the Docker Engine API client. +type Client struct { + api client.APIClient +} + +// New creates a new Docker client connected to the default Docker socket. +func New() (*Client, error) { + api, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("create docker client: %w", err) + } + + return &Client{api: api}, nil +} + +// Close releases resources held by the Docker client. +func (c *Client) Close() error { + if err := c.api.Close(); err != nil { + return fmt.Errorf("close docker client: %w", err) + } + return nil +} + +// Ping checks connectivity to the Docker daemon. +func (c *Client) Ping(ctx context.Context) error { + _, err := c.api.Ping(ctx, client.PingOptions{}) + if err != nil { + return fmt.Errorf("ping docker daemon: %w", err) + } + return nil +} diff --git a/internal/docker/container.go b/internal/docker/container.go new file mode 100644 index 0000000..c835766 --- /dev/null +++ b/internal/docker/container.go @@ -0,0 +1,293 @@ +package docker + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +// ContainerConfig holds all parameters needed to create a managed container. +type ContainerConfig struct { + // Name is the container name (deterministic: dw-{project}-{stage}-{tag}). + Name string + + // Image is the full image reference including tag (e.g. "myapp:v1.2.3"). + Image string + + // Env is a list of environment variables in "KEY=VALUE" format. + Env []string + + // ExposedPorts lists the container ports to publish (e.g. ["8080/tcp"]). + // Each port is mapped to a random host port via Docker auto-assignment. + ExposedPorts []string + + // NetworkName is the Docker network to attach the container to at creation. + NetworkName string + + // NetworkID is the ID of the Docker network (used for endpoint config). + NetworkID string + + // Labels are additional labels to apply to the container. + // docker-watcher management labels are added automatically via Project, Stage, and InstanceID. + Labels map[string]string + + // Project is the docker-watcher project name (used for labelling). + Project string + + // Stage is the docker-watcher stage name (used for labelling). + Stage string + + // InstanceID is the docker-watcher instance ID (used for labelling). + InstanceID string + + // Mounts is a list of bind mounts to attach to the container. + Mounts []mount.Mount +} + +// sanitizeTag replaces characters that are invalid in Docker container names +// with hyphens and lowercases the result. +var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]`) + +// ContainerName builds a deterministic container name from project, stage, and tag. +func ContainerName(project, stage, tag string) string { + sanitizeComponent := func(s string) string { + s = invalidNameChars.ReplaceAllString(s, "-") + return strings.Trim(s, "-") + } + return fmt.Sprintf("dw-%s-%s-%s", sanitizeComponent(project), sanitizeComponent(stage), sanitizeComponent(tag)) +} + +// CreateContainer creates a new container with the given configuration. +// It returns the container ID on success. +func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (string, error) { + // Build port bindings: each exposed port maps to a random host port. + exposedPorts := network.PortSet{} + portBindings := network.PortMap{} + for _, p := range cfg.ExposedPorts { + port, err := network.ParsePort(p) + if err != nil { + return "", fmt.Errorf("parse port %s: %w", p, err) + } + exposedPorts[port] = struct{}{} + portBindings[port] = []network.PortBinding{ + {HostPort: ""}, // empty HostPort = auto-assign + } + } + + // Merge docker-watcher labels with any additional labels. + labels := make(map[string]string) + for k, v := range cfg.Labels { + labels[k] = v + } + labels[LabelProject] = cfg.Project + labels[LabelStage] = cfg.Stage + labels[LabelInstanceID] = cfg.InstanceID + + containerCfg := &container.Config{ + Image: cfg.Image, + Env: cfg.Env, + ExposedPorts: exposedPorts, + Labels: labels, + } + + hostCfg := &container.HostConfig{ + PortBindings: portBindings, + RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled}, + Mounts: cfg.Mounts, + } + + // Attach to network at creation time if specified. + var networkCfg *network.NetworkingConfig + if cfg.NetworkName != "" { + networkCfg = &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + cfg.NetworkName: { + NetworkID: cfg.NetworkID, + }, + }, + } + } + + resp, err := c.api.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: containerCfg, + HostConfig: hostCfg, + NetworkingConfig: networkCfg, + Name: cfg.Name, + }) + if err != nil { + return "", fmt.Errorf("create container %s: %w", cfg.Name, err) + } + + return resp.ID, nil +} + +// StartContainer starts a stopped container. +func (c *Client) StartContainer(ctx context.Context, containerID string) error { + if _, err := c.api.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil { + return fmt.Errorf("start container %s: %w", containerID, err) + } + return nil +} + +// StopContainer gracefully stops a running container with the given timeout in seconds. +// A nil timeout uses the Docker default (10 seconds). +func (c *Client) StopContainer(ctx context.Context, containerID string, timeoutSeconds int) error { + opts := client.ContainerStopOptions{} + if timeoutSeconds > 0 { + opts.Timeout = &timeoutSeconds + } + + if _, err := c.api.ContainerStop(ctx, containerID, opts); err != nil { + return fmt.Errorf("stop container %s: %w", containerID, err) + } + return nil +} + +// RemoveContainer removes a container. If force is true, a running container +// will be killed before removal. +func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error { + opts := client.ContainerRemoveOptions{ + Force: force, + RemoveVolumes: true, + } + + if _, err := c.api.ContainerRemove(ctx, containerID, opts); err != nil { + return fmt.Errorf("remove container %s: %w", containerID, err) + } + return nil +} + +// RestartContainer restarts a container with the given timeout in seconds. +func (c *Client) RestartContainer(ctx context.Context, containerID string, timeoutSeconds int) error { + opts := client.ContainerRestartOptions{} + if timeoutSeconds > 0 { + opts.Timeout = &timeoutSeconds + } + + if _, err := c.api.ContainerRestart(ctx, containerID, opts); err != nil { + return fmt.Errorf("restart container %s: %w", containerID, err) + } + return nil +} + +// ManagedContainer holds summary information about a container managed by docker-watcher. +type ManagedContainer struct { + ID string + Name string + Image string + Status string + State string + Project string + Stage string + InstanceID string + Ports []uint16 +} + +// ListContainers returns all containers matching the given label filters. +// Pass nil or an empty map to list all docker-watcher managed containers. +// Label filters are key=value pairs applied as Docker label filters. +func (c *Client) ListContainers(ctx context.Context, labelFilters map[string]string) ([]ManagedContainer, error) { + filterArgs := make(client.Filters) + + // Always filter by the docker-watcher project label to only return managed containers. + filterArgs.Add("label", LabelProject) + + for k, v := range labelFilters { + if v != "" { + filterArgs.Add("label", k+"="+v) + } else { + filterArgs.Add("label", k) + } + } + + listResult, err := c.api.ContainerList(ctx, client.ContainerListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return nil, fmt.Errorf("list containers: %w", err) + } + + result := make([]ManagedContainer, 0, len(listResult.Items)) + for _, ctr := range listResult.Items { + name := "" + if len(ctr.Names) > 0 { + // Docker prefixes names with "/". + name = strings.TrimPrefix(ctr.Names[0], "/") + } + + var ports []uint16 + for _, p := range ctr.Ports { + if p.PublicPort > 0 { + ports = append(ports, p.PublicPort) + } + } + + result = append(result, ManagedContainer{ + ID: ctr.ID, + Name: name, + Image: ctr.Image, + Status: ctr.Status, + State: string(ctr.State), + Project: ctr.Labels[LabelProject], + Stage: ctr.Labels[LabelStage], + InstanceID: ctr.Labels[LabelInstanceID], + Ports: ports, + }) + } + + return result, nil +} + +// InspectContainerPort returns the host port mapped to a given container port. +// This is useful after starting a container with auto-assigned ports. +func (c *Client) InspectContainerPort(ctx context.Context, containerID string, containerPort string) (uint16, error) { + inspectResult, err := c.api.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + return 0, fmt.Errorf("inspect container %s: %w", containerID, err) + } + inspect := inspectResult.Container + + port, err := network.ParsePort(containerPort) + if err != nil { + return 0, fmt.Errorf("parse container port %s: %w", containerPort, err) + } + + bindings, ok := inspect.NetworkSettings.Ports[port] + if !ok || len(bindings) == 0 { + return 0, fmt.Errorf("container %s: no binding for port %s", containerID, containerPort) + } + + var hostPort uint16 + for _, b := range bindings { + if b.HostPort != "" { + parsed := parsePort(b.HostPort) + if parsed > 0 { + hostPort = parsed + break + } + } + } + + if hostPort == 0 { + return 0, fmt.Errorf("container %s: no host port for %s", containerID, containerPort) + } + + return hostPort, nil +} + +// parsePort converts a port string to uint16. Returns 0 on failure. +func parsePort(s string) uint16 { + n, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return 0 + } + return uint16(n) +} diff --git a/internal/docker/image.go b/internal/docker/image.go new file mode 100644 index 0000000..bcc2793 --- /dev/null +++ b/internal/docker/image.go @@ -0,0 +1,104 @@ +package docker + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/moby/moby/api/types/registry" + "github.com/moby/moby/client" +) + +// ImageInfo holds metadata extracted from a Docker image inspection. +type ImageInfo struct { + // ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]). + ExposedPorts []string + + // Healthcheck is the CMD string from the image's HEALTHCHECK instruction, if any. + Healthcheck string + + // Labels are the key-value pairs defined in the image metadata. + Labels map[string]string +} + +// PullImage pulls an image from a registry. If authConfig is non-empty, it is +// used as the base64-encoded JSON auth payload for private registries. +// The image reference should be in the form "repository:tag". +func (c *Client) PullImage(ctx context.Context, imageRef string, tag string, authConfig string) error { + ref := imageRef + if tag != "" { + ref = imageRef + ":" + tag + } + + opts := client.ImagePullOptions{} + if authConfig != "" { + opts.RegistryAuth = authConfig + } + + reader, err := c.api.ImagePull(ctx, ref, opts) + if err != nil { + return fmt.Errorf("pull image %s: %w", ref, err) + } + + // Wait for the pull to complete. + if err := reader.Wait(ctx); err != nil { + return fmt.Errorf("wait for pull of %s: %w", ref, err) + } + + return nil +} + +// InspectImage retrieves metadata from a local image. +func (c *Client) InspectImage(ctx context.Context, imageRef string) (ImageInfo, error) { + inspectResult, err := c.api.ImageInspect(ctx, imageRef) + if err != nil { + return ImageInfo{}, fmt.Errorf("inspect image %s: %w", imageRef, err) + } + + info := ImageInfo{} + + // Extract labels from Config if available. + if inspectResult.Config != nil { + info.Labels = inspectResult.Config.Labels + + // Extract exposed ports from OCI config (map[string]struct{}). + for port := range inspectResult.Config.ExposedPorts { + info.ExposedPorts = append(info.ExposedPorts, port) + } + + // Extract healthcheck command. + if inspectResult.Config.Healthcheck != nil && len(inspectResult.Config.Healthcheck.Test) > 0 { + // The Test slice is ["CMD", "arg1", "arg2", ...] or ["CMD-SHELL", "cmd string"]. + // Join all parts after the first element for a readable representation. + if len(inspectResult.Config.Healthcheck.Test) > 1 { + info.Healthcheck = joinArgs(inspectResult.Config.Healthcheck.Test[1:]) + } + } + } + + return info, nil +} + +// EncodeRegistryAuth builds a base64-encoded JSON auth string suitable for +// Docker API calls. Pass empty strings for anonymous access. +func EncodeRegistryAuth(username, password, serverAddress string) (string, error) { + cfg := registry.AuthConfig{ + Username: username, + Password: password, + ServerAddress: serverAddress, + } + + data, err := json.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("encode registry auth: %w", err) + } + + return base64.URLEncoding.EncodeToString(data), nil +} + +// joinArgs joins string arguments with spaces. +func joinArgs(args []string) string { + return strings.Join(args, " ") +} diff --git a/internal/docker/network.go b/internal/docker/network.go new file mode 100644 index 0000000..240c05e --- /dev/null +++ b/internal/docker/network.go @@ -0,0 +1,55 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +// EnsureNetwork creates a Docker network with the given name if it does not +// already exist. It returns the network ID in all cases. +func (c *Client) EnsureNetwork(ctx context.Context, networkName string) (string, error) { + // Check if the network already exists. + filterArgs := make(client.Filters).Add("name", networkName) + + listResult, err := c.api.NetworkList(ctx, client.NetworkListOptions{ + Filters: filterArgs, + }) + if err != nil { + return "", fmt.Errorf("list networks for %s: %w", networkName, err) + } + + // NetworkList with a name filter may return partial matches, so check exact name. + for _, n := range listResult.Items { + if n.Name == networkName { + return n.ID, nil + } + } + + // Create the network. + resp, err := c.api.NetworkCreate(ctx, networkName, client.NetworkCreateOptions{ + Driver: "bridge", + Labels: map[string]string{ + LabelProject: "docker-watcher", + }, + }) + if err != nil { + return "", fmt.Errorf("create network %s: %w", networkName, err) + } + + return resp.ID, nil +} + +// ConnectNetwork attaches a container to an existing network. +func (c *Client) ConnectNetwork(ctx context.Context, networkID string, containerID string) error { + _, err := c.api.NetworkConnect(ctx, networkID, client.NetworkConnectOptions{ + Container: containerID, + EndpointConfig: &network.EndpointSettings{}, + }) + if err != nil { + return fmt.Errorf("connect container %s to network %s: %w", containerID, networkID, err) + } + return nil +} diff --git a/internal/events/bus.go b/internal/events/bus.go new file mode 100644 index 0000000..a4097a2 --- /dev/null +++ b/internal/events/bus.go @@ -0,0 +1,121 @@ +package events + +import ( + "encoding/json" + "sync" +) + +// EventType identifies the kind of event being published. +type EventType string + +const ( + // EventDeployLog is emitted when a new deploy log line is appended. + EventDeployLog EventType = "deploy_log" + + // EventInstanceStatus is emitted when an instance status changes. + EventInstanceStatus EventType = "instance_status" + + // EventDeployStatus is emitted when a deploy status changes. + EventDeployStatus EventType = "deploy_status" +) + +// Event is a single event published on the bus. +type Event struct { + Type EventType `json:"type"` + Payload any `json:"payload"` +} + +// DeployLogPayload is the payload for EventDeployLog events. +type DeployLogPayload struct { + DeployID string `json:"deploy_id"` + Message string `json:"message"` + Level string `json:"level"` +} + +// InstanceStatusPayload is the payload for EventInstanceStatus events. +type InstanceStatusPayload struct { + InstanceID string `json:"instance_id"` + ProjectID string `json:"project_id"` + StageID string `json:"stage_id"` + Status string `json:"status"` +} + +// DeployStatusPayload is the payload for EventDeployStatus events. +type DeployStatusPayload struct { + DeployID string `json:"deploy_id"` + ProjectID string `json:"project_id"` + StageID string `json:"stage_id"` + ImageTag string `json:"image_tag"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// Subscriber is a channel that receives events. +type Subscriber chan Event + +// Bus is a simple in-process pub/sub event bus. +// It supports topic-based filtering and per-subscriber buffering. +type Bus struct { + mu sync.RWMutex + subscribers map[Subscriber]subscriberInfo +} + +type subscriberInfo struct { + filter func(Event) bool +} + +// New creates a new event bus. +func New() *Bus { + return &Bus{ + subscribers: make(map[Subscriber]subscriberInfo), + } +} + +// Subscribe registers a new subscriber with an optional filter. +// If filter is nil, the subscriber receives all events. +// The returned channel is buffered to avoid blocking publishers. +func (b *Bus) Subscribe(filter func(Event) bool) Subscriber { + ch := make(Subscriber, 64) + b.mu.Lock() + b.subscribers[ch] = subscriberInfo{filter: filter} + b.mu.Unlock() + return ch +} + +// Unsubscribe removes a subscriber and closes its channel. +func (b *Bus) Unsubscribe(ch Subscriber) { + b.mu.Lock() + if _, ok := b.subscribers[ch]; ok { + delete(b.subscribers, ch) + close(ch) + } + b.mu.Unlock() +} + +// Publish sends an event to all matching subscribers. +// If a subscriber's buffer is full, the event is dropped for that subscriber +// to avoid blocking the publisher. +func (b *Bus) Publish(evt Event) { + b.mu.RLock() + defer b.mu.RUnlock() + + for ch, info := range b.subscribers { + if info.filter != nil && !info.filter(evt) { + continue + } + // Non-blocking send — drop if subscriber is slow. + select { + case ch <- evt: + default: + } + } +} + +// MarshalEvent serializes an event to a JSON string suitable for SSE data lines. +func MarshalEvent(evt Event) (string, error) { + data, err := json.Marshal(evt) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/health/checker.go b/internal/health/checker.go new file mode 100644 index 0000000..885cb9a --- /dev/null +++ b/internal/health/checker.go @@ -0,0 +1,79 @@ +package health + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// DefaultRetries is the number of health check attempts before declaring failure. +const DefaultRetries = 3 + +// DefaultRetryInterval is the pause between health check retries. +const DefaultRetryInterval = 5 * time.Second + +// DefaultTimeout is the HTTP timeout for a single health check attempt. +const DefaultTimeout = 10 * time.Second + +// Checker performs HTTP health checks against a container endpoint. +type Checker struct { + httpClient *http.Client + retries int + retryInterval time.Duration +} + +// New creates a Checker with default settings. +func New() *Checker { + return &Checker{ + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, + retries: DefaultRetries, + retryInterval: DefaultRetryInterval, + } +} + +// Check performs an HTTP GET health check against the given URL. +// It retries up to the configured number of times, waiting retryInterval between attempts. +// Returns nil on the first successful (2xx) response, or the last error encountered. +func (c *Checker) Check(ctx context.Context, url string) error { + var lastErr error + + for attempt := 0; attempt < c.retries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return fmt.Errorf("health check cancelled: %w", ctx.Err()) + case <-time.After(c.retryInterval): + } + } + + lastErr = c.doCheck(ctx, url) + if lastErr == nil { + return nil + } + } + + return fmt.Errorf("health check failed after %d attempts: %w", c.retries, lastErr) +} + +// doCheck performs a single HTTP GET health check. +func (c *Checker) doCheck(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create health check request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("health check request to %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("health check %s returned status %d", url, resp.StatusCode) + } + + return nil +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..b263653 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,34 @@ +package logging + +import ( + "io" + "log/slog" + "os" +) + +// Setup initializes the global structured JSON logger. +// It replaces the default slog handler with a JSON handler writing to stdout. +func Setup() { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + slog.SetDefault(slog.New(handler)) +} + +// SetupWithWriter initializes the global structured JSON logger writing to the given writer. +func SetupWithWriter(w io.Writer) { + handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + slog.SetDefault(slog.New(handler)) +} + +// DeployContext returns a logger enriched with deploy-specific attributes. +func DeployContext(project, stage, tag, instanceID string) *slog.Logger { + return slog.With( + slog.String("project", project), + slog.String("stage", stage), + slog.String("tag", tag), + slog.String("instance_id", instanceID), + ) +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go new file mode 100644 index 0000000..ddc4f2d --- /dev/null +++ b/internal/notify/notifier.go @@ -0,0 +1,82 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +// Event represents a deployment notification payload. +type Event struct { + Type string `json:"type"` // "deploy_success" or "deploy_failure" + Project string `json:"project"` + Stage string `json:"stage"` + ImageTag string `json:"image_tag"` + Subdomain string `json:"subdomain"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` + Timestamp string `json:"timestamp"` +} + +// Notifier sends webhook notifications for deploy events. +// Notifications are fire-and-forget — failures are logged but do not propagate. +type Notifier struct { + httpClient *http.Client +} + +// New creates a Notifier with sensible defaults. +func New() *Notifier { + return &Notifier{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Send sends a notification event to the given webhook URL in a background goroutine. +// It does not block the caller. Errors are logged, not returned. +func (n *Notifier) Send(webhookURL string, event Event) { + if webhookURL == "" { + return + } + + if event.Timestamp == "" { + event.Timestamp = time.Now().UTC().Format(time.RFC3339) + } + + go func() { + if err := n.doSend(context.Background(), webhookURL, event); err != nil { + log.Printf("notify: failed to send webhook to %s: %v", webhookURL, err) + } + }() +} + +// doSend performs the actual HTTP POST to the webhook URL. +func (n *Notifier) doSend(ctx context.Context, webhookURL string, event Event) error { + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal notification: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create notification request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := n.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("notification webhook returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/npm/client.go b/internal/npm/client.go new file mode 100644 index 0000000..e07eb7d --- /dev/null +++ b/internal/npm/client.go @@ -0,0 +1,293 @@ +package npm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Client is an HTTP client for the Nginx Proxy Manager API. +// It handles JWT authentication, automatic token refresh, and CRUD for proxy hosts. +type Client struct { + baseURL string + httpClient *http.Client + + mu sync.Mutex + token string + expiry time.Time + email string + password string +} + +// New creates an NPM client targeting the given base URL (e.g. "http://npm:81/api"). +// The returned client is not yet authenticated — call Authenticate before other methods. +func New(baseURL string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Authenticate obtains a JWT from the NPM API and caches it for future requests. +// The credentials are also stored so the client can re-authenticate automatically on 401. +func (c *Client) Authenticate(ctx context.Context, email, password string) error { + c.mu.Lock() + c.email = email + c.password = password + c.mu.Unlock() + + return c.authenticate(ctx, email, password) +} + +func (c *Client) authenticate(ctx context.Context, email, password string) error { + body, err := json.Marshal(authRequest{ + Identity: email, + Secret: password, + }) + if err != nil { + return fmt.Errorf("marshal auth request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/tokens", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create auth request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send auth request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read auth response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("authenticate: status %d: %s", resp.StatusCode, string(respBody)) + } + + var authResp authResponse + if err := json.Unmarshal(respBody, &authResp); err != nil { + return fmt.Errorf("decode auth response: %w", err) + } + + expiry, err := time.Parse(time.RFC3339, authResp.Expires) + if err != nil { + // If we cannot parse the expiry, set a conservative 12-hour window. + expiry = time.Now().Add(12 * time.Hour) + } + + c.mu.Lock() + c.token = authResp.Token + c.expiry = expiry + c.mu.Unlock() + + return nil +} + +// CreateProxyHost creates a new proxy host and returns the created resource. +func (c *Client) CreateProxyHost(ctx context.Context, config ProxyHostConfig) (ProxyHost, error) { + var host ProxyHost + if err := c.doJSON(ctx, http.MethodPost, "/nginx/proxy-hosts", config, &host); err != nil { + return ProxyHost{}, fmt.Errorf("create proxy host: %w", err) + } + return host, nil +} + +// UpdateProxyHost updates an existing proxy host by ID and returns the updated resource. +func (c *Client) UpdateProxyHost(ctx context.Context, id int, config ProxyHostConfig) (ProxyHost, error) { + var host ProxyHost + path := fmt.Sprintf("/nginx/proxy-hosts/%d", id) + if err := c.doJSON(ctx, http.MethodPut, path, config, &host); err != nil { + return ProxyHost{}, fmt.Errorf("update proxy host %d: %w", id, err) + } + return host, nil +} + +// DeleteProxyHost deletes a proxy host by ID. +func (c *Client) DeleteProxyHost(ctx context.Context, id int) error { + path := fmt.Sprintf("/nginx/proxy-hosts/%d", id) + if err := c.doJSON(ctx, http.MethodDelete, path, nil, nil); err != nil { + return fmt.Errorf("delete proxy host %d: %w", id, err) + } + return nil +} + +// ListProxyHosts returns all proxy hosts. +func (c *Client) ListProxyHosts(ctx context.Context) ([]ProxyHost, error) { + var hosts []ProxyHost + if err := c.doJSON(ctx, http.MethodGet, "/nginx/proxy-hosts", nil, &hosts); err != nil { + return nil, fmt.Errorf("list proxy hosts: %w", err) + } + return hosts, nil +} + +// FindProxyHostByDomain searches existing proxy hosts for one that serves the given domain. +// Returns the matching host and true if found, or a zero-value ProxyHost and false otherwise. +func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (ProxyHost, bool, error) { + hosts, err := c.ListProxyHosts(ctx) + if err != nil { + return ProxyHost{}, false, fmt.Errorf("find proxy host by domain: %w", err) + } + + needle := strings.ToLower(domain) + for _, h := range hosts { + for _, d := range h.DomainNames { + if strings.ToLower(d) == needle { + return h, true, nil + } + } + } + + return ProxyHost{}, false, nil +} + +// doJSON performs an authenticated JSON API request. If the token is expired or a 401 +// is received, it automatically re-authenticates and retries the request once. +func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error { + if err := c.ensureToken(ctx); err != nil { + return err + } + + err := c.doJSONOnce(ctx, method, path, reqBody, result) + if err == nil { + return nil + } + + // If we got a 401, attempt re-auth and retry once. + if isUnauthorized(err) { + c.mu.Lock() + email := c.email + password := c.password + c.mu.Unlock() + + if authErr := c.authenticate(ctx, email, password); authErr != nil { + return fmt.Errorf("re-authenticate after 401: %w", authErr) + } + return c.doJSONOnce(ctx, method, path, reqBody, result) + } + + return err +} + +// errUnauthorized is a sentinel used to detect 401 responses for automatic re-auth. +type errUnauthorized struct { + wrapped error +} + +func (e *errUnauthorized) Error() string { return e.wrapped.Error() } +func (e *errUnauthorized) Unwrap() error { return e.wrapped } + +func isUnauthorized(err error) bool { + var target *errUnauthorized + return errors.As(err, &target) +} + +func (c *Client) doJSONOnce(ctx context.Context, method, path string, reqBody any, result any) error { + var bodyReader io.Reader + if reqBody != nil { + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + c.mu.Lock() + token := c.token + c.mu.Unlock() + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + return &errUnauthorized{ + wrapped: fmt.Errorf("status 401: %s", string(respBody)), + } + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("npm api %s %s: status %d: %s", method, path, resp.StatusCode, string(respBody)) + } + + // DELETE returns 200 with no body. + if result != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + + return nil +} + +// ensureToken checks if the cached token is still valid and re-authenticates if needed. +func (c *Client) ensureToken(ctx context.Context) error { + c.mu.Lock() + token := c.token + expiry := c.expiry + email := c.email + password := c.password + c.mu.Unlock() + + if token == "" { + return fmt.Errorf("npm client not authenticated: call Authenticate first") + } + + // Refresh the token 5 minutes before expiry to avoid race conditions. + if time.Now().Add(5 * time.Minute).After(expiry) { + if err := c.authenticate(ctx, email, password); err != nil { + return fmt.Errorf("refresh expired token: %w", err) + } + } + + return nil +} + +// UnmarshalJSON allows boolInt to decode both JSON booleans and 0/1 integers. +func (b *boolInt) UnmarshalJSON(data []byte) error { + s := strings.TrimSpace(string(data)) + switch s { + case "true", "1": + *b = true + case "false", "0", "null": + *b = false + default: + return fmt.Errorf("cannot unmarshal %q as boolInt", s) + } + return nil +} + +// MarshalJSON encodes boolInt as a JSON boolean. +func (b boolInt) MarshalJSON() ([]byte, error) { + if b { + return []byte("true"), nil + } + return []byte("false"), nil +} diff --git a/internal/npm/types.go b/internal/npm/types.go new file mode 100644 index 0000000..5da2744 --- /dev/null +++ b/internal/npm/types.go @@ -0,0 +1,76 @@ +package npm + +// ProxyHostConfig holds the input fields for creating or updating a proxy host. +type ProxyHostConfig struct { + DomainNames []string `json:"domain_names"` + ForwardScheme string `json:"forward_scheme"` + ForwardHost string `json:"forward_host"` + ForwardPort int `json:"forward_port"` + CertificateID int `json:"certificate_id"` + SSLForced bool `json:"ssl_forced"` + BlockExploits bool `json:"block_exploits"` + CachingEnabled bool `json:"caching_enabled"` + AllowWebsocket bool `json:"allow_websocket_upgrade"` + HTTP2Support bool `json:"http2_support"` + AdvancedConfig string `json:"advanced_config"` + HSTSEnabled bool `json:"hsts_enabled"` + HSTSSubdomains bool `json:"hsts_subdomains"` + AccessListID int `json:"access_list_id"` + Meta Meta `json:"meta"` + Locations []any `json:"locations"` +} + +// Meta holds metadata tags for a proxy host. +type Meta struct { + LetsEncryptAgree bool `json:"letsencrypt_agree"` + DNSChallenge bool `json:"dns_challenge"` + LetsEncryptEmail string `json:"letsencrypt_email,omitempty"` +} + +// ProxyHost represents a proxy host as returned by the NPM API. +type ProxyHost struct { + ID int `json:"id"` + DomainNames []string `json:"domain_names"` + ForwardScheme string `json:"forward_scheme"` + ForwardHost string `json:"forward_host"` + ForwardPort int `json:"forward_port"` + CertificateID any `json:"certificate_id"` + SSLForced boolInt `json:"ssl_forced"` + BlockExploits boolInt `json:"block_exploits"` + CachingEnabled boolInt `json:"caching_enabled"` + AllowWebsocket boolInt `json:"allow_websocket_upgrade"` + HTTP2Support boolInt `json:"http2_support"` + AdvancedConfig string `json:"advanced_config"` + HSTSEnabled boolInt `json:"hsts_enabled"` + HSTSSubdomains boolInt `json:"hsts_subdomains"` + AccessListID int `json:"access_list_id"` + Meta Meta `json:"meta"` + Enabled boolInt `json:"enabled"` + CreatedOn string `json:"created_on"` + ModifiedOn string `json:"modified_on"` +} + +// boolInt handles the NPM API's inconsistent use of 0/1 integers for boolean fields. +type boolInt bool + +// authRequest is the request body for POST /api/tokens. +type authRequest struct { + Identity string `json:"identity"` + Secret string `json:"secret"` +} + +// authResponse is the response body from POST /api/tokens. +type authResponse struct { + Token string `json:"token"` + Expires string `json:"expires"` +} + +// apiError represents an error response from the NPM API. +type apiError struct { + Error apiErrorDetail `json:"error"` +} + +type apiErrorDetail struct { + Message string `json:"message"` + Code int `json:"code"` +} diff --git a/internal/registry/gitea.go b/internal/registry/gitea.go new file mode 100644 index 0000000..638cc95 --- /dev/null +++ b/internal/registry/gitea.go @@ -0,0 +1,290 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// giteaPackageVersion represents a single version entry from the Gitea +// packages API response. +type giteaPackageVersion struct { + ID int64 `json:"id"` + Version string `json:"version"` + Creator struct { + Login string `json:"login"` + } `json:"creator"` + CreatedAt time.Time `json:"created_at"` +} + +// GiteaClient implements Client for Gitea container registries. +type GiteaClient struct { + baseURL string + token string + httpClient *http.Client +} + +// NewGiteaClient creates a new Gitea registry client. +// baseURL should be the Gitea instance URL (e.g., "https://git.example.com"). +// token is a personal access token with package read permissions. +func NewGiteaClient(baseURL, token string) *GiteaClient { + return &GiteaClient{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ListImages returns all container images (packages) for the given owner. +// It queries GET /api/v1/packages/{owner}?type=container and paginates +// through all results, returning a RegistryImage for each unique package. +func (c *GiteaClient) ListImages(ctx context.Context, owner string) ([]RegistryImage, error) { + if owner == "" { + return nil, fmt.Errorf("owner is required for listing images") + } + + // Extract the registry host from baseURL to build full references. + host := c.baseURL + for _, prefix := range []string{"https://", "http://"} { + host = strings.TrimPrefix(host, prefix) + } + host = strings.TrimRight(host, "/") + + var images []RegistryImage + seen := make(map[string]bool) + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&page=%d&limit=%d", + c.baseURL, owner, page, limit) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var packages []giteaPackageListEntry + if err := json.Unmarshal(body, &packages); err != nil { + return nil, fmt.Errorf("decode package list: %w", err) + } + + for _, p := range packages { + if !seen[p.Name] { + seen[p.Name] = true + images = append(images, RegistryImage{ + Name: p.Name, + Owner: owner, + FullRef: fmt.Sprintf("%s/%s/%s", host, owner, p.Name), + }) + } + } + + if len(packages) < limit { + break + } + page++ + } + + return images, nil +} + +// ListTags returns all available tags for the given container image. +// The image should be in the format "owner/package-name" or +// "registry-host/owner/package-name" (the registry host prefix is stripped). +func (c *GiteaClient) ListTags(ctx context.Context, image string) ([]string, error) { + owner, pkg := parseImage(image) + if owner == "" || pkg == "" { + return nil, fmt.Errorf("invalid image format %q: expected owner/package", image) + } + + versions, err := c.listPackageVersions(ctx, owner, pkg) + if err != nil { + return nil, fmt.Errorf("list tags for %s/%s: %w", owner, pkg, err) + } + + tags := make([]string, 0, len(versions)) + for _, v := range versions { + tags = append(tags, v.Version) + } + return tags, nil +} + +// GetLatestTag returns the most recently created tag matching the given glob +// pattern. Returns empty string if no tags match. +func (c *GiteaClient) GetLatestTag(ctx context.Context, image string, pattern string) (string, error) { + tags, err := c.ListTags(ctx, image) + if err != nil { + return "", err + } + return LatestTag(tags, pattern) +} + +// listPackageVersions fetches all container package versions from the Gitea API. +// Endpoint: GET /api/v1/packages/{owner}?type=container&q={package} +// Gitea paginates results; this function fetches all pages. +func (c *GiteaClient) listPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) { + var allVersions []giteaPackageVersion + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&q=%s&page=%d&limit=%d", + c.baseURL, owner, pkg, page, limit) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var packages []giteaPackageListEntry + if err := json.Unmarshal(body, &packages); err != nil { + return nil, fmt.Errorf("decode package list: %w", err) + } + + // Filter for exact package name match and collect versions. + for _, p := range packages { + if p.Name == pkg { + versions, err := c.fetchPackageVersions(ctx, owner, pkg) + if err != nil { + return nil, err + } + return versions, nil + } + } + + // If we got fewer results than the limit, we've reached the last page. + if len(packages) < limit { + break + } + page++ + } + + return allVersions, nil +} + +// giteaPackageListEntry represents a package in the Gitea packages list response. +type giteaPackageListEntry struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Version string `json:"version"` +} + +// fetchPackageVersions fetches all versions of a specific container package. +// Endpoint: GET /api/v1/packages/{owner}/container/{name} +func (c *GiteaClient) fetchPackageVersions(ctx context.Context, owner, pkg string) ([]giteaPackageVersion, error) { + var allVersions []giteaPackageVersion + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/packages/%s/container/%s?page=%d&limit=%d", + c.baseURL, owner, pkg, page, limit) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var versions []giteaPackageVersion + if err := json.Unmarshal(body, &versions); err != nil { + return nil, fmt.Errorf("decode versions: %w", err) + } + + allVersions = append(allVersions, versions...) + + if len(versions) < limit { + break + } + page++ + } + + return allVersions, nil +} + +// parseImage extracts the owner and package name from an image string. +// Supported formats: +// - "owner/package" +// - "registry.example.com/owner/package" +// +// Returns empty strings if the format is invalid. +func parseImage(image string) (owner, pkg string) { + parts := strings.Split(image, "/") + switch len(parts) { + case 2: + // owner/package + return parts[0], parts[1] + case 3: + // registry.example.com/owner/package + return parts[1], parts[2] + default: + return "", "" + } +} diff --git a/internal/registry/poller.go b/internal/registry/poller.go new file mode 100644 index 0000000..e8e8b6b --- /dev/null +++ b/internal/registry/poller.go @@ -0,0 +1,210 @@ +package registry + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" + "github.com/robfig/cron/v3" +) + +// Poller periodically checks registries for new image tags and triggers +// deployments for stages with auto_deploy enabled. +type Poller struct { + store *store.Store + deployer DeployTriggerer + encKey [32]byte + cron *cron.Cron + mu sync.Mutex + entryID cron.EntryID + running bool +} + +// NewPoller creates a new Poller instance. +func NewPoller(st *store.Store, deployer DeployTriggerer, encKey [32]byte) *Poller { + return &Poller{ + store: st, + deployer: deployer, + encKey: encKey, + cron: cron.New(), + } +} + +// Start begins the polling scheduler with the given interval string (e.g., "5m", "1h"). +// If the poller is already running, it stops and restarts with the new interval. +func (p *Poller) Start(interval string) error { + p.mu.Lock() + defer p.mu.Unlock() + + duration, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("parse polling interval %q: %w", interval, err) + } + + // Stop existing schedule if running. + if p.running { + p.cron.Remove(p.entryID) + } + + // Convert duration to a cron schedule: @every . + spec := fmt.Sprintf("@every %s", duration.String()) + entryID, err := p.cron.AddFunc(spec, func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if pollErr := p.poll(ctx); pollErr != nil { + log.Printf("[poller] poll error: %v", pollErr) + } + }) + if err != nil { + return fmt.Errorf("schedule poller: %w", err) + } + + p.entryID = entryID + if !p.running { + p.cron.Start() + } + p.running = true + + log.Printf("[poller] started with interval %s", duration) + return nil +} + +// Stop gracefully shuts down the poller. +func (p *Poller) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + ctx := p.cron.Stop() + <-ctx.Done() + p.running = false + log.Println("[poller] stopped") + } +} + +// poll performs a single polling cycle: iterates over all projects and their +// stages, checks for new tags, and triggers deploys where appropriate. +func (p *Poller) poll(ctx context.Context) error { + projects, err := p.store.GetAllProjects() + if err != nil { + return fmt.Errorf("get projects: %w", err) + } + + for _, project := range projects { + if err := p.pollProject(ctx, project); err != nil { + log.Printf("[poller] project %s (%s): %v", project.Name, project.ID, err) + // Continue polling other projects even if one fails. + } + } + return nil +} + +// pollProject checks all stages of a single project for new tags. +func (p *Poller) pollProject(ctx context.Context, project store.Project) error { + if project.Registry == "" { + return nil + } + + // Look up the registry configuration by name (projects store registry name, not ID). + reg, err := p.store.GetRegistryByName(project.Registry) + if err != nil { + return fmt.Errorf("get registry %s: %w", project.Registry, err) + } + + // Decrypt the registry token. + token, err := crypto.Decrypt(p.encKey, reg.Token) + if err != nil { + // Token might not be encrypted (empty or plaintext). + token = reg.Token + } + + // Create a registry client for this registry type. + client, err := NewClient(reg.Type, reg.URL, token) + if err != nil { + return fmt.Errorf("create registry client: %w", err) + } + + // Fetch all available tags for the project image. + tags, err := client.ListTags(ctx, project.Image) + if err != nil { + return fmt.Errorf("list tags for %s: %w", project.Image, err) + } + + // Check each stage of the project. + stages, err := p.store.GetStagesByProjectID(project.ID) + if err != nil { + return fmt.Errorf("get stages for project %s: %w", project.ID, err) + } + + for _, stage := range stages { + if err := p.pollStage(ctx, project, stage, tags); err != nil { + log.Printf("[poller] project %s stage %s: %v", project.Name, stage.Name, err) + } + } + return nil +} + +// pollStage checks a single stage for new tags and triggers deploy if needed. +func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error { + // Find the latest tag matching the stage's pattern. + latest, err := LatestTag(allTags, stage.TagPattern) + if err != nil { + return fmt.Errorf("match tags for stage %s: %w", stage.Name, err) + } + if latest == "" { + return nil + } + + // Get the last polled tag for this stage. + state, err := p.store.GetPollState(stage.ID) + if err != nil { + // No poll state yet — this is the first poll for this stage. + // Record the current latest tag without triggering a deploy, + // so we don't deploy everything on first startup. + return p.store.UpsertPollState(store.PollState{ + StageID: stage.ID, + LastTag: latest, + LastPolled: now(), + }) + } + + // Update the poll timestamp regardless. + defer func() { + if err := p.store.UpsertPollState(store.PollState{ + StageID: stage.ID, + LastTag: latest, + LastPolled: now(), + }); err != nil { + log.Printf("[poller] failed to update poll state for stage %s: %v", stage.ID, err) + } + }() + + // If the latest tag hasn't changed, nothing to do. + if state.LastTag == latest { + return nil + } + + log.Printf("[poller] new tag %q detected for project %s stage %s (was %q)", + latest, project.Name, stage.Name, state.LastTag) + + // Only trigger deploy if auto_deploy is enabled for this stage. + if !stage.AutoDeploy { + log.Printf("[poller] auto_deploy disabled for stage %s, skipping deploy", stage.Name) + return nil + } + + if err := p.deployer.TriggerDeploy(ctx, project.ID, stage.ID, latest); err != nil { + return fmt.Errorf("trigger deploy for tag %s: %w", latest, err) + } + + return nil +} + +// now returns the current UTC time as a formatted string. +func now() string { + return time.Now().UTC().Format("2006-01-02 15:04:05") +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..4471b3b --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,89 @@ +package registry + +import ( + "context" + "fmt" + "path" + "sort" + "strings" +) + +// RegistryImage represents a container image discovered from a registry. +type RegistryImage struct { + Name string `json:"name"` + Owner string `json:"owner"` + FullRef string `json:"full_ref"` // e.g., "git.example.com/owner/my-app" +} + +// Client defines the interface for interacting with a container image registry. +type Client interface { + // ListTags returns all available tags for the given image. + ListTags(ctx context.Context, image string) ([]string, error) + + // GetLatestTag returns the most recently created tag that matches the given + // glob pattern. Returns an empty string and no error if no tags match. + GetLatestTag(ctx context.Context, image string, pattern string) (string, error) + + // ListImages returns all container images available in the registry for the + // given owner. Returns an error if the registry does not support image listing. + ListImages(ctx context.Context, owner string) ([]RegistryImage, error) +} + +// DeployTriggerer is called by the poller when a new tag is detected for a +// stage with auto_deploy enabled. This decouples the registry package from the +// deployer implementation. +type DeployTriggerer interface { + TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error +} + +// MatchTags filters a list of tags, returning only those that match the given +// glob pattern. Pattern matching uses path.Match semantics (*, ?, []). +// Returns an error if the pattern is malformed. +func MatchTags(tags []string, pattern string) ([]string, error) { + if pattern == "" || pattern == "*" { + result := make([]string, len(tags)) + copy(result, tags) + return result, nil + } + + // Validate pattern once before iterating. + if _, err := path.Match(pattern, ""); err != nil { + return nil, fmt.Errorf("invalid tag pattern %q: %w", pattern, err) + } + + var matched []string + for _, tag := range tags { + ok, _ := path.Match(pattern, tag) + if ok { + matched = append(matched, tag) + } + } + return matched, nil +} + +// LatestTag returns the last element of a sorted tag list that matches the +// pattern. Tags are sorted lexicographically; the "latest" is the last in sort +// order. Returns empty string if no tags match. Returns an error if the pattern +// is malformed. +func LatestTag(tags []string, pattern string) (string, error) { + matched, err := MatchTags(tags, pattern) + if err != nil { + return "", err + } + if len(matched) == 0 { + return "", nil + } + sort.Strings(matched) + return matched[len(matched)-1], nil +} + +// NewClient creates a registry Client based on the registry type string. +// Supported types: "gitea". Future: "github", "dockerhub". +func NewClient(registryType, baseURL, token string) (Client, error) { + switch strings.ToLower(registryType) { + case "gitea": + return NewGiteaClient(baseURL, token), nil + default: + return nil, fmt.Errorf("unsupported registry type: %s", registryType) + } +} diff --git a/internal/store/deploys.go b/internal/store/deploys.go new file mode 100644 index 0000000..24e1e6c --- /dev/null +++ b/internal/store/deploys.go @@ -0,0 +1,167 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateDeploy inserts a new deploy record. +func (s *Store) CreateDeploy(d Deploy) (Deploy, error) { + d.ID = uuid.New().String() + d.StartedAt = now() + if d.Status == "" { + d.Status = "pending" + } + + _, err := s.db.Exec( + `INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error, + ) + if err != nil { + return Deploy{}, fmt.Errorf("insert deploy: %w", err) + } + return d, nil +} + +// GetDeployByID returns a single deploy by its ID. +func (s *Store) GetDeployByID(id string) (Deploy, error) { + var d Deploy + err := s.db.QueryRow( + `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error + FROM deploys WHERE id = ?`, id, + ).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error) + if errors.Is(err, sql.ErrNoRows) { + return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound) + } + if err != nil { + return Deploy{}, fmt.Errorf("query deploy: %w", err) + } + return d, nil +} + +// GetDeploysByProjectID returns all deploys for a project, newest first. +func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) { + rows, err := s.db.Query( + `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error + FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID, + ) + if err != nil { + return nil, fmt.Errorf("query deploys: %w", err) + } + defer rows.Close() + + return scanDeploys(rows) +} + +// GetRecentDeploys returns the most recent deploys across all projects. +func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) { + rows, err := s.db.Query( + `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error + FROM deploys ORDER BY started_at DESC LIMIT ?`, limit, + ) + if err != nil { + return nil, fmt.Errorf("query recent deploys: %w", err) + } + defer rows.Close() + + return scanDeploys(rows) +} + +// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy. +func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error { + ts := now() + var finishedAt string + if isTerminalDeployStatus(status) { + finishedAt = ts + } + + result, err := s.db.Exec( + `UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`, + status, deployErr, finishedAt, id, + ) + if err != nil { + return fmt.Errorf("update deploy status: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("deploy %s: %w", id, ErrNotFound) + } + return nil +} + +// SetDeployInstanceID links a deploy to the instance it created. +func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error { + result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID) + if err != nil { + return fmt.Errorf("set deploy instance: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound) + } + return nil +} + +// AppendDeployLog adds a log entry for a deploy. +func (s *Store) AppendDeployLog(deployID string, message string, level string) error { + if level == "" { + level = "info" + } + _, err := s.db.Exec( + `INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`, + deployID, message, level, now(), + ) + if err != nil { + return fmt.Errorf("append deploy log: %w", err) + } + return nil +} + +// GetDeployLogs returns all log entries for a deploy, ordered chronologically. +func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) { + rows, err := s.db.Query( + `SELECT id, deploy_id, message, level, created_at + FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID, + ) + if err != nil { + return nil, fmt.Errorf("query deploy logs: %w", err) + } + defer rows.Close() + + var logs []DeployLog + for rows.Next() { + var l DeployLog + if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil { + return nil, fmt.Errorf("scan deploy log: %w", err) + } + logs = append(logs, l) + } + return logs, rows.Err() +} + +// scanDeploys is a helper that scans deploy rows from a cursor. +func scanDeploys(rows *sql.Rows) ([]Deploy, error) { + var deploys []Deploy + for rows.Next() { + var d Deploy + if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil { + return nil, fmt.Errorf("scan deploy: %w", err) + } + deploys = append(deploys, d) + } + return deploys, rows.Err() +} + +// isTerminalDeployStatus returns true if the status indicates the deploy is finished. +func isTerminalDeployStatus(status string) bool { + switch status { + case "success", "failed", "rolled_back": + return true + default: + return false + } +} diff --git a/internal/store/instances.go b/internal/store/instances.go new file mode 100644 index 0000000..6203b12 --- /dev/null +++ b/internal/store/instances.go @@ -0,0 +1,137 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateInstance inserts a new instance record. +func (s *Store) CreateInstance(inst Instance) (Instance, error) { + inst.ID = uuid.New().String() + inst.CreatedAt = now() + inst.UpdatedAt = inst.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO instances (id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, + inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.CreatedAt, inst.UpdatedAt, + ) + if err != nil { + return Instance{}, fmt.Errorf("insert instance: %w", err) + } + return inst, nil +} + +// CreateInstanceWithID inserts a new instance using a pre-generated ID. +// Use this when the ID must be known before creation (e.g., for container labels). +func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) { + if inst.ID == "" { + return Instance{}, fmt.Errorf("instance ID is required") + } + inst.CreatedAt = now() + inst.UpdatedAt = inst.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO instances (id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, + inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.CreatedAt, inst.UpdatedAt, + ) + if err != nil { + return Instance{}, fmt.Errorf("insert instance: %w", err) + } + return inst, nil +} + +// GetInstanceByID returns a single instance by its ID. +func (s *Store) GetInstanceByID(id string) (Instance, error) { + var inst Instance + err := s.db.QueryRow( + `SELECT id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at + FROM instances WHERE id = ?`, id, + ).Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag, + &inst.Subdomain, &inst.NpmProxyID, &inst.Status, &inst.Port, &inst.CreatedAt, &inst.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Instance{}, fmt.Errorf("instance %s: %w", id, ErrNotFound) + } + if err != nil { + return Instance{}, fmt.Errorf("query instance: %w", err) + } + return inst, nil +} + +// GetInstancesByStageID returns all instances for a given stage. +func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) { + rows, err := s.db.Query( + `SELECT id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, created_at, updated_at + FROM instances WHERE stage_id = ? ORDER BY created_at DESC`, stageID, + ) + if err != nil { + return nil, fmt.Errorf("query instances: %w", err) + } + defer rows.Close() + + var instances []Instance + for rows.Next() { + var inst Instance + if err := rows.Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag, + &inst.Subdomain, &inst.NpmProxyID, &inst.Status, &inst.Port, &inst.CreatedAt, &inst.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan instance: %w", err) + } + instances = append(instances, inst) + } + return instances, rows.Err() +} + +// UpdateInstance updates an existing instance's mutable fields. +func (s *Store) UpdateInstance(inst Instance) error { + inst.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, updated_at=? + WHERE id=?`, + inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, + inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, inst.UpdatedAt, inst.ID, + ) + if err != nil { + return fmt.Errorf("update instance: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("instance %s: %w", inst.ID, ErrNotFound) + } + return nil +} + +// UpdateInstanceStatus sets only the status field on an instance. +func (s *Store) UpdateInstanceStatus(id string, status string) error { + ts := now() + result, err := s.db.Exec( + `UPDATE instances SET status=?, updated_at=? WHERE id=?`, + status, ts, id, + ) + if err != nil { + return fmt.Errorf("update instance status: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("instance %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteInstance removes an instance by ID. +func (s *Store) DeleteInstance(id string) error { + result, err := s.db.Exec(`DELETE FROM instances WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete instance: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("instance %s: %w", id, ErrNotFound) + } + return nil +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..2c48e50 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,117 @@ +package store + +// Project represents a deployable application. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Registry string `json:"registry"` + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` + Env string `json:"env"` // JSON-encoded map + Volumes string `json:"volumes"` // JSON-encoded map + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Stage represents a deployment stage within a project (e.g. dev, rel, prod). +type Stage struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + TagPattern string `json:"tag_pattern"` + AutoDeploy bool `json:"auto_deploy"` + MaxInstances int `json:"max_instances"` + Confirm bool `json:"confirm"` + PromoteFrom string `json:"promote_from"` + Subdomain string `json:"subdomain"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Registry represents a container image registry. +type Registry struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Token string `json:"token"` + Owner string `json:"owner"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Settings holds global application configuration (single-row pattern). +type Settings struct { + Domain string `json:"domain"` + ServerIP string `json:"server_ip"` + Network string `json:"network"` + SubdomainPattern string `json:"subdomain_pattern"` + NotificationURL string `json:"notification_url"` + NpmURL string `json:"npm_url"` + NpmEmail string `json:"npm_email"` + NpmPassword string `json:"npm_password"` + WebhookSecret string `json:"webhook_secret"` + PollingInterval string `json:"polling_interval"` + BaseVolumePath string `json:"base_volume_path"` + UpdatedAt string `json:"updated_at"` +} + +// Instance represents a running (or stopped) container for a project stage. +type Instance struct { + ID string `json:"id"` + StageID string `json:"stage_id"` + ProjectID string `json:"project_id"` + ContainerID string `json:"container_id"` + ImageTag string `json:"image_tag"` + Subdomain string `json:"subdomain"` + NpmProxyID int `json:"npm_proxy_id"` + Status string `json:"status"` // running, stopped, failed, removing + Port int `json:"port"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Deploy represents a deployment attempt. +type Deploy struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + StageID string `json:"stage_id"` + InstanceID string `json:"instance_id"` + ImageTag string `json:"image_tag"` + Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + Error string `json:"error"` +} + +// DeployLog is a single log entry for a deploy. +type DeployLog struct { + ID int64 `json:"id"` + DeployID string `json:"deploy_id"` + Message string `json:"message"` + Level string `json:"level"` // info, warn, error + CreatedAt string `json:"created_at"` +} + +// StageEnv represents a per-stage environment variable override. +type StageEnv struct { + ID string `json:"id"` + StageID string `json:"stage_id"` + Key string `json:"key"` + Value string `json:"value"` + Encrypted bool `json:"encrypted"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Volume represents a volume mount configuration for a project. +type Volume struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Source string `json:"source"` + Target string `json:"target"` + Mode string `json:"mode"` // shared or isolated + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/store/poll_state.go b/internal/store/poll_state.go new file mode 100644 index 0000000..7d23db4 --- /dev/null +++ b/internal/store/poll_state.go @@ -0,0 +1,75 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +// PollState tracks the last polled tag for a stage, enabling the poller to +// detect new tags since the previous poll cycle. +type PollState struct { + StageID string `json:"stage_id"` + LastTag string `json:"last_tag"` + LastPolled string `json:"last_polled"` +} + +// GetPollState returns the poll state for a given stage. +func (s *Store) GetPollState(stageID string) (PollState, error) { + var ps PollState + err := s.db.QueryRow( + `SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`, + stageID, + ).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled) + if errors.Is(err, sql.ErrNoRows) { + return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound) + } + if err != nil { + return PollState{}, fmt.Errorf("query poll state: %w", err) + } + return ps, nil +} + +// UpsertPollState inserts or updates the poll state for a stage. +func (s *Store) UpsertPollState(ps PollState) error { + _, err := s.db.Exec( + `INSERT INTO poll_states (stage_id, last_tag, last_polled) + VALUES (?, ?, ?) + ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`, + ps.StageID, ps.LastTag, ps.LastPolled, + ) + if err != nil { + return fmt.Errorf("upsert poll state: %w", err) + } + return nil +} + +// DeletePollState removes the poll state for a stage. +func (s *Store) DeletePollState(stageID string) error { + _, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID) + if err != nil { + return fmt.Errorf("delete poll state: %w", err) + } + return nil +} + +// GetAllPollStates returns all poll states, ordered by last_polled descending. +func (s *Store) GetAllPollStates() ([]PollState, error) { + rows, err := s.db.Query( + `SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`, + ) + if err != nil { + return nil, fmt.Errorf("query poll states: %w", err) + } + defer rows.Close() + + var states []PollState + for rows.Next() { + var ps PollState + if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil { + return nil, fmt.Errorf("scan poll state: %w", err) + } + states = append(states, ps) + } + return states, rows.Err() +} diff --git a/internal/store/projects.go b/internal/store/projects.go new file mode 100644 index 0000000..3d436f1 --- /dev/null +++ b/internal/store/projects.go @@ -0,0 +1,95 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateProject inserts a new project and returns it. +func (s *Store) CreateProject(p Project) (Project, error) { + p.ID = uuid.New().String() + p.CreatedAt = now() + p.UpdatedAt = p.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.CreatedAt, p.UpdatedAt, + ) + if err != nil { + return Project{}, fmt.Errorf("insert project: %w", err) + } + return p, nil +} + +// GetProjectByID returns a single project by its ID. +func (s *Store) GetProjectByID(id string) (Project, error) { + var p Project + err := s.db.QueryRow( + `SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at + FROM projects WHERE id = ?`, id, + ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) + } + if err != nil { + return Project{}, fmt.Errorf("query project: %w", err) + } + return p, nil +} + +// GetAllProjects returns every project ordered by name. +func (s *Store) GetAllProjects() ([]Project, error) { + rows, err := s.db.Query( + `SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at + FROM projects ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("query projects: %w", err) + } + defer rows.Close() + + var projects []Project + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan project: %w", err) + } + projects = append(projects, p) + } + return projects, rows.Err() +} + +// UpdateProject updates an existing project's mutable fields. +func (s *Store) UpdateProject(p Project) error { + p.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=? + WHERE id=?`, + p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.UpdatedAt, p.ID, + ) + if err != nil { + return fmt.Errorf("update project: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("project %s: %w", p.ID, ErrNotFound) + } + return nil +} + +// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys. +func (s *Store) DeleteProject(id string) error { + result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete project: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("project %s: %w", id, ErrNotFound) + } + return nil +} diff --git a/internal/store/registries.go b/internal/store/registries.go new file mode 100644 index 0000000..08528cc --- /dev/null +++ b/internal/store/registries.go @@ -0,0 +1,111 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateRegistry inserts a new registry. +func (s *Store) CreateRegistry(r Registry) (Registry, error) { + r.ID = uuid.New().String() + r.CreatedAt = now() + r.UpdatedAt = r.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO registries (id, name, url, type, token, owner, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.Name, r.URL, r.Type, r.Token, r.Owner, r.CreatedAt, r.UpdatedAt, + ) + if err != nil { + return Registry{}, fmt.Errorf("insert registry: %w", err) + } + return r, nil +} + +// GetRegistryByID returns a single registry by its ID. +func (s *Store) GetRegistryByID(id string) (Registry, error) { + var r Registry + err := s.db.QueryRow( + `SELECT id, name, url, type, token, owner, created_at, updated_at + FROM registries WHERE id = ?`, id, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Registry{}, fmt.Errorf("registry %s: %w", id, ErrNotFound) + } + if err != nil { + return Registry{}, fmt.Errorf("query registry: %w", err) + } + return r, nil +} + +// GetRegistryByName returns a single registry by its unique name. +func (s *Store) GetRegistryByName(name string) (Registry, error) { + var r Registry + err := s.db.QueryRow( + `SELECT id, name, url, type, token, owner, created_at, updated_at + FROM registries WHERE name = ?`, name, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Registry{}, fmt.Errorf("registry %q: %w", name, ErrNotFound) + } + if err != nil { + return Registry{}, fmt.Errorf("query registry by name: %w", err) + } + return r, nil +} + +// GetAllRegistries returns every registry ordered by name. +func (s *Store) GetAllRegistries() ([]Registry, error) { + rows, err := s.db.Query( + `SELECT id, name, url, type, token, owner, created_at, updated_at + FROM registries ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("query registries: %w", err) + } + defer rows.Close() + + var registries []Registry + for rows.Next() { + var r Registry + if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan registry: %w", err) + } + registries = append(registries, r) + } + return registries, rows.Err() +} + +// UpdateRegistry updates an existing registry's mutable fields. +func (s *Store) UpdateRegistry(r Registry) error { + r.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=? + WHERE id=?`, + r.Name, r.URL, r.Type, r.Token, r.Owner, r.UpdatedAt, r.ID, + ) + if err != nil { + return fmt.Errorf("update registry: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("registry %s: %w", r.ID, ErrNotFound) + } + return nil +} + +// DeleteRegistry removes a registry by ID. +func (s *Store) DeleteRegistry(id string) error { + result, err := s.db.Exec(`DELETE FROM registries WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete registry: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("registry %s: %w", id, ErrNotFound) + } + return nil +} diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..90b5354 --- /dev/null +++ b/internal/store/settings.go @@ -0,0 +1,37 @@ +package store + +import ( + "fmt" +) + +// GetSettings returns the global settings (single-row pattern, always row id=1). +func (s *Store) GetSettings() (Settings, error) { + var st Settings + err := s.db.QueryRow( + `SELECT domain, server_ip, network, subdomain_pattern, notification_url, + npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, updated_at + FROM settings WHERE id = 1`, + ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, + &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.UpdatedAt) + if err != nil { + return Settings{}, fmt.Errorf("query settings: %w", err) + } + return st, nil +} + +// UpdateSettings upserts the global settings row. +func (s *Store) UpdateSettings(st Settings) error { + st.UpdatedAt = now() + _, err := s.db.Exec( + `UPDATE settings SET + domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, + npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, updated_at=? + WHERE id = 1`, + st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, + st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("update settings: %w", err) + } + return nil +} diff --git a/internal/store/stage_env.go b/internal/store/stage_env.go new file mode 100644 index 0000000..cddd707 --- /dev/null +++ b/internal/store/stage_env.go @@ -0,0 +1,112 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateStageEnv inserts a new stage environment variable override. +func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) { + env.ID = uuid.New().String() + env.CreatedAt = now() + env.UpdatedAt = env.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + env.ID, env.StageID, env.Key, env.Value, boolToInt(env.Encrypted), + env.CreatedAt, env.UpdatedAt, + ) + if err != nil { + return StageEnv{}, fmt.Errorf("insert stage env: %w", err) + } + return env, nil +} + +// GetStageEnvByStageID returns all environment variable overrides for a stage. +func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) { + rows, err := s.db.Query( + `SELECT id, stage_id, key, value, encrypted, created_at, updated_at + FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID, + ) + if err != nil { + return nil, fmt.Errorf("query stage env: %w", err) + } + defer rows.Close() + + var envs []StageEnv + for rows.Next() { + env, err := scanStageEnv(rows) + if err != nil { + return nil, err + } + envs = append(envs, env) + } + return envs, rows.Err() +} + +// GetStageEnvByID returns a single stage env override by ID. +func (s *Store) GetStageEnvByID(id string) (StageEnv, error) { + var env StageEnv + var encrypted int + err := s.db.QueryRow( + `SELECT id, stage_id, key, value, encrypted, created_at, updated_at + FROM stage_env WHERE id = ?`, id, + ).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, + &env.CreatedAt, &env.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound) + } + if err != nil { + return StageEnv{}, fmt.Errorf("query stage env: %w", err) + } + env.Encrypted = encrypted != 0 + return env, nil +} + +// UpdateStageEnv updates an existing stage environment variable override. +func (s *Store) UpdateStageEnv(env StageEnv) error { + env.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=? + WHERE id=?`, + env.Key, env.Value, boolToInt(env.Encrypted), env.UpdatedAt, env.ID, + ) + if err != nil { + return fmt.Errorf("update stage env: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound) + } + return nil +} + +// DeleteStageEnv removes a stage env override by ID. +func (s *Store) DeleteStageEnv(id string) error { + result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete stage env: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage env %s: %w", id, ErrNotFound) + } + return nil +} + +// scanStageEnv scans a stage env row from a *sql.Rows cursor. +func scanStageEnv(rows *sql.Rows) (StageEnv, error) { + var env StageEnv + var encrypted int + err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, + &env.CreatedAt, &env.UpdatedAt) + if err != nil { + return StageEnv{}, fmt.Errorf("scan stage env: %w", err) + } + env.Encrypted = encrypted != 0 + return env, nil +} diff --git a/internal/store/stages.go b/internal/store/stages.go new file mode 100644 index 0000000..ada2c69 --- /dev/null +++ b/internal/store/stages.go @@ -0,0 +1,123 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateStage inserts a new stage for a project. +func (s *Store) CreateStage(st Stage) (Stage, error) { + st.ID = uuid.New().String() + st.CreatedAt = now() + st.UpdatedAt = st.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + st.ID, st.ProjectID, st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances, + boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt, + ) + if err != nil { + return Stage{}, fmt.Errorf("insert stage: %w", err) + } + return st, nil +} + +// GetStagesByProjectID returns all stages for a given project. +func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) { + rows, err := s.db.Query( + `SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at + FROM stages WHERE project_id = ? ORDER BY name`, projectID, + ) + if err != nil { + return nil, fmt.Errorf("query stages: %w", err) + } + defer rows.Close() + + var stages []Stage + for rows.Next() { + st, err := scanStage(rows) + if err != nil { + return nil, err + } + stages = append(stages, st) + } + return stages, rows.Err() +} + +// GetStageByID returns a single stage by its ID. +func (s *Store) GetStageByID(id string) (Stage, error) { + var st Stage + var autoDeploy, confirm int + err := s.db.QueryRow( + `SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at + FROM stages WHERE id = ?`, id, + ).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, + &confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound) + } + if err != nil { + return Stage{}, fmt.Errorf("query stage: %w", err) + } + st.AutoDeploy = autoDeploy != 0 + st.Confirm = confirm != 0 + return st, nil +} + +// UpdateStage updates an existing stage's mutable fields. +func (s *Store) UpdateStage(st Stage) error { + st.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=? + WHERE id=?`, + st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances, + boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID, + ) + if err != nil { + return fmt.Errorf("update stage: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound) + } + return nil +} + +// DeleteStage removes a stage by ID. Cascading deletes handle child instances. +func (s *Store) DeleteStage(id string) error { + result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete stage: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage %s: %w", id, ErrNotFound) + } + return nil +} + +// boolToInt converts a bool to an integer for SQLite storage. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// scanStage scans a stage row from a *sql.Rows cursor. +func scanStage(rows *sql.Rows) (Stage, error) { + var st Stage + var autoDeploy, confirm int + err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, + &confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt) + if err != nil { + return Stage{}, fmt.Errorf("scan stage: %w", err) + } + st.AutoDeploy = autoDeploy != 0 + st.Confirm = confirm != 0 + return st, nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..8c5ca3a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,230 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// ErrNotFound is returned when a requested entity does not exist. +var ErrNotFound = errors.New("not found") + +// Store wraps the SQLite database connection and provides access to all query methods. +type Store struct { + db *sql.DB +} + +// New opens a SQLite database at the given path and runs auto-migration. +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode and foreign keys for better concurrency and referential integrity. + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("exec pragma %q: %w", p, err) + } + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return s, nil +} + +// Close closes the underlying database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +// DB returns the underlying *sql.DB for advanced operations like transactions. +func (s *Store) DB() *sql.DB { + return s.db +} + +// migrate creates all tables if they do not already exist, then runs +// incremental migrations for schema changes added after initial release. +func (s *Store) migrate() error { + if _, err := s.db.Exec(schema); err != nil { + return err + } + return s.runMigrations() +} + +// runMigrations applies additive schema changes that cannot be expressed +// with CREATE TABLE IF NOT EXISTS. +func (s *Store) runMigrations() error { + migrations := []string{ + // Add owner column to registries (2026-03-28). + `ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, + // Add base_volume_path to settings (2026-03-28). + `ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`, + } + + for _, m := range migrations { + // Ignore errors from already-applied migrations (duplicate column). + _, _ = s.db.Exec(m) + } + return nil +} + +const schema = ` +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + registry TEXT NOT NULL DEFAULT '', + image TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 0, + healthcheck TEXT NOT NULL DEFAULT '', + env TEXT NOT NULL DEFAULT '{}', + volumes TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS stages ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + tag_pattern TEXT NOT NULL DEFAULT '*', + auto_deploy INTEGER NOT NULL DEFAULT 0, + max_instances INTEGER NOT NULL DEFAULT 1, + confirm INTEGER NOT NULL DEFAULT 0, + promote_from TEXT NOT NULL DEFAULT '', + subdomain TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, name) +); + +CREATE TABLE IF NOT EXISTS registries ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'generic', + token TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + domain TEXT NOT NULL DEFAULT '', + server_ip TEXT NOT NULL DEFAULT '', + network TEXT NOT NULL DEFAULT '', + subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}', + notification_url TEXT NOT NULL DEFAULT '', + npm_url TEXT NOT NULL DEFAULT '', + npm_email TEXT NOT NULL DEFAULT '', + npm_password TEXT NOT NULL DEFAULT '', + webhook_secret TEXT NOT NULL DEFAULT '', + polling_interval TEXT NOT NULL DEFAULT '5m', + base_volume_path TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS instances ( + id TEXT PRIMARY KEY, + stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + container_id TEXT NOT NULL DEFAULT '', + image_tag TEXT NOT NULL, + subdomain TEXT NOT NULL DEFAULT '', + npm_proxy_id INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'stopped', + port INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS deploys ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, + instance_id TEXT NOT NULL DEFAULT '', + image_tag TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS deploy_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE, + message TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS poll_states ( + stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE, + last_tag TEXT NOT NULL DEFAULT '', + last_polled TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS auth_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + auth_mode TEXT NOT NULL DEFAULT 'local', + oidc_client_id TEXT NOT NULL DEFAULT '', + oidc_client_secret TEXT NOT NULL DEFAULT '', + oidc_issuer_url TEXT NOT NULL DEFAULT '', + oidc_redirect_url TEXT NOT NULL DEFAULT '' +); + +-- Seed the settings row if it does not exist. +INSERT OR IGNORE INTO settings (id) VALUES (1); + +-- Seed the auth_settings row if it does not exist. +INSERT OR IGNORE INTO auth_settings (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS stage_env ( + id TEXT PRIMARY KEY, + stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + encrypted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(stage_id, key) +); + +CREATE TABLE IF NOT EXISTS volumes ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + source TEXT NOT NULL, + target TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'shared', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +` + +// now returns the current time formatted for SQLite storage. +func now() string { + return time.Now().UTC().Format("2006-01-02 15:04:05") +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..266211b --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,183 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// User represents an authenticated user stored in the database. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Email string `json:"email"` + Role string `json:"role"` // admin, viewer + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// AuthSettings holds the authentication configuration (single-row pattern). +type AuthSettings struct { + AuthMode string `json:"auth_mode"` // local, oidc + OIDCClientID string `json:"oidc_client_id"` + OIDCClientSecret string `json:"-"` + OIDCIssuerURL string `json:"oidc_issuer_url"` + OIDCRedirectURL string `json:"oidc_redirect_url"` +} + +// CreateUser inserts a new user record. +func (s *Store) CreateUser(u User) (User, error) { + u.ID = uuid.New().String() + u.CreatedAt = now() + u.UpdatedAt = u.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO users (id, username, password_hash, email, role, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.PasswordHash, u.Email, u.Role, u.CreatedAt, u.UpdatedAt, + ) + if err != nil { + return User{}, fmt.Errorf("insert user: %w", err) + } + return u, nil +} + +// GetUserByID returns a single user by its ID. +func (s *Store) GetUserByID(id string) (User, error) { + var u User + err := s.db.QueryRow( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users WHERE id = ?`, id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, fmt.Errorf("user %s: %w", id, ErrNotFound) + } + if err != nil { + return User{}, fmt.Errorf("query user: %w", err) + } + return u, nil +} + +// GetUserByUsername returns a single user by username. +func (s *Store) GetUserByUsername(username string) (User, error) { + var u User + err := s.db.QueryRow( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users WHERE username = ?`, username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, fmt.Errorf("user %q: %w", username, ErrNotFound) + } + if err != nil { + return User{}, fmt.Errorf("query user by username: %w", err) + } + return u, nil +} + +// GetAllUsers returns every user ordered by username. +func (s *Store) GetAllUsers() ([]User, error) { + rows, err := s.db.Query( + `SELECT id, username, password_hash, email, role, created_at, updated_at + FROM users ORDER BY username`, + ) + if err != nil { + return nil, fmt.Errorf("query users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// UpdateUser updates a user's mutable fields (username, email, role). +func (s *Store) UpdateUser(u User) error { + u.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE users SET username=?, email=?, role=?, updated_at=? WHERE id=?`, + u.Username, u.Email, u.Role, u.UpdatedAt, u.ID, + ) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", u.ID, ErrNotFound) + } + return nil +} + +// UpdateUserPassword updates a user's password hash. +func (s *Store) UpdateUserPassword(id string, passwordHash string) error { + ts := now() + result, err := s.db.Exec( + `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, + passwordHash, ts, id, + ) + if err != nil { + return fmt.Errorf("update user password: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteUser removes a user by ID. +func (s *Store) DeleteUser(id string) error { + result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("user %s: %w", id, ErrNotFound) + } + return nil +} + +// UserCount returns the total number of users. +func (s *Store) UserCount() (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return count, nil +} + +// GetAuthSettings returns the auth settings (single-row pattern, always row id=1). +func (s *Store) GetAuthSettings() (AuthSettings, error) { + var as AuthSettings + err := s.db.QueryRow( + `SELECT auth_mode, oidc_client_id, oidc_client_secret, oidc_issuer_url, oidc_redirect_url + FROM auth_settings WHERE id = 1`, + ).Scan(&as.AuthMode, &as.OIDCClientID, &as.OIDCClientSecret, &as.OIDCIssuerURL, &as.OIDCRedirectURL) + if err != nil { + return AuthSettings{}, fmt.Errorf("query auth settings: %w", err) + } + return as, nil +} + +// UpdateAuthSettings updates the auth settings row. +func (s *Store) UpdateAuthSettings(as AuthSettings) error { + _, err := s.db.Exec( + `UPDATE auth_settings SET auth_mode=?, oidc_client_id=?, oidc_client_secret=?, oidc_issuer_url=?, oidc_redirect_url=? + WHERE id = 1`, + as.AuthMode, as.OIDCClientID, as.OIDCClientSecret, as.OIDCIssuerURL, as.OIDCRedirectURL, + ) + if err != nil { + return fmt.Errorf("update auth settings: %w", err) + } + return nil +} diff --git a/internal/store/volumes.go b/internal/store/volumes.go new file mode 100644 index 0000000..3ab1acd --- /dev/null +++ b/internal/store/volumes.go @@ -0,0 +1,112 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateVolume inserts a new volume configuration for a project. +func (s *Store) CreateVolume(vol Volume) (Volume, error) { + vol.ID = uuid.New().String() + vol.CreatedAt = now() + vol.UpdatedAt = vol.CreatedAt + + if vol.Mode == "" { + vol.Mode = "shared" + } + + _, err := s.db.Exec( + `INSERT INTO volumes (id, project_id, source, target, mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode, + vol.CreatedAt, vol.UpdatedAt, + ) + if err != nil { + return Volume{}, fmt.Errorf("insert volume: %w", err) + } + return vol, nil +} + +// GetVolumesByProjectID returns all volume configurations for a project. +func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { + rows, err := s.db.Query( + `SELECT id, project_id, source, target, mode, created_at, updated_at + FROM volumes WHERE project_id = ? ORDER BY target`, projectID, + ) + if err != nil { + return nil, fmt.Errorf("query volumes: %w", err) + } + defer rows.Close() + + var vols []Volume + for rows.Next() { + vol, err := scanVolume(rows) + if err != nil { + return nil, err + } + vols = append(vols, vol) + } + return vols, rows.Err() +} + +// GetVolumeByID returns a single volume by its ID. +func (s *Store) GetVolumeByID(id string) (Volume, error) { + var vol Volume + err := s.db.QueryRow( + `SELECT id, project_id, source, target, mode, created_at, updated_at + FROM volumes WHERE id = ?`, id, + ).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, + &vol.CreatedAt, &vol.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound) + } + if err != nil { + return Volume{}, fmt.Errorf("query volume: %w", err) + } + return vol, nil +} + +// UpdateVolume updates an existing volume configuration. +func (s *Store) UpdateVolume(vol Volume) error { + vol.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE volumes SET source=?, target=?, mode=?, updated_at=? + WHERE id=?`, + vol.Source, vol.Target, vol.Mode, vol.UpdatedAt, vol.ID, + ) + if err != nil { + return fmt.Errorf("update volume: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound) + } + return nil +} + +// DeleteVolume removes a volume configuration by ID. +func (s *Store) DeleteVolume(id string) error { + result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete volume: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("volume %s: %w", id, ErrNotFound) + } + return nil +} + +// scanVolume scans a volume row from a *sql.Rows cursor. +func scanVolume(rows *sql.Rows) (Volume, error) { + var vol Volume + err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, + &vol.CreatedAt, &vol.UpdatedAt) + if err != nil { + return Volume{}, fmt.Errorf("scan volume: %w", err) + } + return vol, nil +} diff --git a/internal/webhook/autocreate.go b/internal/webhook/autocreate.go new file mode 100644 index 0000000..6cbaff5 --- /dev/null +++ b/internal/webhook/autocreate.go @@ -0,0 +1,111 @@ +package webhook + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + + "github.com/alexei/docker-watcher/internal/store" +) + +// AutoCreateProject creates a new project and a default "dev" stage from an +// unknown image. It inspects the Docker image to extract defaults (EXPOSE port, +// healthcheck, labels). +// +// The auto-created project uses: +// - Name: derived from image name (e.g. "web-app-launcher") +// - Image: full owner/name path +// - Port: first EXPOSE port from the image, or 0 if none +// - Healthcheck: from image HEALTHCHECK instruction, if present +// - A single "dev" stage with auto_deploy=true and tag_pattern="*" +func AutoCreateProject( + ctx context.Context, + st *store.Store, + inspector ImageInspector, + parsed ParsedImage, +) (store.Project, store.Stage, error) { + // Build the full image ref for inspection (registry/owner/name:tag). + imageRef := buildImageRef(parsed) + + var port int + var healthcheck string + + // Attempt to inspect the image for metadata. If inspection fails (image + // not pulled locally), proceed with zero defaults. + if inspector != nil { + info, err := inspector.InspectImage(ctx, imageRef) + if err != nil { + log.Printf("[webhook] image inspection failed for %s (using defaults): %v", imageRef, err) + } else { + port = extractPort(info.ExposedPorts) + healthcheck = info.Healthcheck + } + } + + project, err := st.CreateProject(store.Project{ + Name: parsed.Name, + Registry: parsed.Registry, + Image: parsed.FullName(), + Port: port, + Healthcheck: healthcheck, + Env: "{}", + Volumes: "{}", + }) + if err != nil { + return store.Project{}, store.Stage{}, fmt.Errorf("create project: %w", err) + } + + stage, err := st.CreateStage(store.Stage{ + ProjectID: project.ID, + Name: "dev", + TagPattern: "*", + AutoDeploy: true, + MaxInstances: 1, + }) + if err != nil { + return store.Project{}, store.Stage{}, fmt.Errorf("create default stage: %w", err) + } + + return project, stage, nil +} + +// buildImageRef reconstructs a pullable image reference from parsed components. +func buildImageRef(parsed ParsedImage) string { + var parts []string + if parsed.Registry != "" { + parts = append(parts, parsed.Registry) + } + if parsed.Owner != "" { + parts = append(parts, parsed.Owner) + } + parts = append(parts, parsed.Name) + + ref := strings.Join(parts, "/") + if parsed.Tag != "" { + ref += ":" + parsed.Tag + } + return ref +} + +// extractPort parses the first exposed port from Docker EXPOSE entries. +// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found. +func extractPort(exposedPorts []string) int { + if len(exposedPorts) == 0 { + return 0 + } + + // Take the first port entry. + raw := exposedPorts[0] + // Strip protocol suffix (e.g. "/tcp", "/udp"). + if idx := strings.Index(raw, "/"); idx != -1 { + raw = raw[:idx] + } + + port, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return port +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go new file mode 100644 index 0000000..9c5628e --- /dev/null +++ b/internal/webhook/handler.go @@ -0,0 +1,256 @@ +package webhook + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/store" +) + +// DeployTriggerer is called when a webhook determines a deploy should happen. +// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles. +type DeployTriggerer interface { + TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error +} + +// ImageInspector abstracts Docker image inspection for testability. +type ImageInspector interface { + InspectImage(ctx context.Context, imageRef string) (docker.ImageInfo, error) +} + +// Payload is the expected JSON body for a webhook request. +type Payload struct { + // Image is the full image reference including tag, e.g. + // "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123". + Image string `json:"image"` +} + +// ParsedImage holds the components extracted from a full image reference string. +type ParsedImage struct { + // Registry is the hostname, e.g. "git.dolgolyov-family.by". + Registry string + // Owner is the namespace/org, e.g. "alexei". + Owner string + // Name is the repository name, e.g. "web-app-launcher". + Name string + // Tag is the image tag, e.g. "dev-abc123". Empty string means "latest". + Tag string +} + +// FullName returns "owner/name" (the image path without registry and tag). +func (p ParsedImage) FullName() string { + if p.Owner != "" { + return p.Owner + "/" + p.Name + } + return p.Name +} + +// ParseImageRef splits a full image reference into its components. +// Accepted formats: +// +// registry.example.com/owner/name:tag +// registry.example.com/owner/name +// owner/name:tag +// name:tag +func ParseImageRef(ref string) (ParsedImage, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return ParsedImage{}, fmt.Errorf("empty image reference") + } + + var parsed ParsedImage + + // Split off tag. + if idx := strings.LastIndex(ref, ":"); idx != -1 { + // Make sure the colon is not inside the registry host (e.g. "localhost:5000/img"). + afterColon := ref[idx+1:] + if !strings.Contains(afterColon, "/") { + parsed.Tag = afterColon + ref = ref[:idx] + } + } + + parts := strings.Split(ref, "/") + switch len(parts) { + case 1: + // "name" + parsed.Name = parts[0] + case 2: + // "owner/name" + parsed.Owner = parts[0] + parsed.Name = parts[1] + default: + // "registry/owner/name" or "registry/owner/sub/name" — first segment is registry. + parsed.Registry = parts[0] + parsed.Owner = strings.Join(parts[1:len(parts)-1], "/") + parsed.Name = parts[len(parts)-1] + } + + if parsed.Name == "" { + return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref) + } + + return parsed, nil +} + +// Handler is the HTTP handler for webhook requests. +type Handler struct { + store *store.Store + deployer DeployTriggerer + inspector ImageInspector +} + +// NewHandler creates a new webhook Handler. +func NewHandler(st *store.Store, deployer DeployTriggerer, inspector ImageInspector) *Handler { + return &Handler{ + store: st, + deployer: deployer, + inspector: inspector, + } +} + +// Route returns a chi router with the webhook endpoint mounted. +func (h *Handler) Route() chi.Router { + r := chi.NewRouter() + r.Post("/{secret}", h.handleWebhook) + return r +} + +// handleWebhook processes an incoming webhook request. +// URL format: POST /api/webhook/{secret-uuid} +// Returns 404 for invalid secrets (no information leak). +func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + secret := chi.URLParam(r, "secret") + if secret == "" { + http.NotFound(w, r) + return + } + + // Validate the webhook secret against stored settings. + settings, err := h.store.GetSettings() + if err != nil { + log.Printf("[webhook] failed to read settings: %v", err) + http.NotFound(w, r) + return + } + + if settings.WebhookSecret == "" || subtle.ConstantTimeCompare([]byte(settings.WebhookSecret), []byte(secret)) != 1 { + http.NotFound(w, r) + return + } + + // Parse the request body. + var payload Payload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, `{"error":"invalid JSON payload"}`, http.StatusBadRequest) + return + } + + if payload.Image == "" { + http.Error(w, `{"error":"missing image field"}`, http.StatusBadRequest) + return + } + + parsed, err := ParseImageRef(payload.Image) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Default tag to "latest" if omitted. + if parsed.Tag == "" { + parsed.Tag = "latest" + } + + log.Printf("[webhook] received push for image %s:%s", parsed.FullName(), parsed.Tag) + + // Look up a matching project by image name. + project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed) + if err != nil { + log.Printf("[webhook] lookup error: %v", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + if !found { + // Unknown project — auto-create with defaults from image inspection. + log.Printf("[webhook] unknown image %s, auto-creating project", parsed.FullName()) + project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed) + if err != nil { + log.Printf("[webhook] auto-create failed: %v", err) + http.Error(w, `{"error":"failed to auto-create project"}`, http.StatusInternalServerError) + return + } + log.Printf("[webhook] auto-created project %s (%s) with stage %s", project.Name, project.ID, stage.Name) + } + + // Only deploy if auto_deploy is enabled for the matched stage. + if !stage.AutoDeploy { + log.Printf("[webhook] auto_deploy disabled for project %s stage %s, skipping deploy", project.Name, stage.Name) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": false, "project": project.Name, "stage": stage.Name}) + return + } + + if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil { + log.Printf("[webhook] deploy trigger failed: %v", err) + http.Error(w, `{"error":"deploy trigger failed"}`, http.StatusInternalServerError) + return + } + + log.Printf("[webhook] triggered deploy for project %s stage %s tag %s", project.Name, stage.Name, parsed.Tag) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag}) +} + +// EnsureWebhookSecret checks whether a webhook secret exists in settings. +// If not, it generates a new UUID and stores it. Returns the current secret. +func EnsureWebhookSecret(st *store.Store) (string, error) { + settings, err := st.GetSettings() + if err != nil { + return "", fmt.Errorf("get settings: %w", err) + } + + if settings.WebhookSecret != "" { + return settings.WebhookSecret, nil + } + + settings.WebhookSecret = uuid.New().String() + if err := st.UpdateSettings(settings); err != nil { + return "", fmt.Errorf("store webhook secret: %w", err) + } + + log.Printf("[webhook] generated new webhook secret") + return settings.WebhookSecret, nil +} + +// RegenerateWebhookSecret generates a new webhook secret UUID, replacing and +// invalidating the old one. Returns the new secret. +func RegenerateWebhookSecret(st *store.Store) (string, error) { + settings, err := st.GetSettings() + if err != nil { + return "", fmt.Errorf("get settings: %w", err) + } + + settings.WebhookSecret = uuid.New().String() + if err := st.UpdateSettings(settings); err != nil { + return "", fmt.Errorf("store webhook secret: %w", err) + } + + log.Printf("[webhook] regenerated webhook secret") + return settings.WebhookSecret, nil +} diff --git a/internal/webhook/matcher.go b/internal/webhook/matcher.go new file mode 100644 index 0000000..f6225e3 --- /dev/null +++ b/internal/webhook/matcher.go @@ -0,0 +1,90 @@ +package webhook + +import ( + "context" + "fmt" + "path" + + "github.com/alexei/docker-watcher/internal/store" +) + +// FindProjectAndStage searches for a project whose image matches the parsed +// image reference, then finds the stage whose tag pattern matches the incoming +// tag. Returns (project, stage, found, error). +// +// Matching logic: +// 1. Iterate all projects. +// 2. Compare the project's Image field against the parsed image's FullName(). +// 3. For the matched project, iterate its stages and find one whose TagPattern +// matches the incoming tag using path.Match (glob semantics). +// 4. If multiple stages match, the first match wins (stages are ordered by name). +func FindProjectAndStage(ctx context.Context, st *store.Store, parsed ParsedImage) (store.Project, store.Stage, bool, error) { + projects, err := st.GetAllProjects() + if err != nil { + return store.Project{}, store.Stage{}, false, fmt.Errorf("get projects: %w", err) + } + + imageName := parsed.FullName() + + for _, project := range projects { + if !imageMatches(project.Image, imageName) { + continue + } + + stage, found, err := matchStage(st, project.ID, parsed.Tag) + if err != nil { + return store.Project{}, store.Stage{}, false, fmt.Errorf("match stage for project %s: %w", project.Name, err) + } + if found { + return project, stage, true, nil + } + + // Project matches but no stage pattern matches this tag. + // Return project with empty stage — caller can decide what to do. + // For now, we treat it as "not found" so auto-create doesn't fire + // for known projects with no matching stage. + return store.Project{}, store.Stage{}, false, nil + } + + return store.Project{}, store.Stage{}, false, nil +} + +// imageMatches checks if a project's stored image name matches the parsed +// image name. The comparison is case-sensitive and supports the project image +// being stored as either "owner/name" or just "name". +func imageMatches(projectImage, incomingImage string) bool { + if projectImage == incomingImage { + return true + } + // Also match if the incoming image has an owner prefix but the project + // only stores the bare name (or vice versa). This handles registries + // that include or omit the owner segment. + return false +} + +// matchStage finds the first stage of a project whose tag pattern matches the +// given tag. Uses path.Match for glob-style matching (same as the registry poller). +func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) { + stages, err := st.GetStagesByProjectID(projectID) + if err != nil { + return store.Stage{}, false, fmt.Errorf("get stages: %w", err) + } + + for _, stage := range stages { + pattern := stage.TagPattern + if pattern == "" { + pattern = "*" + } + + matched, err := path.Match(pattern, tag) + if err != nil { + // Invalid pattern — skip this stage. + continue + } + if matched { + return stage, true, nil + } + } + + return store.Stage{}, false, nil +} diff --git a/plans/docker-watcher-core/CONTEXT.md b/plans/docker-watcher-core/CONTEXT.md new file mode 100644 index 0000000..581c2fc --- /dev/null +++ b/plans/docker-watcher-core/CONTEXT.md @@ -0,0 +1,53 @@ +# Feature Context: Docker Watcher Core + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Orchestrator +- **Strategy:** Big Bang (with per-phase code quality reviews) +- **Build (Go):** `go build ./cmd/server/` +- **Test (Go):** `go test ./...` +- **Lint (Go):** `golangci-lint run` +- **Build (Frontend):** `cd web && npm run build` +- **Test (Frontend):** `cd web && npm test` +- **Dev server:** `go run ./cmd/server/` + +## Current State +Greenfield project. Only PLAN.md exists with the architecture document. + +## Temporary Workarounds +None yet. + +## Cross-Phase Dependencies +- Phase 2 depends on Phase 1 (store CRUD for seed import) +- Phases 3 and 4 are independent of each other (can run in parallel) +- Phase 5 depends on Phase 1 (store for poll state) +- Phase 6 depends on Phase 3 (Docker inspect for auto-creation) and Phase 1 (store) +- Phase 7 depends on Phases 3, 4, 5 (Docker, NPM, registry clients) +- Phase 8 depends on Phases 1-7 (wires everything to HTTP) +- Phases 9 and 10 are independent of each other (can run in parallel) +- Phase 11 depends on Phases 8, 9, 10 (embeds frontend, SSE wires to API) +- Phase 12 depends on all prior phases + +## Deferred Work +None yet. + +## Failed Approaches +None yet. + +## Review Findings Log +None yet. + +## Phase Execution Log +| Phase | Agent Used | Test Writer | Parallel | Notes | +|-------|-----------|-------------|----------|-------| +| — | — | — | — | No phases executed yet | + +## Environment & Runtime Notes +- Platform: Windows 10 (development), Linux (deployment target) +- Docker socket: `/var/run/docker.sock` (Linux) — development may need Docker Desktop +- Go version: TBD (will be determined in Phase 1) + +## Implementation Notes +- Big Bang strategy: intermediate phases skip build/tests, code quality reviews after every phase +- Final phase (12) is the only phase where build + full test suite must pass +- Phases 3+4 and 9+10 identified for parallel execution diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md new file mode 100644 index 0000000..4dcbd72 --- /dev/null +++ b/plans/docker-watcher-core/PLAN.md @@ -0,0 +1,108 @@ +# Feature: Docker Watcher Core + +**Branch:** `feature/docker-watcher-core` +**Base branch:** `main` +**Created:** 2026-03-27 +**Status:** 🟡 In Progress +**Strategy:** Big Bang (with per-phase code quality reviews) +**Mode:** Automated +**Execution:** Orchestrator + +## Summary + +A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project. + +## Build & Test Commands + +- **Build (Go):** `go build ./cmd/server/` +- **Test (Go):** `go test ./...` +- **Lint (Go):** `golangci-lint run` +- **Build (Frontend):** `cd web && npm run build` +- **Test (Frontend):** `cd web && npm test` +- **Dev server:** `go run ./cmd/server/` + +## Phases + +- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md) +- [x] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md) +- [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md) +- [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md) +- [x] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md) +- [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md) +- [x] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md) +- [x] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md) +- [x] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md) +- [x] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) +- [x] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) +- [x] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md) +- [x] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) +- [x] Phase 14: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) + +### Parallel Execution Notes + +- Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel +- Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +| ----- | ------ | ------ | ------ | ----- | --------- | +| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 7: Deployer & Health | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 8: API Layer | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | +| Phase 9: Dashboard | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 10: Settings & Deploy | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 11: Embed & SSE | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 12: Hardening | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 13: Volumes & Env | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ | +| Phase 14: UI Polish | frontend | ✅ Complete | ⬜ Pending | ✅ Required (Final) | ⬜ | + +## Amendment Log + +### Amendment 1 — 2026-03-27 + +**Type:** Added phase +**What changed:** Added Phase 13: Frontend Polish & Modern UI after Phase 12 +**Why:** User wants modern look & feel with SVG icons and polished frontend +**Impact on existing phases:** None — Phase 13 runs after all functionality is complete. Build/tests now required on Phase 13 (final) instead of Phase 12. + +### Amendment 2 — 2026-03-27 + +**Type:** Modified phase +**What changed:** Added Task 13 (EN/RU localization) to Phase 13: Frontend Polish & Modern UI +**Why:** User wants bilingual support (English and Russian) in the dashboard +**Impact on existing phases:** None — contained within Phase 13 + +### Amendment 3 — 2026-03-27 + +**Type:** Added phase +**What changed:** Added Phase 14: Volumes & Environment — per-project env vars with per-stage overrides, volume mounts with shared/isolated modes, encryption for sensitive values, UI editor +**Why:** Missing from feature planner phases but present in root PLAN.md Phase 4 +**Impact on existing phases:** Phase 14 becomes the final phase (build/tests required). Phase 13 (UI Polish) remains but no longer the final phase for build enforcement. + +### Amendment 4 — 2026-03-27 + +**Type:** Modified phase +**What changed:** Updated Phase 12 (Hardening) auth tasks to support two modes: Local auth (username/password in SQLite with bcrypt) and OAuth2/OIDC (Authentik or any OIDC provider with configurable discovery URL). Added auth settings UI, user management, OIDC callback flow. +**Why:** Root PLAN.md was updated to require OAuth2/OIDC support alongside local auth +**Impact on existing phases:** Phase 12 task count increased from 10 to 12. Added new files for auth module and login page. + +### Amendment 5 — 2026-03-27 + +**Type:** Reordered phases +**What changed:** Swapped Phase 13 (UI Polish) and Phase 14 (Volumes & Env). Volumes & Env is now Phase 13, UI Polish is now Phase 14 (final). +**Why:** Volumes & Env adds new UI pages that need the polish pass. UI Polish must run last to cover all pages including auth (Phase 12) and volume/env editors (Phase 13). +**Impact on existing phases:** Execution order changed. UI Polish (now Phase 14) remains the final phase with build/test enforcement. + +## Final Review + +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Security review +- [ ] Merged to `main` diff --git a/plans/docker-watcher-core/phase-1-scaffold-store.md b/plans/docker-watcher-core/phase-1-scaffold-store.md new file mode 100644 index 0000000..5ea7005 --- /dev/null +++ b/plans/docker-watcher-core/phase-1-scaffold-store.md @@ -0,0 +1,95 @@ +# Phase 1: Project Scaffold & SQLite Store + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Initialize the Go project, establish the directory structure, and implement the SQLite store with schema, migrations, and CRUD operations for all entities. + +## Tasks + +- [x] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md +- [x] Task 2: Add core dependencies to go.mod (sqlite, chi, yaml, uuid, cron) +- [x] Task 3: Define SQLite schema — tables for projects, stages, registries, settings, instances, deploys, deploy_logs +- [x] Task 4: Implement store initialization with auto-migration (create tables if not exist) +- [x] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete) +- [x] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete) +- [x] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete) +- [x] Task 8: Implement settings Get/Update (single-row config pattern) +- [x] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus) +- [x] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append +- [x] Task 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close) + +## Files to Modify/Create +- `go.mod` — module definition and dependencies +- `go.sum` — dependency checksums +- `cmd/server/main.go` — entry point +- `internal/store/store.go` — DB connection, schema, migrations +- `internal/store/projects.go` — project queries +- `internal/store/stages.go` — stage queries +- `internal/store/registries.go` — registry queries +- `internal/store/settings.go` — settings queries +- `internal/store/instances.go` — instance queries +- `internal/store/deploys.go` — deploy history queries + +## Acceptance Criteria +- `go mod tidy` succeeds +- All store CRUD functions are implemented with proper error handling +- Schema covers all entities from the architecture plan +- Entry point compiles (may not fully run until later phases wire everything) + +## Notes +- Use `modernc.org/sqlite` for CGo-free SQLite +- Use `go-chi/chi/v5` for routing (will be wired in Phase 8) +- Settings table uses a single-row pattern (one row, upsert on update) +- Instance status should be an enum-like string: "running", "stopped", "failed", "removing" +- Deploy status: "pending", "pulling", "starting", "configuring_proxy", "health_checking", "success", "failed", "rolled_back" + +## Review Checklist +- [ ] All tasks completed +- [ ] Code follows Go conventions (gofmt, proper error returns) +- [ ] No unintended side effects +- [ ] Schema is normalized and covers all planned entities +- [ ] CRUD functions handle not-found cases properly + +## Handoff to Next Phase + +### What was built + +- Go module initialized at `github.com/alexei/docker-watcher` with all core dependencies +- Full directory structure created: `cmd/server/`, `internal/store/`, plus empty dirs for config, docker, npm, registry, deployer, health, notify, webhook, api, crypto +- SQLite store with 7 tables: projects, stages, registries, settings, instances, deploys, deploy_logs +- Auto-migration runs on store initialization (CREATE TABLE IF NOT EXISTS) +- WAL mode, foreign keys, and busy timeout pragmas enabled +- Settings table uses single-row pattern with `INSERT OR IGNORE` seed +- Models extracted to `internal/store/models.go` for clean separation + +### Key files + +- `go.mod` — module definition with modernc.org/sqlite, chi, yaml, uuid, cron +- `cmd/server/main.go` — entry point that creates data dir, opens store, defers close +- `internal/store/store.go` — DB connection, pragmas, schema DDL, migration +- `internal/store/models.go` — all entity structs (Project, Stage, Registry, Settings, Instance, Deploy, DeployLog) +- `internal/store/projects.go` — full CRUD +- `internal/store/stages.go` — full CRUD with bool-to-int conversion for SQLite +- `internal/store/registries.go` — full CRUD +- `internal/store/settings.go` — Get/Update (single-row upsert) +- `internal/store/instances.go` — full CRUD + UpdateStatus +- `internal/store/deploys.go` — Create, GetByID, GetByProjectID, GetRecent, UpdateDeployStatus, SetDeployInstanceID, AppendDeployLog, GetDeployLogs + +### Conventions established + +- UUIDs generated via `github.com/google/uuid` on Create operations +- Timestamps stored as `datetime('now')` defaults in schema, `time.Now().UTC().Format("2006-01-02 15:04:05")` in Go code +- All query errors wrapped with `fmt.Errorf` and `%w` for unwrapping +- Not-found cases return descriptive error strings (not sentinel errors yet — can be refined) +- Boolean fields stored as INTEGER (0/1) in SQLite, converted via `boolToInt` helper +- JSON-encoded maps stored as TEXT for env and volumes fields + +### What Phase 2 needs to know + +- `store.New(dbPath)` returns a `*Store` that is ready to use — no additional init needed +- The `settings` table is pre-seeded with a row (id=1) so `GetSettings` always works +- Registry `token` and settings `npm_password` are stored as plain text — Phase 2 (Crypto) should add encryption/decryption around these fields +- `go.sum` does not exist yet — run `go mod tidy` after Go is available to generate it diff --git a/plans/docker-watcher-core/phase-10-settings-deploy.md b/plans/docker-watcher-core/phase-10-settings-deploy.md new file mode 100644 index 0000000..26a7e82 --- /dev/null +++ b/plans/docker-watcher-core/phase-10-settings-deploy.md @@ -0,0 +1,56 @@ +# Phase 10: Quick Deploy & Settings Pages + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build the Quick Deploy page (paste image, auto-inspect, one-click deploy) and all Settings pages (registries, credentials, global settings, webhook URL). + +## Tasks + +- [ ] Task 1: Quick Deploy page (`routes/deploy/+page.svelte`) — image URL input, inspect button +- [ ] Task 2: Quick Deploy inspect flow — call /api/deploy/inspect, display auto-filled form (project name, port, stage, subdomain) +- [ ] Task 3: Quick Deploy submit — user reviews defaults, clicks Deploy, calls /api/deploy/quick +- [ ] Task 4: Settings layout (`routes/settings/+layout.svelte`) — sub-navigation for settings sections +- [ ] Task 5: Global settings page (`routes/settings/+page.svelte`) — domain, server IP, network, subdomain pattern, polling interval +- [ ] Task 6: Registries page (`routes/settings/registries/+page.svelte`) — list, add, edit, delete, test connection +- [ ] Task 7: Credentials page (`routes/settings/credentials/+page.svelte`) — NPM credentials, registry tokens (masked display) +- [ ] Task 8: Webhook URL display and regenerate button in settings +- [ ] Task 9: Projects config page (`routes/projects/config/+page.svelte` or integrated into project detail) — add/edit/delete projects, configure stages +- [ ] Task 10: Stage configuration form — tag patterns, auto_deploy toggle, max_instances, subdomain override +- [ ] Task 11: Form validation on all input pages — required fields, URL format, port range +- [ ] Task 12: Success/error toast notifications for all form submissions + +## Files to Modify/Create +- `web/src/routes/deploy/+page.svelte` — quick deploy +- `web/src/routes/settings/+layout.svelte` — settings layout +- `web/src/routes/settings/+page.svelte` — global settings +- `web/src/routes/settings/registries/+page.svelte` — registry management +- `web/src/routes/settings/credentials/+page.svelte` — credential management +- `web/src/lib/components/Toast.svelte` — toast notifications +- `web/src/lib/components/FormField.svelte` — reusable form field with validation + +## Acceptance Criteria +- Quick Deploy: paste image URL → inspect → review defaults → deploy works end-to-end +- All settings are editable and saved via API +- Registry test connection shows success/failure +- Credentials are masked in display (`••••••••`) +- Webhook URL is shown with copy button and regenerate option +- Form validation prevents bad submissions + +## Notes +- Quick Deploy is the zero-config entry point — should be dead simple UX +- Credential fields: show mask, edit replaces entirely (no partial edit) +- Registry test: calls POST /api/registries/:id/test, shows connection result +- Toast component: appears top-right, auto-dismiss after 5s, color-coded (green/red) + +## Review Checklist +- [ ] All tasks completed +- [ ] Quick deploy flow is intuitive (minimal clicks) +- [ ] Credentials never shown in plaintext in UI +- [ ] Form validation covers required fields and formats +- [ ] Error states are handled with user-friendly messages + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-11-embed-sse.md b/plans/docker-watcher-core/phase-11-embed-sse.md new file mode 100644 index 0000000..90d4f15 --- /dev/null +++ b/plans/docker-watcher-core/phase-11-embed-sse.md @@ -0,0 +1,76 @@ +# Phase 11: Frontend Embed & Real-Time Updates + +**Status:** Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Build SvelteKit to static files, embed into the Go binary with `go:embed`, serve from Go, and implement SSE for real-time deploy progress and instance status updates. + +## Tasks + +- [x] Task 1: Configure SvelteKit static adapter to output to `web/build/` (already configured) +- [x] Task 2: Add `//go:embed web/build` directive in Go — `web.go` at project root +- [x] Task 3: Create Go handler for serving embedded SPA — `internal/api/static.go` with SPA fallback +- [x] Task 4: Implement SSE endpoint for deploy logs — `GET /api/deploys/:id/logs` (SSE + JSON fallback) +- [x] Task 5: Implement SSE endpoint for instance status — `GET /api/events` streams instance status changes +- [x] Task 6: Create event bus/broadcaster in Go — `internal/events/bus.go` with pub/sub channels +- [x] Task 7: Frontend: connect to SSE for deploy progress — `connectDeployLogs()` in `web/src/lib/sse.ts` +- [x] Task 8: Frontend: connect to SSE for instance status — global SSE in `+layout.svelte` via store +- [x] Task 9: Handle SSE reconnection in frontend — exponential backoff with jitter in `connectSSE()` +- [x] Task 10: Build script/Makefile — `make build` builds frontend then Go binary + +## Files to Modify/Create +- `web/svelte.config.js` — already configured with static adapter outputting to `web/build/` +- `web.go` — root-level embed directive (`//go:embed web/build`) +- `internal/api/static.go` — embedded static file server with SPA fallback +- `internal/api/sse.go` — SSE endpoints for deploy logs and instance events +- `internal/events/bus.go` — event bus for publishing/subscribing to events +- `web/src/lib/sse.ts` — SSE client helper with auto-reconnect +- `web/src/lib/stores/instance-status.ts` — Svelte store for real-time instance status +- `web/src/routes/+layout.svelte` — wired up global SSE connection for instance status +- `Makefile` — build frontend + backend +- `cmd/server/main.go` — wired embedded static serving and event bus +- `internal/api/router.go` — added eventBus to Server, SSE routes +- `internal/api/deploys.go` — removed old JSON stub, replaced by SSE handler +- `internal/deployer/deployer.go` — added event publishing for deploy logs, status, instance status + +## Acceptance Criteria +- `make build` produces a single Go binary with embedded frontend +- Go binary serves the SvelteKit SPA on all non-API routes +- Deploy progress streams in real-time via SSE +- Instance status updates appear without page refresh +- SSE reconnects automatically after network hiccups + +## Notes +- `go:embed` requires the embedded directory to be relative to the Go source file +- SPA fallback: any request that doesn't match `/api/*` gets `index.html` +- Event bus: simple pub/sub with channels — no external dependency needed +- SSE format: `data: {"type": "deploy_log", "payload": {...}}\n\n` +- Keep SSE connections lightweight — use context cancellation for cleanup +- WriteTimeout on HTTP server set to 0 to support long-lived SSE connections +- Deploy logs endpoint serves both SSE (Accept: text/event-stream) and JSON (default) + +## Review Checklist +- [x] All tasks completed +- [x] Single binary serves both API and frontend +- [x] SSE handles multiple concurrent clients (buffered channels, non-blocking publish) +- [x] No goroutine leaks on SSE disconnect (context cancellation + Unsubscribe) +- [x] Build process is reproducible (Makefile) + +## Handoff to Next Phase + +### What was implemented +- **Event bus** (`internal/events/bus.go`): In-process pub/sub with topic filtering, buffered subscriber channels (64 events), non-blocking publish. Supports `EventDeployLog`, `EventInstanceStatus`, and `EventDeployStatus` event types. +- **SSE endpoints**: `GET /api/deploys/{id}/logs` streams deploy logs with JSON fallback; `GET /api/events` streams global instance/deploy status changes. +- **Static file serving**: `web.go` at project root embeds `web/build/`, `internal/api/static.go` serves SPA with fallback. Mounted via chi's `NotFound` handler. +- **Frontend SSE client** (`web/src/lib/sse.ts`): `connectSSE()` with exponential backoff + jitter, `connectDeployLogs()` and `connectGlobalEvents()` convenience functions. +- **Instance status store** (`web/src/lib/stores/instance-status.ts`): Svelte writable store updated by global SSE connection in `+layout.svelte`. +- **Deployer integration**: `deployer.go` now publishes deploy log, deploy status, and instance status events via `EventPublisher` interface. + +### Key integration points for next phase +- `events.Bus` is passed to both `api.NewServer` and `deployer.New` +- `api.NewServer` now requires an `*events.Bus` parameter (6th arg before encKey) +- `deployer.New` now requires an `EventPublisher` parameter (6th arg before encKey) +- HTTP server `WriteTimeout` is 0 to support SSE +- The `web.go` file at project root uses package name `dockerwatcher` (imported as `github.com/alexei/docker-watcher`) diff --git a/plans/docker-watcher-core/phase-12-hardening.md b/plans/docker-watcher-core/phase-12-hardening.md new file mode 100644 index 0000000..a195218 --- /dev/null +++ b/plans/docker-watcher-core/phase-12-hardening.md @@ -0,0 +1,72 @@ +# Phase 12: Hardening + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Production hardening — blue-green deploys, promote flow, dashboard auth, graceful shutdown, structured logging, and config export. + +## Tasks + +- [ ] Task 1: Blue-green deploys — start new container, health check, swap NPM proxy, then stop old container (zero downtime) +- [ ] Task 2: Promote flow — enforce `promote_from` for production deploys (only tags running in source stage are eligible) +- [ ] Task 3: Local auth — username/password stored in SQLite (bcrypt hashed), login endpoint, session token (JWT or cookie) +- [ ] Task 4: OAuth2/OIDC auth — integration with Authentik or any OIDC provider (configurable client ID, client secret, discovery URL) +- [ ] Task 5: Auth settings UI — settings page to choose auth mode (local/OIDC), configure OIDC provider, manage local users +- [ ] Task 6: Auth middleware — protect all /api/* routes except webhook; check session/JWT/OIDC token +- [ ] Task 7: Graceful shutdown — handle SIGTERM/SIGINT, drain in-progress deploys, close DB, stop poller +- [ ] Task 8: Structured logging — JSON logs with deploy context (project, stage, tag, instance ID) +- [ ] Task 9: Config export — download current SQLite state as YAML (reverse of seed import) +- [ ] Task 10: Dockerfile — multi-stage build (build frontend + Go, copy to minimal image) +- [ ] Task 11: docker-compose.yml — production-ready compose file with volumes, network, env +- [ ] Task 12: Final wiring review — ensure all services are properly initialized and shut down + +## Files to Modify/Create +- `internal/deployer/bluegreen.go` — blue-green deploy strategy +- `internal/deployer/promote.go` — promote flow logic +- `internal/auth/local.go` — local auth (bcrypt password hashing, session tokens) +- `internal/auth/oidc.go` — OAuth2/OIDC provider integration +- `internal/auth/middleware.go` — auth middleware (session/JWT/OIDC token validation) +- `internal/auth/models.go` — user model, auth settings, session store +- `internal/api/auth.go` — auth API endpoints (login, logout, OIDC callback, user management) +- `internal/config/export.go` — config export to YAML +- `internal/logging/logger.go` — structured JSON logger +- `internal/store/users.go` — user CRUD, auth settings persistence +- `web/src/routes/login/+page.svelte` — login page +- `web/src/routes/settings/auth/+page.svelte` — auth settings UI +- `cmd/server/main.go` — graceful shutdown, structured logging, auth init +- `Dockerfile` — multi-stage build +- `docker-compose.yml` — production compose file + +## Acceptance Criteria +- Blue-green: zero downtime during deploy (old container serves until new one is healthy) +- Promote: production deploy only accepts tags from the specified source stage +- Auth: unauthenticated requests to /api/* (except webhook) return 401 +- Graceful shutdown: in-progress deploys complete before exit +- Logs are JSON-formatted with contextual fields +- Config export produces valid YAML that could be re-imported +- Docker image builds and runs correctly + +## Notes +- Blue-green: keep old container running until new one passes health check, then swap NPM proxy and stop old +- Auth has two modes configurable via settings: + - **Local auth**: username/password in SQLite (bcrypt hashed), JWT session tokens + - **OAuth2/OIDC**: integration with Authentik or any OIDC provider (client ID, secret, discovery URL) +- First launch: create default admin user with configurable password via ADMIN_PASSWORD env var +- OIDC flow: redirect to provider → callback → create/link local user → issue session +- SIGTERM handling: use Go's `os/signal` + `context.WithCancel` +- Structured logging: use `log/slog` (Go stdlib since 1.21) +- Dockerfile: build stage with Node.js + Go, runtime stage with scratch/alpine +- Phase 13 (UI Polish) and Phase 14 (Volumes & Env) follow this phase + +## Review Checklist +- [ ] All tasks completed +- [ ] Blue-green deploy handles rollback if new container fails +- [ ] Auth doesn't block webhook endpoint +- [ ] Graceful shutdown tested with concurrent deploys +- [ ] Dockerfile produces a minimal image +- [ ] docker-compose.yml matches the example in PLAN.md + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-13-ui-polish.md b/plans/docker-watcher-core/phase-13-ui-polish.md new file mode 100644 index 0000000..0fba31f --- /dev/null +++ b/plans/docker-watcher-core/phase-13-ui-polish.md @@ -0,0 +1,86 @@ +# Phase 13: Frontend Polish & Modern UI + +**Status:** COMPLETED +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Enhance the web UI with a modern, polished look and feel — custom SVG icons, refined typography, consistent color palette, smooth transitions, and overall professional frontend quality. + +## Tasks + +- [x] Task 1: Design system foundations — CSS custom properties for color palette (light/dark), spacing scale, typography scale, border radius tokens, shadows, transitions in `web/src/lib/styles/tokens.css` +- [x] Task 2: SVG icon set — 38 Lucide-based inline SVG icon components in `web/src/lib/components/icons/` covering all UI actions (deploy, stop, start, restart, remove, settings, registry, etc.) +- [x] Task 3: Refine layout — polished sidebar with active state indicators (dot + background), smooth transitions, responsive breakpoints, collapsible sidebar on mobile with hamburger menu +- [x] Task 4: Dashboard cards — redesigned project cards with box icon, status indicators, instance count badges, hover effects (-translate-y-0.5, shadow-md), port/healthcheck chips +- [x] Task 5: Project detail view — clean card layout for instances with icon action buttons, inline status badges with pulse animation for "running", deploy history as timeline cards +- [x] Task 6: Form styling — consistent input fields with design tokens, select dropdowns, ToggleSwitch component replacing checkboxes, button hierarchy (primary brand/secondary/danger) +- [x] Task 7: Toast/notification system — slide-in toasts with Lucide icons, rounded-xl, auto-dismiss, stacking +- [x] Task 8: Loading states — Skeleton, SkeletonCard, SkeletonTable loader components with shimmer animation for data fetching, IconLoader spinner for actions +- [x] Task 9: Empty states — EmptyState component with SVG illustrations and call-to-action buttons for all empty list scenarios +- [x] Task 10: Responsive design — mobile-friendly layout with collapsible sidebar, hamburger menu, mobile top bar, touch-friendly controls, horizontal settings nav on mobile +- [x] Task 11: Micro-interactions — button press feedback (active:animate-press), status pulse animation (ping), scale-in for dialogs/forms, fade-in for overlays, slide-in for toasts +- [x] Task 12: Dark mode support — ThemeToggle component with light/dark/system modes, CSS custom properties for dark theme via [data-theme="dark"], localStorage persistence, system preference detection +- [x] Task 13: Localization (EN/RU) — i18n store with derived t() function, en.json and ru.json locale files, LocaleSwitcher component, localStorage persistence, all UI strings translated + +## Files Created +- `web/src/lib/styles/tokens.css` — design tokens (colors, spacing, typography, radius, shadows, transitions, animations) +- `web/src/lib/components/icons/` — 38 Lucide icon components + index.ts barrel export +- `web/src/lib/i18n/en.json` — English locale strings +- `web/src/lib/i18n/ru.json` — Russian locale strings +- `web/src/lib/i18n/index.ts` — i18n store with t() function and locale management +- `web/src/lib/stores/theme.ts` — dark mode store with system preference detection +- `web/src/lib/components/Skeleton.svelte` — base skeleton loader +- `web/src/lib/components/SkeletonCard.svelte` — card skeleton placeholder +- `web/src/lib/components/SkeletonTable.svelte` — table skeleton placeholder +- `web/src/lib/components/EmptyState.svelte` — empty state with SVG illustrations +- `web/src/lib/components/ToggleSwitch.svelte` — toggle switch replacing checkboxes +- `web/src/lib/components/ThemeToggle.svelte` — light/dark/system theme toggle +- `web/src/lib/components/LocaleSwitcher.svelte` — EN/RU locale switcher + +## Files Modified +- `web/src/app.css` — imports tokens.css, adds base styles, custom scrollbar, focus ring utility +- `web/src/routes/+layout.svelte` — polished sidebar with icons, collapsible mobile sidebar, theme/locale controls +- `web/src/routes/+page.svelte` — dashboard with stats cards, skeleton loaders, empty states, i18n +- `web/src/routes/login/+page.svelte` — polished login with design tokens and i18n +- `web/src/routes/deploy/+page.svelte` — quick deploy with icons, animations, i18n +- `web/src/routes/projects/+page.svelte` — projects list with skeleton loaders, empty states, i18n +- `web/src/routes/projects/[id]/+page.svelte` — project detail with deploy timeline, icons, i18n +- `web/src/routes/projects/[id]/env/+page.svelte` — env editor with toggle switches, icons, i18n +- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor with icons, i18n +- `web/src/routes/settings/+layout.svelte` — settings nav with icons, responsive horizontal nav +- `web/src/routes/settings/+page.svelte` — general settings with design tokens, i18n +- `web/src/routes/settings/registries/+page.svelte` — registries with icons, empty states, i18n +- `web/src/routes/settings/credentials/+page.svelte` — credentials with design tokens, i18n +- `web/src/routes/settings/auth/+page.svelte` — auth settings with icons, empty states, i18n +- `web/src/lib/components/Toast.svelte` — slide-in toasts with Lucide icons +- `web/src/lib/components/StatusBadge.svelte` — pulse animation for running status +- `web/src/lib/components/ConfirmDialog.svelte` — fade/scale-in animation, icon +- `web/src/lib/components/FormField.svelte` — consistent styling with design tokens +- `web/src/lib/components/ProjectCard.svelte` — redesigned with hover effects, badges +- `web/src/lib/components/InstanceCard.svelte` — icon action buttons, improved layout + +## Acceptance Criteria +- [x] UI looks modern and professional — not "default framework" appearance +- [x] Consistent icon language throughout the app +- [x] Smooth transitions and meaningful animations (not gratuitous) +- [x] Responsive down to mobile viewport +- [x] Loading and empty states provide good UX +- [x] Color palette works well in both light and dark contexts +- [x] All UI strings available in English and Russian, switchable via locale picker + +## Review Checklist +- [x] All tasks completed +- [x] Visual consistency across all pages +- [x] No functionality regressions +- [x] Responsive on mobile/tablet/desktop +- [x] Accessible (proper contrast ratios, focus states, aria labels on icons) + +## Handoff Notes +This is the FINAL phase. All 13 phases of Docker Watcher are now complete. The application has: +- Full Go backend with SQLite, Docker management, Nginx Proxy Manager integration +- SvelteKit frontend with dark mode, i18n (EN/RU), responsive design, skeleton loaders, empty states +- Real-time SSE events for deploy/instance status +- Authentication (local + OIDC), RBAC, registry management +- Environment variable overrides, volume management, config export +- Webhook-based and polling-based image detection diff --git a/plans/docker-watcher-core/phase-14-volumes-env.md b/plans/docker-watcher-core/phase-14-volumes-env.md new file mode 100644 index 0000000..68edfcd --- /dev/null +++ b/plans/docker-watcher-core/phase-14-volumes-env.md @@ -0,0 +1,58 @@ +# Phase 14: Volumes & Environment + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** fullstack + +## Objective +Implement per-project environment variables with per-stage overrides, volume mounts with shared/isolated modes, sensitive env value encryption, and UI for managing both. + +## Tasks + +- [ ] Task 1: Extend store schema — add `stage_env` table for per-stage env overrides (stage_id, key, value, encrypted bool) +- [ ] Task 2: Extend store schema — add `volumes` table for volume config (project_id, source, target, mode: shared|isolated) +- [ ] Task 3: Implement store CRUD for stage env overrides (Create, GetByStageID, Update, Delete) +- [ ] Task 4: Implement store CRUD for volumes (Create, GetByProjectID, Update, Delete) +- [ ] Task 5: Encrypt sensitive env values (values marked as secret) using crypto.Encrypt before storage +- [ ] Task 6: Merge env vars during deploy — project-level env + stage-level overrides, decrypt secrets +- [ ] Task 7: Compute volume mounts during deploy — shared mode uses path as-is, isolated mode appends `/{stage}-{tag}/` to source +- [ ] Task 8: Pass merged env vars and volume mounts to Docker container creation +- [ ] Task 9: API endpoints — CRUD for stage env vars and project volumes +- [ ] Task 10: Frontend — env var editor in project/stage settings (key/value pairs, secret toggle) +- [ ] Task 11: Frontend — volume editor in project settings (source/target/mode) +- [ ] Task 12: Frontend — per-stage env override UI showing inherited vs overridden values + +## Files to Modify/Create +- `internal/store/stage_env.go` — stage env CRUD +- `internal/store/volumes.go` — volume CRUD +- `internal/store/store.go` — add new tables to schema +- `internal/deployer/deployer.go` — merge env vars and compute volume mounts during deploy +- `internal/docker/container.go` — accept volume mounts in ContainerConfig +- `internal/api/stages.go` — add env var endpoints +- `internal/api/projects.go` — add volume endpoints +- `web/src/routes/projects/[id]/env/+page.svelte` — env var editor +- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor + +## Acceptance Criteria +- Project-level env vars applied to all containers +- Stage-level overrides replace project-level values for matching keys +- Sensitive env values encrypted at rest, decrypted only during deploy +- Shared volumes: all instances mount same host path +- Isolated volumes: each instance gets `{source}/{stage}-{tag}/` subdirectory +- UI allows managing env vars and volumes per project and per stage + +## Notes +- Project `env` field already exists as JSON blob in the store — this phase may migrate to a proper table or keep JSON and add stage overrides separately +- Volume `mode` is either "shared" or "isolated" +- Isolated volume subdirectory is created automatically by Docker (bind mount creates parent dirs) +- Sensitive env display: masked in UI, "Change" button pattern (same as credentials page) + +## Review Checklist +- [ ] All tasks completed +- [ ] Env merge logic is correct (stage overrides project) +- [ ] Secret values never appear in plaintext in API responses +- [ ] Volume paths are validated (no path traversal) +- [ ] Isolated volume subdirectory naming is deterministic + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-2-crypto-config.md b/plans/docker-watcher-core/phase-2-crypto-config.md new file mode 100644 index 0000000..f9f4aa4 --- /dev/null +++ b/plans/docker-watcher-core/phase-2-crypto-config.md @@ -0,0 +1,61 @@ +# Phase 2: Crypto & Config Seed Loader + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement AES-256 encryption for credential storage and the YAML seed config parser that imports into SQLite on first launch. + +## Tasks + +- [x] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher` +- [x] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes) +- [x] Task 3: Define YAML config structs matching the seed format from PLAN.md +- [x] Task 4: Implement YAML parser — read and validate seed file +- [x] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD +- [x] Task 6: Encrypt credential fields (registry tokens, NPM password) during import +- [x] Task 7: Create `docker-watcher.example.yaml` with documented example config +- [x] Task 8: Wire seed import into `cmd/server/main.go` startup sequence + +## Files to Modify/Create +- `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt +- `internal/config/config.go` — YAML structs and parser +- `internal/config/seed.go` — seed import logic (YAML → SQLite) +- `docker-watcher.example.yaml` — example seed config +- `cmd/server/main.go` — add seed import to startup + +## Acceptance Criteria +- Encrypt then decrypt round-trips correctly +- Different plaintexts produce different ciphertexts (random nonce) +- YAML parsing handles all fields from the seed format +- Seed import creates projects, stages, registries, and settings in SQLite +- Credentials are encrypted before storage +- Import is idempotent — skipped if DB already has data + +## Notes +- ENCRYPTION_KEY is the only secret env var — everything else is encrypted in SQLite +- Use GCM mode for authenticated encryption (integrity + confidentiality) +- Seed import should be transactional — all or nothing +- The example YAML should have placeholder values, not real credentials + +## Review Checklist +- [x] All tasks completed +- [x] Crypto uses secure practices (random nonce, GCM, no ECB) +- [x] No hardcoded keys or secrets +- [x] YAML parsing validates required fields +- [x] Import is transactional + +## Handoff to Next Phase + +- `crypto.Encrypt(key, plaintext)` and `crypto.Decrypt(key, ciphertextHex)` handle AES-256-GCM encryption; ciphertext is hex-encoded with prepended nonce +- `crypto.KeyFromEnv()` derives a `[32]byte` key from the `ENCRYPTION_KEY` env var via SHA-256 +- `crypto.EncryptIfNotEmpty(key, value)` is a convenience wrapper that passes through empty strings unchanged +- `config.ImportSeed(db, seedPath)` is the single entry point for seed import — called from `main.go` at startup +- Import is idempotent: skipped if the DB already has projects or registries +- Import is transactional: all inserts happen within a single SQLite transaction (rollback on any failure) +- Registry `token` and settings `npm_password` are now stored encrypted in SQLite — later phases that read these fields must decrypt with `crypto.Decrypt(key, value)` +- `store.DB()` method was added to expose the underlying `*sql.DB` for transaction use +- Seed file path is configurable via `SEED_FILE` env var (default: `./docker-watcher.yaml`) +- YAML validation ensures: `global.domain` is required, every project needs `image`, project registry references must exist, stages need `tag_pattern` +- `go.sum` still does not exist — run `go mod tidy` when Go toolchain is available diff --git a/plans/docker-watcher-core/phase-3-docker-client.md b/plans/docker-watcher-core/phase-3-docker-client.md new file mode 100644 index 0000000..4c07f1b --- /dev/null +++ b/plans/docker-watcher-core/phase-3-docker-client.md @@ -0,0 +1,98 @@ +# Phase 3: Docker Client + +**Status:** :white_check_mark: Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement the Docker Engine API wrapper for container lifecycle management — pull images, inspect, create/start/stop/remove containers, and manage networks. + +## Tasks + +- [x] Task 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`) +- [x] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth +- [x] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels +- [x] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels +- [x] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)` +- [x] Task 6: Implement `RestartContainer(ctx, containerID, timeout)` +- [x] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers +- [x] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists +- [x] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network +- [x] Task 10: Add docker-watcher labels to all managed containers (`docker-watcher.project`, `docker-watcher.stage`, `docker-watcher.instance-id`) + +## Files to Modify/Create +- `internal/docker/client.go` — Docker client wrapper, connection setup +- `internal/docker/container.go` — container lifecycle operations +- `internal/docker/image.go` — pull and inspect operations +- `internal/docker/network.go` — network management + +## Acceptance Criteria +- Client connects to Docker socket +- Pull handles both public and authenticated registries +- Image inspection extracts port, healthcheck, and label metadata +- Container creation applies all config (env, ports, network, labels) +- All operations return meaningful errors +- Managed containers are identifiable via labels + +## Notes +- Use `github.com/docker/docker/client` SDK +- Container names should be deterministic: `dw-{project}-{stage}-{tag-sanitized}` +- All containers should be on the shared network (e.g., `staging-net`) +- Port mapping: container's EXPOSE port → random host port (Docker auto-assigns) +- Auth config for private registries will come from the store (encrypted tokens) + +## Review Checklist +- [x] All tasks completed +- [x] Proper context propagation for cancellation +- [x] Resource cleanup (close client, remove failed containers) +- [x] No hardcoded values +- [x] Error messages include container/image identifiers + +## Handoff to Next Phase + +### Exported API surface (`internal/docker` package) + +**Client lifecycle:** +- `docker.New() (*Client, error)` — creates client with env-based config and API version negotiation +- `(*Client).Close() error` — releases resources +- `(*Client).Ping(ctx) error` — checks daemon connectivity + +**Image operations (`image.go`):** +- `(*Client).PullImage(ctx, imageRef, tag, authConfig) error` — pulls image; authConfig is base64-encoded JSON (use `EncodeRegistryAuth` helper) +- `(*Client).InspectImage(ctx, imageRef) (ImageInfo, error)` — returns `ImageInfo{ExposedPorts, Healthcheck, Labels}` +- `docker.EncodeRegistryAuth(username, password, serverAddress) (string, error)` — builds auth payload for `PullImage` + +**Container operations (`container.go`):** +- `(*Client).CreateContainer(ctx, ContainerConfig) (containerID string, error)` — creates container with labels, env, ports, network +- `(*Client).StartContainer(ctx, containerID) error` +- `(*Client).StopContainer(ctx, containerID, timeoutSeconds) error` +- `(*Client).RemoveContainer(ctx, containerID, force) error` +- `(*Client).RestartContainer(ctx, containerID, timeoutSeconds) error` +- `(*Client).ListContainers(ctx, labelFilters) ([]ManagedContainer, error)` — always scoped to docker-watcher labels +- `(*Client).InspectContainerPort(ctx, containerID, containerPort) (uint16, error)` — gets auto-assigned host port +- `docker.ContainerName(project, stage, tag) string` — deterministic name: `dw-{project}-{stage}-{tag-sanitized}` + +**Network operations (`network.go`):** +- `(*Client).EnsureNetwork(ctx, networkName) (networkID string, error)` — idempotent create-if-not-exists +- `(*Client).ConnectNetwork(ctx, networkID, containerID) error` + +**Label constants:** +- `docker.LabelProject` = `"docker-watcher.project"` +- `docker.LabelStage` = `"docker-watcher.stage"` +- `docker.LabelInstanceID` = `"docker-watcher.instance-id"` + +**Key types:** +- `docker.ContainerConfig` — input for `CreateContainer` (Name, Image, Env, ExposedPorts, NetworkName, NetworkID, Labels, Project, Stage, InstanceID) +- `docker.ImageInfo` — output of `InspectImage` (ExposedPorts, Healthcheck, Labels) +- `docker.ManagedContainer` — output of `ListContainers` (ID, Name, Image, Status, State, Project, Stage, InstanceID, Ports) + +### Dependencies added +- `github.com/docker/docker v27.5.1+incompatible` +- `github.com/docker/go-connections v0.5.0` +- Run `go mod tidy` to resolve transitive dependencies before building + +### Conventions maintained +- `context.Context` as first parameter on all methods +- Errors wrapped with `fmt.Errorf("context: %w", err)` +- Package-level constants for labels +- Immutable patterns (new maps created rather than mutating input) diff --git a/plans/docker-watcher-core/phase-4-npm-client.md b/plans/docker-watcher-core/phase-4-npm-client.md new file mode 100644 index 0000000..ba11b0a --- /dev/null +++ b/plans/docker-watcher-core/phase-4-npm-client.md @@ -0,0 +1,78 @@ +# Phase 4: NPM Client + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for proxy hosts, and host lookup. + +## Tasks + +- [x] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh +- [x] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT +- [x] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts +- [x] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id} +- [x] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id} +- [x] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts +- [x] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name +- [x] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.) +- [x] Task 9: Handle JWT token expiry — re-authenticate automatically on 401 + +## Files to Modify/Create +- `internal/npm/client.go` — NPM API client, auth, HTTP helpers +- `internal/npm/types.go` — request/response types for proxy hosts + +## Acceptance Criteria +- Client authenticates and caches JWT +- CRUD operations work for proxy hosts +- Token refresh happens transparently on expiry +- Proxy host config supports: domain, forward host, forward port, SSL (Let's Encrypt optional) +- FindByDomain enables checking if a proxy already exists before creating + +## Notes +- NPM API base: typically `http://npm:81/api` +- Forward host for containers: use container name on the shared Docker network +- Forward port: the container's internal port (from EXPOSE) +- SSL: for staging, can be disabled; production may want Let's Encrypt +- NPM credentials come from settings (encrypted in SQLite, decrypted at runtime) + +## Review Checklist +- [ ] All tasks completed +- [ ] JWT caching and refresh work correctly +- [ ] HTTP errors are properly handled (not just status code, but response body) +- [ ] No credentials logged or leaked in errors +- [ ] Struct types match NPM API contract + +## Handoff to Next Phase + +### What was built + +- `internal/npm/types.go` — `ProxyHostConfig` (create/update input), `ProxyHost` (API response), `Meta`, auth types, and `boolInt` custom JSON type for NPM's 0/1 boolean fields. +- `internal/npm/client.go` — Full NPM API client with JWT auth, auto-refresh, and CRUD. + +### Public API surface + +```go +npm.New(baseURL string) *Client +(*Client).Authenticate(ctx, email, password string) error +(*Client).CreateProxyHost(ctx, config ProxyHostConfig) (ProxyHost, error) +(*Client).UpdateProxyHost(ctx, id int, config ProxyHostConfig) (ProxyHost, error) +(*Client).DeleteProxyHost(ctx, id int) error +(*Client).ListProxyHosts(ctx) ([]ProxyHost, error) +(*Client).FindProxyHostByDomain(ctx, domain string) (ProxyHost, bool, error) +``` + +### Key design decisions + +- JWT token is cached with expiry; auto-refreshed 5 minutes before expiry or on 401. +- Credentials are stored in memory after `Authenticate` to enable transparent re-auth. +- All HTTP errors include the response body text for debugging. +- Credentials are never included in error messages. +- `boolInt` type handles NPM API's inconsistent 0/1 vs true/false for boolean fields. +- `FindProxyHostByDomain` does case-insensitive matching against all domain names. + +### Dependencies for next phase + +- Caller must provide decrypted NPM credentials (email + password from settings via `crypto.Decrypt`). +- `ProxyHost.ID` (int) maps to `Instance.NpmProxyID` in the store for tracking. diff --git a/plans/docker-watcher-core/phase-5-registry-poller.md b/plans/docker-watcher-core/phase-5-registry-poller.md new file mode 100644 index 0000000..5b849b2 --- /dev/null +++ b/plans/docker-watcher-core/phase-5-registry-poller.md @@ -0,0 +1,49 @@ +# Phase 5: Registry Client & Poller + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement the registry client interface with Gitea implementation, and the periodic tag polling scheduler. + +## Tasks + +- [ ] Task 1: Define `Registry` interface — `ListTags(ctx, image)`, `GetLatestTag(ctx, image, pattern)` +- [ ] Task 2: Implement Gitea registry client — uses Gitea API to list container image tags +- [ ] Task 3: Implement tag pattern matching — match tags against glob patterns (e.g., `dev-*`, `v*`) +- [ ] Task 4: Implement tag comparison — detect new tags since last poll (store last-seen tag per project/stage) +- [ ] Task 5: Create poller service — periodic scheduler using `robfig/cron` +- [ ] Task 6: Poller logic — for each project/stage with polling enabled, check for new tags, trigger deploy if auto_deploy +- [ ] Task 7: Add `last_polled_tag` field to instances or a new `poll_state` table in store +- [ ] Task 8: Implement registry factory — create client based on registry type (gitea, future: github, dockerhub) + +## Files to Modify/Create +- `internal/registry/registry.go` — interface definition + factory +- `internal/registry/gitea.go` — Gitea registry implementation +- `internal/registry/poller.go` — polling scheduler service +- `internal/store/poll_state.go` — poll state persistence (optional, or extend existing tables) + +## Acceptance Criteria +- Gitea client can list tags for a given image +- Tag pattern matching correctly filters tags (glob-style) +- Poller runs on configurable interval +- New tags are detected by comparing against stored state +- Registry factory returns correct client based on type + +## Notes +- Gitea API: `GET /api/v1/packages/{owner}/container/{image}/tags` (or similar, verify against Gitea docs) +- Auth: Bearer token from registry config +- Polling interval comes from global settings +- The poller is a fallback — webhooks are the primary detection mechanism (Phase 6) +- GitHub Container Registry support is future work — just define the interface now + +## Review Checklist +- [ ] All tasks completed +- [ ] Interface is clean and minimal +- [ ] Pattern matching handles edge cases (empty pattern, no tags) +- [ ] Poller doesn't leak goroutines +- [ ] Registry auth tokens handled securely + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-6-webhook-handler.md b/plans/docker-watcher-core/phase-6-webhook-handler.md new file mode 100644 index 0000000..fbb6545 --- /dev/null +++ b/plans/docker-watcher-core/phase-6-webhook-handler.md @@ -0,0 +1,78 @@ +# Phase 6: Webhook Handler + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement the secret UUID-based webhook endpoint that receives image push notifications from CI systems, with auto-creation of unknown projects. + +## Tasks + +- [x] Task 1: Implement webhook HTTP handler — `POST /api/webhook/:secret-uuid` +- [x] Task 2: Validate incoming payload — extract image name and tag +- [x] Task 3: Look up project by image name in store — match against configured project images +- [x] Task 4: If known project: match tag to stage via tag patterns, determine if auto_deploy +- [x] Task 5: If unknown project: auto-create project with defaults from image inspection (EXPOSE port, labels) +- [x] Task 6: Generate and store webhook secret UUID in settings (on first launch) +- [x] Task 7: Implement webhook URL regeneration (new UUID, invalidates old one) +- [x] Task 8: Define webhook payload struct (`{"image": "registry/org/app:tag"}`) + +## Files to Modify/Create +- `internal/webhook/handler.go` — webhook HTTP handler + payload parsing +- `internal/webhook/matcher.go` — project/stage matching logic +- `internal/webhook/autocreate.go` — auto-create project from unknown image + +## Acceptance Criteria +- Valid webhook URL with correct UUID triggers processing +- Invalid/missing UUID returns 404 (no information leak) +- Known images are matched to projects and stages +- Unknown images trigger auto-creation with sensible defaults +- Webhook URL can be regenerated + +## Notes +- Webhook URL format: `POST /api/webhook/d8f2a1e9-...` +- No authentication needed beyond the secret UUID +- Auto-created projects use: image EXPOSE port, "dev" as default stage, auto_deploy: true +- The webhook handler calls into the deployer (Phase 7) — for now, define the interface/callback +- Keep the handler thin — it matches and delegates + +## Review Checklist +- [x] All tasks completed +- [x] No information leak on invalid UUIDs +- [x] Payload validation rejects malformed input +- [x] Auto-creation uses safe defaults +- [x] Handler is stateless (delegates to store/deployer) + +## Handoff to Next Phase + +### Exported API + +- `webhook.NewHandler(store, deployer, inspector)` — creates the HTTP handler +- `webhook.Handler.Route()` — returns a `chi.Router` to mount at `/api/webhook` +- `webhook.EnsureWebhookSecret(store)` — generates UUID on first launch, returns current secret +- `webhook.RegenerateWebhookSecret(store)` — replaces secret with new UUID, invalidates old one +- `webhook.ParseImageRef(ref)` — parses `registry/owner/name:tag` into components + +### Interfaces Defined + +- `webhook.DeployTriggerer` — `TriggerDeploy(ctx, projectID, stageID, imageTag) error` (mirrors `registry.DeployTriggerer`) +- `webhook.ImageInspector` — `InspectImage(ctx, imageRef) (docker.ImageInfo, error)` (wraps `docker.Client`) + +### Integration Points + +- Mount the webhook router: `r.Mount("/api/webhook", webhookHandler.Route())` +- Call `webhook.EnsureWebhookSecret(store)` at application startup to generate the secret on first launch +- The deployer must implement `webhook.DeployTriggerer` (same signature as `registry.DeployTriggerer`) +- The Docker client (`*docker.Client`) satisfies `webhook.ImageInspector` directly + +### Auto-Create Behavior + +- Unknown images create a project with name from image name, port from EXPOSE, healthcheck from image metadata +- A default "dev" stage is created with `tag_pattern: "*"`, `auto_deploy: true`, `max_instances: 1` +- If image inspection fails (not pulled locally), project is created with port=0 and empty healthcheck + +### Tag Matching + +- Uses `path.Match` (glob semantics) — same approach as the registry poller +- Stages are checked in name-sorted order; first matching stage wins diff --git a/plans/docker-watcher-core/phase-7-deployer.md b/plans/docker-watcher-core/phase-7-deployer.md new file mode 100644 index 0000000..d54e54a --- /dev/null +++ b/plans/docker-watcher-core/phase-7-deployer.md @@ -0,0 +1,54 @@ +# Phase 7: Deployer & Health Checker + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement the core deployment orchestrator: pull → start container → configure NPM proxy → health check → success/rollback. Plus multi-instance support and notifications. + +## Tasks + +- [x] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier +- [x] Task 2: Implement deploy flow: pull image → create container → start → connect to network → configure proxy → health check +- [x] Task 3: Implement subdomain generation per convention: `stage-{stage}-{project}` for default, `stage-{stage}-{project}-{tag}` for specific +- [x] Task 4: Sanitize tags for DNS (dots → dashes, lowercase, truncate) +- [x] Task 5: Implement health checker — HTTP GET to `http://container:{port}{healthcheck_path}` with retries and timeout +- [x] Task 6: Implement rollback on health check failure — remove new container, delete NPM proxy host, update instance status +- [x] Task 7: Implement multi-instance support — multiple tags of same project/stage can run simultaneously +- [x] Task 8: Implement max_instances enforcement — remove oldest instance when limit reached +- [x] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure +- [x] Task 10: Create deploy history records in store (status, timestamps, logs) +- [x] Task 11: Implement deploy log streaming — append log entries during deploy for real-time visibility + +## Files to Modify/Create +- `internal/deployer/deployer.go` — main deploy orchestrator +- `internal/deployer/subdomain.go` — subdomain generation and DNS sanitization +- `internal/deployer/rollback.go` — rollback logic +- `internal/health/checker.go` — HTTP health checker with retries +- `internal/notify/notifier.go` — webhook notification sender + +## Acceptance Criteria +- Full deploy flow works end-to-end (pull → proxy → health check) +- Failed health checks trigger automatic rollback +- Multi-instance: deploying a new tag doesn't stop existing instances +- max_instances removes oldest instance when exceeded +- Notifications fire on success and failure +- Deploy history is recorded with status and timestamps + +## Notes +- Health check: 3 retries, 5s between retries, 10s timeout per attempt (configurable later) +- Subdomain pattern comes from global settings +- Notifications are fire-and-forget (don't block deploy on notification failure) +- Deploy logs should be structured entries (timestamp + message) for SSE streaming later +- The deployer is the central orchestrator — webhook handler and poller both call into it + +## Review Checklist +- [ ] All tasks completed +- [ ] Rollback cleans up ALL resources (container, proxy, instance record) +- [ ] No goroutine leaks +- [ ] Error handling at every step of the deploy flow +- [ ] Subdomain generation produces valid DNS names + +## Handoff to Next Phase + diff --git a/plans/docker-watcher-core/phase-8-api-layer.md b/plans/docker-watcher-core/phase-8-api-layer.md new file mode 100644 index 0000000..73d668e --- /dev/null +++ b/plans/docker-watcher-core/phase-8-api-layer.md @@ -0,0 +1,112 @@ +# Phase 8: REST API Layer + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Wire up all REST API endpoints using chi router, connecting the store, deployer, and other services to HTTP handlers. + +## Tasks + +- [x] Task 1: Set up chi router with middleware (logging, recovery, CORS, JSON content-type) +- [x] Task 2: Implement project endpoints — GET/POST /api/projects, GET/PUT/DELETE /api/projects/:id +- [x] Task 3: Implement stage endpoints — POST /api/projects/:id/stages, PUT/DELETE /api/projects/:id/stages/:stage +- [x] Task 4: Implement instance endpoints — GET /api/projects/:id/stages/:stage/instances, POST (deploy), DELETE (remove) +- [x] Task 5: Implement instance control endpoints — POST .../instances/:iid/stop, start, restart +- [x] Task 6: Implement quick deploy endpoints — POST /api/deploy/inspect, POST /api/deploy/quick +- [x] Task 7: Implement registry endpoints — GET/POST /api/registries, PUT/DELETE /api/registries/:id, POST .../test +- [x] Task 8: Implement settings endpoints — GET/PUT /api/settings, GET /api/settings/webhook-url, POST .../regenerate +- [x] Task 9: Implement deploy history endpoints — GET /api/deploys, GET /api/deploys/:id/logs (SSE stub) +- [x] Task 10: Implement registry tags endpoint — GET /api/registries/:id/tags/:image +- [x] Task 11: Wire webhook handler into router — POST /api/webhook/:secret-uuid +- [x] Task 12: Wire everything in main.go — initialize all services, start HTTP server + +## Files to Modify/Create +- `internal/api/router.go` — chi router setup, middleware +- `internal/api/projects.go` — project CRUD handlers +- `internal/api/stages.go` — stage CRUD handlers +- `internal/api/instances.go` — instance lifecycle handlers +- `internal/api/deploys.go` — deploy + quick deploy handlers +- `internal/api/registries.go` — registry CRUD + test + tags handlers +- `internal/api/settings.go` — settings handlers +- `internal/api/middleware.go` — middleware (logging, CORS, recovery) +- `internal/api/response.go` — consistent API response helpers (envelope format) +- `cmd/server/main.go` — full service wiring and HTTP server start + +## Acceptance Criteria +- All endpoints from the API spec in PLAN.md are implemented +- Consistent JSON envelope response format (success, data, error, metadata) +- CORS configured for frontend dev (localhost origins) +- Proper HTTP status codes (200, 201, 400, 404, 500) +- main.go starts a fully wired HTTP server + +## Notes +- Response envelope: `{"success": bool, "data": any, "error": string|null, "meta": {pagination}}` +- CORS: allow all origins in dev, restrict in production (configurable later) +- SSE for deploy logs is a stub in this phase — real implementation in Phase 11 +- Quick deploy: /inspect pulls and inspects image, returns defaults; /quick creates project + deploys +- All handlers should validate input and return 400 for bad requests + +## Review Checklist +- [x] All tasks completed +- [x] All API endpoints from PLAN.md are covered +- [x] Consistent response format across all endpoints +- [x] Input validation on all POST/PUT handlers +- [x] No business logic in handlers (delegates to services) + +## Handoff to Next Phase + +### API Surface +- `api.NewServer(store, docker, deployer, webhookHandler, encKey)` creates the server +- `server.Router()` returns a `chi.Router` with all routes mounted under `/api` +- Response envelope: `{"success": bool, "data": ..., "error": "..."}` + +### Endpoints Implemented +| Method | Path | Handler | +|--------|------|---------| +| GET | /api/projects | listProjects | +| POST | /api/projects | createProject | +| GET | /api/projects/{id} | getProject (includes stages) | +| PUT | /api/projects/{id} | updateProject | +| DELETE | /api/projects/{id} | deleteProject | +| POST | /api/projects/{id}/stages | createStage | +| PUT | /api/projects/{id}/stages/{stage} | updateStage | +| DELETE | /api/projects/{id}/stages/{stage} | deleteStage | +| GET | /api/projects/{id}/stages/{stage}/instances | listInstances | +| POST | /api/projects/{id}/stages/{stage}/instances | deployInstance | +| DELETE | /api/projects/{id}/stages/{stage}/instances/{iid} | removeInstance | +| POST | .../instances/{iid}/stop | stopInstance | +| POST | .../instances/{iid}/start | startInstance | +| POST | .../instances/{iid}/restart | restartInstance | +| GET | /api/deploys | listDeploys | +| GET | /api/deploys/{id}/logs | getDeployLogs (JSON stub) | +| POST | /api/deploy/inspect | inspectImage | +| POST | /api/deploy/quick | quickDeploy | +| GET | /api/registries | listRegistries | +| POST | /api/registries | createRegistry | +| PUT | /api/registries/{id} | updateRegistry | +| DELETE | /api/registries/{id} | deleteRegistry | +| POST | /api/registries/{id}/test | testRegistry | +| GET | /api/registries/{id}/tags/* | listRegistryTags | +| GET | /api/settings | getSettings | +| PUT | /api/settings | updateSettings | +| GET | /api/settings/webhook-url | getWebhookURL | +| POST | /api/settings/regenerate | regenerateWebhookSecret | +| POST | /api/webhook/{secret} | webhook handler (mounted from webhook package) | + +### main.go Wiring +- All services initialized: store, docker, npm, deployer, health, notifier, webhook, poller +- HTTP server with graceful shutdown on SIGTERM/SIGINT +- Environment variables: `DATA_DIR`, `SEED_FILE`, `ENCRYPTION_KEY`, `NPM_URL`, `POLLING_INTERVAL`, `LISTEN_ADDR` +- Default listen address: `:8080` + +### SSE Stub +- `GET /api/deploys/{id}/logs` returns logs as JSON array (not SSE yet) +- Real SSE streaming deferred to Phase 11 + +### Security Notes +- Registry tokens are encrypted before storage, decrypted on read for API calls +- Settings response strips `npm_password` and `webhook_secret`, returns `has_npm_password` boolean +- Registry list response strips tokens, returns `has_token` boolean +- CORS allows all origins (dev mode) -- restrict in Phase 12 diff --git a/plans/docker-watcher-core/phase-9-dashboard.md b/plans/docker-watcher-core/phase-9-dashboard.md new file mode 100644 index 0000000..6dcd7e5 --- /dev/null +++ b/plans/docker-watcher-core/phase-9-dashboard.md @@ -0,0 +1,99 @@ +# Phase 9: SvelteKit Dashboard & Project Views + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build the SvelteKit frontend with the dashboard overview and project detail views — project list, instance status, controls, and deploy history. + +## Tasks + +- [x] Task 1: Initialize SvelteKit project in `web/` directory with TypeScript, static adapter +- [x] Task 2: Set up Tailwind CSS v4 with @tailwindcss/vite plugin +- [x] Task 3: Create shared API client (`lib/api.ts`) — typed fetch wrapper for all backend endpoints +- [x] Task 4: Define TypeScript types (`lib/types.ts`) — Project, Stage, Instance, Deploy, Registry, Settings +- [x] Task 5: Create layout with navigation — sidebar with Dashboard, Projects, Deploy, Settings links +- [x] Task 6: Dashboard page (`routes/+page.svelte`) — project overview cards with instance counts, status indicators +- [x] Task 7: Projects list page (`routes/projects/+page.svelte`) — all projects with quick stats, "Add Project" button +- [x] Task 8: Project detail page (`routes/projects/[id]/+page.svelte`) — stages, instances per stage, controls +- [x] Task 9: Instance controls — Stop, Start, Restart, Remove buttons with confirmation dialogs +- [x] Task 10: Deploy history section in project detail — recent deploys with status, timestamp, tag +- [x] Task 11: "Deploy new version" dropdown — list available tags from registry, trigger deploy +- [x] Task 12: Create reusable components: StatusBadge, InstanceCard, ProjectCard, ConfirmDialog + +## Files to Modify/Create +- `web/package.json` — SvelteKit project config +- `web/svelte.config.js` — SvelteKit config with static adapter +- `web/vite.config.ts` — Vite config with API proxy for dev +- `web/src/app.html` — base HTML +- `web/src/lib/api.ts` — API client +- `web/src/lib/types.ts` — shared TypeScript types +- `web/src/routes/+layout.svelte` — app layout with navigation +- `web/src/routes/+page.svelte` — dashboard +- `web/src/routes/projects/+page.svelte` — project list +- `web/src/routes/projects/[id]/+page.svelte` — project detail +- `web/src/lib/components/StatusBadge.svelte` — status indicator +- `web/src/lib/components/InstanceCard.svelte` — instance display +- `web/src/lib/components/ProjectCard.svelte` — project summary card +- `web/src/lib/components/ConfirmDialog.svelte` — confirmation modal + +## Acceptance Criteria +- SvelteKit project builds to static output +- Dashboard shows all projects with live status +- Project detail shows stages, instances, and controls +- Instance controls trigger correct API calls +- Deploy dropdown fetches and displays available tags +- UI is responsive and clean + +## Notes +- SvelteKit static adapter for embedding in Go binary +- API proxy in vite.config.ts for dev: proxy `/api` to `http://localhost:8080` +- Use SvelteKit's `fetch` for SSR-compatible data loading +- Status colors: green=running, yellow=starting, red=failed, gray=stopped +- Keep components small and reusable + +## Review Checklist +- [ ] All tasks completed +- [ ] TypeScript types match backend API response format +- [ ] API client handles errors gracefully with user feedback +- [ ] No hardcoded API URLs (use relative paths) +- [ ] Components are reusable and well-structured + +## Handoff to Next Phase + +Phase 9 is complete. All 14 files have been created in the `web/` directory: + +**Configuration files:** +- `web/package.json` — Svelte 5, SvelteKit 2, Tailwind CSS v4, static adapter, TypeScript +- `web/svelte.config.js` — Static adapter with SPA fallback (`index.html`) +- `web/vite.config.ts` — Tailwind v4 vite plugin + `/api` proxy to `localhost:8080` +- `web/tsconfig.json` — Strict TypeScript, bundler module resolution +- `web/src/app.html` — Base HTML shell +- `web/src/app.css` — Tailwind v4 import +- `web/src/routes/+layout.ts` — Disables SSR, enables prerender for static adapter + +**Core library:** +- `web/src/lib/types.ts` — All TypeScript types matching Go backend models exactly (Project, Stage, Instance, Deploy, DeployLog, Registry, Settings, ApiEnvelope) +- `web/src/lib/api.ts` — Full typed API client covering all endpoints (projects, instances, deploys, registries, settings). Unwraps envelope, throws `ApiError` on failure. + +**Components (Svelte 5 runes):** +- `StatusBadge.svelte` — Color-coded status pill (green/yellow/red/gray/blue) +- `ConfirmDialog.svelte` — Modal with danger/primary variants +- `InstanceCard.svelte` — Instance display with stop/start/restart/remove controls +- `ProjectCard.svelte` — Project summary card for dashboard grid + +**Pages:** +- `+layout.svelte` — Sidebar navigation (Dashboard, Projects, Deploy, Settings) +- `routes/+page.svelte` — Dashboard with stats cards and project grid +- `routes/projects/+page.svelte` — Project table with inline add-project form +- `routes/projects/[id]/+page.svelte` — Full project detail: stages, instances, deploy form, deploy history + +**Key decisions:** +- Used Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`) throughout +- Tailwind CSS v4 with `@tailwindcss/vite` plugin (no PostCSS config needed) +- Client-side only rendering (SSR disabled) for static adapter compatibility +- API client uses relative `/api/` paths — works in both dev (vite proxy) and prod (embedded) +- All API calls include loading spinners and error states with retry buttons + +**Ready for Phase 10:** Settings pages, Quick Deploy page, and remaining UI routes. The API client already includes all endpoint wrappers needed. diff --git a/web.go b/web.go new file mode 100644 index 0000000..5d23f87 --- /dev/null +++ b/web.go @@ -0,0 +1,9 @@ +package dockerwatcher + +import "embed" + +// WebBuildFS holds the embedded SvelteKit static build output. +// The build directory is populated by running `npm run build` in the web/ directory. +// +//go:embed all:web/build +var WebBuildFS embed.FS diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..025f5d4 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3225 @@ +{ + "name": "docker-watcher-web", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "docker-watcher-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "dev": true, + "optional": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "dev": true, + "optional": true + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "requires": {} + }, + "@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "requires": {} + }, + "@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + } + }, + "@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "requires": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + } + }, + "@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "requires": { + "debug": "^4.3.7" + } + }, + "@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "requires": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "requires": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + } + }, + "@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "dev": true, + "optional": true + }, + "@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "dev": true, + "optional": true + }, + "@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "requires": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + } + }, + "@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true + }, + "acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true + }, + "aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true + }, + "axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true + }, + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "requires": { + "readdirp": "^4.0.1" + } + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true + }, + "cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true + }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true + }, + "devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + } + }, + "esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "requires": { + "@types/estree": "^1.0.6" + } + }, + "jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true + }, + "kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true + }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "requires": { + "detect-libc": "^2.0.3", + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "dev": true, + "optional": true + }, + "lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "dev": true, + "optional": true + }, + "lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "dev": true, + "optional": true + }, + "locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true + }, + "mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true + }, + "rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "requires": { + "mri": "^1.1.0" + } + }, + "set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true + }, + "sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + } + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "dev": true, + "requires": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + } + }, + "svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + } + }, + "tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true + }, + "tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + } + }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "requires": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "fsevents": "~2.3.3", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + } + }, + "vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "requires": {} + }, + "zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7f1a9df --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "docker-watcher-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + }, + "type": "module" +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..a8e7941 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,66 @@ +@import 'tailwindcss'; +@import '$lib/styles/tokens.css'; + +/* ── Base Styles ──────────────────────────────────────────────────── */ + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + font-family: var(--font-family-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--surface-page); + color: var(--text-primary); +} + +/* Hide default number input spinners — use a cleaner look */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { + -moz-appearance: textfield; +} + +/* Screen reader only helper */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* ── Focus Ring Utility ───────────────────────────────────────────── */ + +.focus-ring:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +/* ── Custom Scrollbar ─────────────────────────────────────────────── */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..9f912e4 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,336 @@ +import type { + ApiEnvelope, + Deploy, + DeployLog, + InspectResult, + Instance, + Project, + ProjectDetail, + Registry, + RegistryImage, + Settings, + Stage, + StageEnv, + Volume +} from './types'; + +// ── Helpers ───────────────────────────────────────────────────────── + +class ApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} + +function getAuthToken(): string | null { + if (typeof localStorage !== 'undefined') { + return localStorage.getItem('auth_token'); + } + return null; +} + +async function request(path: string, init?: RequestInit): Promise { + const token = getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(init?.headers as Record) + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(path, { + ...init, + headers + }); + + // Redirect to login on 401 (expired/missing token). + if (res.status === 401 && typeof window !== 'undefined' && !path.includes('/auth/')) { + localStorage.removeItem('auth_token'); + window.location.href = '/login'; + throw new ApiError('Authentication required', 401); + } + + let envelope: ApiEnvelope; + try { + envelope = await res.json(); + } catch { + throw new ApiError( + `Server returned non-JSON response (HTTP ${res.status})`, + res.status + ); + } + + if (!envelope.success) { + throw new ApiError(envelope.error ?? 'Unknown API error', res.status); + } + + return envelope.data as T; +} + +function get(path: string): Promise { + return request(path); +} + +function post(path: string, body?: unknown): Promise { + return request(path, { + method: 'POST', + body: body !== undefined ? JSON.stringify(body) : undefined + }); +} + +function put(path: string, body: unknown): Promise { + return request(path, { + method: 'PUT', + body: JSON.stringify(body) + }); +} + +function del(path: string): Promise { + return request(path, { method: 'DELETE' }); +} + +// ── Projects ──────────────────────────────────────────────────────── + +export function listProjects(): Promise { + return get('/api/projects'); +} + +export function getProject(id: string): Promise { + return get(`/api/projects/${id}`); +} + +export function createProject(data: Partial): Promise { + return post('/api/projects', data); +} + +export function updateProject(id: string, data: Partial): Promise { + return put(`/api/projects/${id}`, data); +} + +export function deleteProject(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${id}`); +} + +// ── Stages ───────────────────────────────────────────────────────── + +export function createStage(projectId: string, data: Partial): Promise { + return post(`/api/projects/${projectId}/stages`, data); +} + +export function updateStage(projectId: string, stageId: string, data: Partial): Promise { + return put(`/api/projects/${projectId}/stages/${stageId}`, data); +} + +export function deleteStage(projectId: string, stageId: string): Promise { + return del(`/api/projects/${projectId}/stages/${stageId}`); +} + +// ── Instances ─────────────────────────────────────────────────────── + +export function listInstances(projectId: string, stageId: string): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/instances`); +} + +export function deployInstance( + projectId: string, + stageId: string, + imageTag: string +): Promise<{ status: string }> { + return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, { + image_tag: imageTag + }); +} + +export function removeInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}` + ); +} + +export function stopInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop` + ); +} + +export function startInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start` + ); +} + +export function restartInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart` + ); +} + +// ── Deploys ───────────────────────────────────────────────────────── + +export function listDeploys(limit = 50): Promise { + return get(`/api/deploys?limit=${limit}`); +} + +export function getDeployLogs(deployId: string): Promise { + return get(`/api/deploys/${deployId}/logs`); +} + +export function inspectImage(image: string): Promise { + return post('/api/deploy/inspect', { image }); +} + +export function quickDeploy(data: { + name?: string; + image: string; + tag?: string; + registry?: string; + port?: number; +}): Promise<{ project: Project; status: string }> { + return post<{ project: Project; status: string }>('/api/deploy/quick', data); +} + +// ── Registries ────────────────────────────────────────────────────── + +export function listRegistries(): Promise { + return get('/api/registries'); +} + +export function createRegistry(data: Partial): Promise { + return post('/api/registries', data); +} + +export function updateRegistry(id: string, data: Partial): Promise { + return put(`/api/registries/${id}`, data); +} + +export function deleteRegistry(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/registries/${id}`); +} + +export function testRegistry(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/registries/${id}/test`); +} + +export function listRegistryTags(registryId: string, image: string): Promise { + return get(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`); +} + +export function listRegistryImages(registryId: string): Promise { + return get(`/api/registries/${registryId}/images`); +} + +// ── Settings ──────────────────────────────────────────────────────── + +export function getSettings(): Promise { + return get('/api/settings'); +} + +export function updateSettings(data: Partial): Promise { + return put('/api/settings', data); +} + +export function getWebhookUrl(): Promise<{ url: string }> { + return get<{ url: string }>('/api/settings/webhook-url'); +} + +export function regenerateWebhookUrl(): Promise<{ url: string }> { + return post<{ url: string }>('/api/settings/webhook-url/regenerate'); +} + +// ── Auth ───────────────────────────────────────────────────────────── + +export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> { + return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password }); +} + +export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> { + return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me'); +} + +// ── Config Export ──────────────────────────────────────────────────── + +export function exportConfigUrl(): string { + return '/api/config/export'; +} + +// ── Stage Env Overrides ────────────────────────────────────────────── + +export function listStageEnv(projectId: string, stageId: string): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/env`); +} + +export function createStageEnv( + projectId: string, + stageId: string, + data: { key: string; value: string; encrypted?: boolean } +): Promise { + return post(`/api/projects/${projectId}/stages/${stageId}/env`, data); +} + +export function updateStageEnv( + projectId: string, + stageId: string, + envId: string, + data: { key?: string; value?: string; encrypted?: boolean } +): Promise { + return put(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data); +} + +export function deleteStageEnv( + projectId: string, + stageId: string, + envId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`); +} + +// ── Volumes ────────────────────────────────────────────────────────── + +export function listVolumes(projectId: string): Promise { + return get(`/api/projects/${projectId}/volumes`); +} + +export function createVolume( + projectId: string, + data: { source: string; target: string; mode?: string } +): Promise { + return post(`/api/projects/${projectId}/volumes`, data); +} + +export function updateVolume( + projectId: string, + volId: string, + data: { source?: string; target?: string; mode?: string } +): Promise { + return put(`/api/projects/${projectId}/volumes/${volId}`, data); +} + +export function deleteVolume( + projectId: string, + volId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); +} + +export { ApiError }; diff --git a/web/src/lib/components/ConfirmDialog.svelte b/web/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..469b95c --- /dev/null +++ b/web/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,82 @@ + + + +{#if open} + + + + +
+
+
+
+ +
+
+

{title}

+

{message}

+
+
+ +
+ + +
+
+
+{/if} diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..2704215 --- /dev/null +++ b/web/src/lib/components/EmptyState.svelte @@ -0,0 +1,85 @@ + + + +
+ +
+ {#if icon === 'projects'} + + + + + {:else if icon === 'instances'} + + + + {:else if icon === 'deploys'} + + + + {:else if icon === 'registries'} + + + + {:else if icon === 'volumes'} + + + + {:else if icon === 'users'} + + + + {/if} +
+ +

{title}

+ + {#if description} +

{description}

+ {/if} + + {#if actionLabel} + {#if actionHref} + + + + + {actionLabel} + + {:else if onaction} + + {/if} + {/if} +
diff --git a/web/src/lib/components/EntityPicker.svelte b/web/src/lib/components/EntityPicker.svelte new file mode 100644 index 0000000..b098e89 --- /dev/null +++ b/web/src/lib/components/EntityPicker.svelte @@ -0,0 +1,443 @@ + + + +{#if open} + + +
+ + + +
+ +
+{/if} + + diff --git a/web/src/lib/components/FormField.svelte b/web/src/lib/components/FormField.svelte new file mode 100644 index 0000000..a46e260 --- /dev/null +++ b/web/src/lib/components/FormField.svelte @@ -0,0 +1,78 @@ + + + +
+ + + {#if type === 'textarea'} + + {:else} + + {/if} + + {#if error} +

{error}

+ {/if} + + {#if helpText && !error} +

{helpText}

+ {/if} +
diff --git a/web/src/lib/components/InstanceCard.svelte b/web/src/lib/components/InstanceCard.svelte new file mode 100644 index 0000000..d26f956 --- /dev/null +++ b/web/src/lib/components/InstanceCard.svelte @@ -0,0 +1,157 @@ + + + +
+
+
+
+ + {instance.image_tag} + + +
+ + {#if subdomainUrl} + + {instance.subdomain} + + + {/if} + +
+ :{instance.port} + {timeSinceCreated()} +
+
+ + +
+ {#if instance.status === 'running'} + + + {:else if instance.status === 'stopped'} + + {/if} + +
+
+ + {#if error} +

{error}

+ {/if} +
+ + { if (confirmAction) handleAction(confirmAction); }} + oncancel={() => { confirmAction = null; }} +/> diff --git a/web/src/lib/components/LocaleSwitcher.svelte b/web/src/lib/components/LocaleSwitcher.svelte new file mode 100644 index 0000000..ff54ad0 --- /dev/null +++ b/web/src/lib/components/LocaleSwitcher.svelte @@ -0,0 +1,25 @@ + + + +
+ +
+ {#each availableLocales as loc} + + {/each} +
+
diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte new file mode 100644 index 0000000..d65fcee --- /dev/null +++ b/web/src/lib/components/ProjectCard.svelte @@ -0,0 +1,83 @@ + + + + +
+
+
+
+ +
+

{project.name}

+
+

{project.image}

+
+ +
+ + +
+ {#if totalCount > 0} + + + {runningCount} + + {#if stoppedCount > 0} + + + {stoppedCount} + + {/if} + {#if failedCount > 0} + + + {failedCount} + + {/if} + + {totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')} + + {:else} + {$t('projectDetail.noInstancesRunning')} + {/if} +
+ + +
+ {#if project.port} + :{project.port} + {/if} + {#if project.healthcheck} + {project.healthcheck} + {/if} +
+
diff --git a/web/src/lib/components/Skeleton.svelte b/web/src/lib/components/Skeleton.svelte new file mode 100644 index 0000000..2e6e0d4 --- /dev/null +++ b/web/src/lib/components/Skeleton.svelte @@ -0,0 +1,25 @@ + + + + diff --git a/web/src/lib/components/SkeletonCard.svelte b/web/src/lib/components/SkeletonCard.svelte new file mode 100644 index 0000000..41105af --- /dev/null +++ b/web/src/lib/components/SkeletonCard.svelte @@ -0,0 +1,24 @@ + + + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
diff --git a/web/src/lib/components/SkeletonTable.svelte b/web/src/lib/components/SkeletonTable.svelte new file mode 100644 index 0000000..f6faf6e --- /dev/null +++ b/web/src/lib/components/SkeletonTable.svelte @@ -0,0 +1,30 @@ + + + +
+
+
+ {#each Array(cols) as _, i} + + {/each} +
+
+ {#each Array(rows) as _, i} +
+ {#each Array(cols) as _, j} + + {/each} +
+ {/each} +
diff --git a/web/src/lib/components/StatusBadge.svelte b/web/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..90e6140 --- /dev/null +++ b/web/src/lib/components/StatusBadge.svelte @@ -0,0 +1,47 @@ + + + + + + {#if isAnimated} + + {/if} + + + {label} + diff --git a/web/src/lib/components/ThemeToggle.svelte b/web/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..823ba63 --- /dev/null +++ b/web/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,32 @@ + + + +
+ {#each modes as mode} + + {/each} +
diff --git a/web/src/lib/components/Toast.svelte b/web/src/lib/components/Toast.svelte new file mode 100644 index 0000000..52af77f --- /dev/null +++ b/web/src/lib/components/Toast.svelte @@ -0,0 +1,43 @@ + + + +
+ {#each $toasts as toast (toast.id)} + + {/each} +
diff --git a/web/src/lib/components/ToggleSwitch.svelte b/web/src/lib/components/ToggleSwitch.svelte new file mode 100644 index 0000000..242bb8a --- /dev/null +++ b/web/src/lib/components/ToggleSwitch.svelte @@ -0,0 +1,35 @@ + + + + diff --git a/web/src/lib/components/icons/IconAlert.svelte b/web/src/lib/components/icons/IconAlert.svelte new file mode 100644 index 0000000..39e5eb5 --- /dev/null +++ b/web/src/lib/components/icons/IconAlert.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconBox.svelte b/web/src/lib/components/icons/IconBox.svelte new file mode 100644 index 0000000..e97cedc --- /dev/null +++ b/web/src/lib/components/icons/IconBox.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconCheck.svelte b/web/src/lib/components/icons/IconCheck.svelte new file mode 100644 index 0000000..fde21a5 --- /dev/null +++ b/web/src/lib/components/icons/IconCheck.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconChevronRight.svelte b/web/src/lib/components/icons/IconChevronRight.svelte new file mode 100644 index 0000000..94e3c3d --- /dev/null +++ b/web/src/lib/components/icons/IconChevronRight.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconClock.svelte b/web/src/lib/components/icons/IconClock.svelte new file mode 100644 index 0000000..e137973 --- /dev/null +++ b/web/src/lib/components/icons/IconClock.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconContainer.svelte b/web/src/lib/components/icons/IconContainer.svelte new file mode 100644 index 0000000..a4d7248 --- /dev/null +++ b/web/src/lib/components/icons/IconContainer.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconCopy.svelte b/web/src/lib/components/icons/IconCopy.svelte new file mode 100644 index 0000000..43d0d8a --- /dev/null +++ b/web/src/lib/components/icons/IconCopy.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconDashboard.svelte b/web/src/lib/components/icons/IconDashboard.svelte new file mode 100644 index 0000000..6117c6a --- /dev/null +++ b/web/src/lib/components/icons/IconDashboard.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconDatabase.svelte b/web/src/lib/components/icons/IconDatabase.svelte new file mode 100644 index 0000000..5bf5309 --- /dev/null +++ b/web/src/lib/components/icons/IconDatabase.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconDeploy.svelte b/web/src/lib/components/icons/IconDeploy.svelte new file mode 100644 index 0000000..6bb9474 --- /dev/null +++ b/web/src/lib/components/icons/IconDeploy.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconEdit.svelte b/web/src/lib/components/icons/IconEdit.svelte new file mode 100644 index 0000000..c82ad94 --- /dev/null +++ b/web/src/lib/components/icons/IconEdit.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconExternalLink.svelte b/web/src/lib/components/icons/IconExternalLink.svelte new file mode 100644 index 0000000..5a96e1b --- /dev/null +++ b/web/src/lib/components/icons/IconExternalLink.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconGlobe.svelte b/web/src/lib/components/icons/IconGlobe.svelte new file mode 100644 index 0000000..b6fdede --- /dev/null +++ b/web/src/lib/components/icons/IconGlobe.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconHardDrive.svelte b/web/src/lib/components/icons/IconHardDrive.svelte new file mode 100644 index 0000000..94f4867 --- /dev/null +++ b/web/src/lib/components/icons/IconHardDrive.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconInfo.svelte b/web/src/lib/components/icons/IconInfo.svelte new file mode 100644 index 0000000..ae256c3 --- /dev/null +++ b/web/src/lib/components/icons/IconInfo.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconKey.svelte b/web/src/lib/components/icons/IconKey.svelte new file mode 100644 index 0000000..a549f39 --- /dev/null +++ b/web/src/lib/components/icons/IconKey.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconLoader.svelte b/web/src/lib/components/icons/IconLoader.svelte new file mode 100644 index 0000000..20e7957 --- /dev/null +++ b/web/src/lib/components/icons/IconLoader.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconLock.svelte b/web/src/lib/components/icons/IconLock.svelte new file mode 100644 index 0000000..c11f2aa --- /dev/null +++ b/web/src/lib/components/icons/IconLock.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconMenu.svelte b/web/src/lib/components/icons/IconMenu.svelte new file mode 100644 index 0000000..cf853c4 --- /dev/null +++ b/web/src/lib/components/icons/IconMenu.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconMonitor.svelte b/web/src/lib/components/icons/IconMonitor.svelte new file mode 100644 index 0000000..1a5356f --- /dev/null +++ b/web/src/lib/components/icons/IconMonitor.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconMoon.svelte b/web/src/lib/components/icons/IconMoon.svelte new file mode 100644 index 0000000..fd6f87a --- /dev/null +++ b/web/src/lib/components/icons/IconMoon.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconPlay.svelte b/web/src/lib/components/icons/IconPlay.svelte new file mode 100644 index 0000000..0151a4a --- /dev/null +++ b/web/src/lib/components/icons/IconPlay.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconPlus.svelte b/web/src/lib/components/icons/IconPlus.svelte new file mode 100644 index 0000000..11a74f8 --- /dev/null +++ b/web/src/lib/components/icons/IconPlus.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconProjects.svelte b/web/src/lib/components/icons/IconProjects.svelte new file mode 100644 index 0000000..6d87904 --- /dev/null +++ b/web/src/lib/components/icons/IconProjects.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconRefresh.svelte b/web/src/lib/components/icons/IconRefresh.svelte new file mode 100644 index 0000000..78e7bce --- /dev/null +++ b/web/src/lib/components/icons/IconRefresh.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconRestart.svelte b/web/src/lib/components/icons/IconRestart.svelte new file mode 100644 index 0000000..78e7bce --- /dev/null +++ b/web/src/lib/components/icons/IconRestart.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconSearch.svelte b/web/src/lib/components/icons/IconSearch.svelte new file mode 100644 index 0000000..179871c --- /dev/null +++ b/web/src/lib/components/icons/IconSearch.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconServer.svelte b/web/src/lib/components/icons/IconServer.svelte new file mode 100644 index 0000000..64b1eba --- /dev/null +++ b/web/src/lib/components/icons/IconServer.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconSettings.svelte b/web/src/lib/components/icons/IconSettings.svelte new file mode 100644 index 0000000..9fbc1e9 --- /dev/null +++ b/web/src/lib/components/icons/IconSettings.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconShield.svelte b/web/src/lib/components/icons/IconShield.svelte new file mode 100644 index 0000000..25b9136 --- /dev/null +++ b/web/src/lib/components/icons/IconShield.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconStop.svelte b/web/src/lib/components/icons/IconStop.svelte new file mode 100644 index 0000000..7f139f2 --- /dev/null +++ b/web/src/lib/components/icons/IconStop.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconSun.svelte b/web/src/lib/components/icons/IconSun.svelte new file mode 100644 index 0000000..067b1bd --- /dev/null +++ b/web/src/lib/components/icons/IconSun.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconTag.svelte b/web/src/lib/components/icons/IconTag.svelte new file mode 100644 index 0000000..1937f3c --- /dev/null +++ b/web/src/lib/components/icons/IconTag.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconTrash.svelte b/web/src/lib/components/icons/IconTrash.svelte new file mode 100644 index 0000000..ffcbdc8 --- /dev/null +++ b/web/src/lib/components/icons/IconTrash.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconUnlock.svelte b/web/src/lib/components/icons/IconUnlock.svelte new file mode 100644 index 0000000..1b6bf39 --- /dev/null +++ b/web/src/lib/components/icons/IconUnlock.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconUser.svelte b/web/src/lib/components/icons/IconUser.svelte new file mode 100644 index 0000000..3de2276 --- /dev/null +++ b/web/src/lib/components/icons/IconUser.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconUsers.svelte b/web/src/lib/components/icons/IconUsers.svelte new file mode 100644 index 0000000..0bfd32f --- /dev/null +++ b/web/src/lib/components/icons/IconUsers.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconWifi.svelte b/web/src/lib/components/icons/IconWifi.svelte new file mode 100644 index 0000000..7408972 --- /dev/null +++ b/web/src/lib/components/icons/IconWifi.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconX.svelte b/web/src/lib/components/icons/IconX.svelte new file mode 100644 index 0000000..7e28eb8 --- /dev/null +++ b/web/src/lib/components/icons/IconX.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/index.ts b/web/src/lib/components/icons/index.ts new file mode 100644 index 0000000..a38f759 --- /dev/null +++ b/web/src/lib/components/icons/index.ts @@ -0,0 +1,47 @@ +/** + * Lucide-based SVG icon components for Docker Watcher. + * Task 2: Inline SVGs from Lucide icon set as Svelte components. + * + * Each icon is a standalone .svelte component accepting size and class props. + * Re-exported here for convenient imports. + */ + +export { default as IconDashboard } from './IconDashboard.svelte'; +export { default as IconProjects } from './IconProjects.svelte'; +export { default as IconDeploy } from './IconDeploy.svelte'; +export { default as IconSettings } from './IconSettings.svelte'; +export { default as IconPlay } from './IconPlay.svelte'; +export { default as IconStop } from './IconStop.svelte'; +export { default as IconRestart } from './IconRestart.svelte'; +export { default as IconTrash } from './IconTrash.svelte'; +export { default as IconPlus } from './IconPlus.svelte'; +export { default as IconCheck } from './IconCheck.svelte'; +export { default as IconX } from './IconX.svelte'; +export { default as IconAlert } from './IconAlert.svelte'; +export { default as IconInfo } from './IconInfo.svelte'; +export { default as IconChevronRight } from './IconChevronRight.svelte'; +export { default as IconExternalLink } from './IconExternalLink.svelte'; +export { default as IconCopy } from './IconCopy.svelte'; +export { default as IconSearch } from './IconSearch.svelte'; +export { default as IconSun } from './IconSun.svelte'; +export { default as IconMoon } from './IconMoon.svelte'; +export { default as IconMonitor } from './IconMonitor.svelte'; +export { default as IconMenu } from './IconMenu.svelte'; +export { default as IconGlobe } from './IconGlobe.svelte'; +export { default as IconKey } from './IconKey.svelte'; +export { default as IconShield } from './IconShield.svelte'; +export { default as IconServer } from './IconServer.svelte'; +export { default as IconDatabase } from './IconDatabase.svelte'; +export { default as IconBox } from './IconBox.svelte'; +export { default as IconLoader } from './IconLoader.svelte'; +export { default as IconTag } from './IconTag.svelte'; +export { default as IconClock } from './IconClock.svelte'; +export { default as IconEdit } from './IconEdit.svelte'; +export { default as IconLock } from './IconLock.svelte'; +export { default as IconUnlock } from './IconUnlock.svelte'; +export { default as IconUser } from './IconUser.svelte'; +export { default as IconUsers } from './IconUsers.svelte'; +export { default as IconContainer } from './IconContainer.svelte'; +export { default as IconHardDrive } from './IconHardDrive.svelte'; +export { default as IconWifi } from './IconWifi.svelte'; +export { default as IconRefresh } from './IconRefresh.svelte'; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json new file mode 100644 index 0000000..fdf49ed --- /dev/null +++ b/web/src/lib/i18n/en.json @@ -0,0 +1,387 @@ +{ + "app": { + "name": "Docker Watcher", + "version": "v0.1" + }, + "nav": { + "dashboard": "Dashboard", + "projects": "Projects", + "deploy": "Deploy", + "settings": "Settings" + }, + "dashboard": { + "title": "Dashboard", + "quickDeploy": "Quick Deploy", + "totalProjects": "Total Projects", + "runningInstances": "Running Instances", + "failedInstances": "Failed Instances", + "projects": "Projects", + "retry": "Retry", + "noProjects": "No projects yet.", + "addFirst": "Add your first project", + "loadFailed": "Failed to load dashboard" + }, + "projects": { + "title": "Projects", + "addProject": "Add Project", + "cancel": "Cancel", + "newProject": "New Project", + "name": "Name", + "image": "Image", + "port": "Port", + "registry": "Registry", + "created": "Created", + "view": "View", + "noProjects": "No projects configured yet.", + "getStarted": "Click \"Add Project\" to get started.", + "createProject": "Create Project", + "creating": "Creating...", + "healthcheck": "Healthcheck Path", + "nameRequired": "Name and image are required.", + "loadFailed": "Failed to load projects", + "createFailed": "Failed to create project", + "browseImages": "Browse Images", + "selectImage": "Select an image", + "noImages": "No images found", + "loadingImages": "Loading images...", + "imageLoadFailed": "Failed to load images" + }, + "projectDetail": { + "deleteProject": "Delete Project", + "envVars": "Environment Variables", + "volumes": "Volume Mounts", + "stages": "Stages", + "noStages": "No stages configured for this project.", + "pattern": "Pattern", + "autoDeploy": "auto-deploy", + "requiresConfirm": "requires confirm", + "instances": "instances", + "deployNewVersion": "Deploy new version", + "selectTag": "Select tag to deploy", + "loadingTags": "Loading tags...", + "chooseTag": "Choose a tag...", + "enterTag": "Enter image tag (e.g., dev-abc123)", + "deploy": "Deploy", + "deploying": "Deploying...", + "recentDeploys": "Recent Deploys", + "noDeployHistory": "No deploy history for this project.", + "tag": "Tag", + "status": "Status", + "started": "Started", + "finished": "Finished", + "error": "Error", + "noInstancesRunning": "No instances running", + "deleteConfirmTitle": "Delete Project", + "deleteConfirmMessage": "This will permanently delete the project '{name}' and all its stages, instances, and deploy history. This cannot be undone.", + "loadFailed": "Failed to load project", + "deleteFailed": "Failed to delete project", + "deployFailed": "Deploy failed" + }, + "envEditor": { + "title": "Environment Variables", + "description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.", + "stage": "Stage", + "projectDefaults": "Project-Level Defaults", + "stageOverrides": "Stage Overrides", + "key": "Key", + "value": "Value", + "secret": "Secret", + "source": "Source", + "actions": "Actions", + "overridden": "overridden", + "inherited": "inherited", + "overridesProject": "overrides project", + "stageOnly": "stage only", + "edit": "Edit", + "change": "Change", + "delete": "Delete", + "save": "Save", + "add": "Add", + "adding": "Adding...", + "noStages": "No stages configured. Add stages to the project first.", + "loadFailed": "Failed to load project", + "envAdded": "Environment variable added", + "envUpdated": "Environment variable updated", + "envDeleted": "Environment variable deleted", + "addFailed": "Failed to add env var", + "updateFailed": "Failed to update env var", + "deleteFailed": "Failed to delete env var", + "loadEnvFailed": "Failed to load env vars" + }, + "volumeEditor": { + "title": "Volume Mounts", + "description": "Configure volume mounts for containers.", + "sharedDesc": "Shared mode uses the source path as-is for all instances.", + "isolatedDesc": "Isolated mode appends /{stage}-{tag}/ to the source, giving each instance its own directory.", + "sourceHost": "Source (Host)", + "targetContainer": "Target (Container)", + "mode": "Mode", + "actions": "Actions", + "shared": "Shared", + "isolated": "Isolated", + "edit": "Edit", + "delete": "Delete", + "save": "Save", + "add": "Add", + "adding": "Adding...", + "noVolumes": "No volumes configured yet. Add one above.", + "volumeAdded": "Volume added", + "volumeUpdated": "Volume updated", + "volumeDeleted": "Volume deleted", + "loadFailed": "Failed to load volumes", + "addFailed": "Failed to add volume", + "updateFailed": "Failed to update volume", + "deleteFailed": "Failed to delete volume" + }, + "quickDeploy": { + "title": "Quick Deploy", + "description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.", + "step1": "1. Enter Image URL", + "imageUrl": "Image URL", + "imageUrlHelp": "Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)", + "inspect": "Inspect", + "inspecting": "Inspecting...", + "step2": "2. Review Configuration", + "reviewDesc": "These defaults were detected from the image. Adjust as needed before deploying.", + "projectName": "Project Name", + "port": "Port", + "portHelp": "Container port to expose (1-65535)", + "healthCheckPath": "Health Check Path", + "healthCheckHelp": "Optional HTTP path for health verification", + "stage": "Stage", + "development": "Development", + "release": "Release", + "production": "Production", + "stageHelp": "Deployment stage for this image", + "subdomainOverride": "Subdomain Override", + "subdomainHelp": "Leave empty to use the default subdomain pattern", + "envVars": "Environment Variables", + "envVarsHelp": "One per line, KEY=VALUE format", + "step3": "3. Deploy", + "deployDesc": "A new project will be created and the container will be deployed immediately.", + "deployBtn": "Deploy", + "inspectedSuccess": "Image inspected successfully", + "deployedSuccess": "Deployed {name} successfully!", + "inspectFailed": "Failed to inspect image", + "deployFailed": "Deployment failed", + "browseImages": "Browse", + "selectImage": "Select an image from a registry", + "noImages": "No images found", + "loadingImages": "Loading...", + "imageLoadFailed": "Failed to load images" + }, + "settings": { + "title": "Settings", + "general": "General", + "registries": "Registries", + "credentials": "Credentials", + "authentication": "Authentication", + "appearance": "Appearance" + }, + "settingsGeneral": { + "title": "General Settings", + "globalConfig": "Global Configuration", + "domain": "Domain", + "domainHelp": "Base domain for subdomain routing", + "serverIp": "Server IP", + "serverIpHelp": "Public IP address of the server", + "dockerNetwork": "Docker Network", + "dockerNetworkHelp": "Docker network for deployed containers", + "subdomainPattern": "Subdomain Pattern", + "subdomainPatternHelp": "Pattern for auto-generated subdomains", + "pollingInterval": "Polling Interval (seconds)", + "pollingIntervalHelp": "How often to check registries for new tags (10-86400)", + "notificationUrl": "Notification URL", + "notificationUrlHelp": "Webhook URL for deploy notifications", + "saveSettings": "Save Settings", + "saving": "Saving...", + "saved": "Settings saved successfully", + "saveFailed": "Failed to save settings", + "loadFailed": "Failed to load settings", + "webhookUrl": "Webhook URL", + "webhookDesc": "This secret URL receives image push notifications from your CI pipeline.", + "noWebhookUrl": "No webhook URL configured", + "copy": "Copy", + "copied": "Webhook URL copied to clipboard", + "regenerateUrl": "Regenerate URL", + "regenerating": "Regenerating...", + "regenerated": "Webhook URL regenerated", + "regenerateFailed": "Failed to regenerate webhook URL", + "regenerateWarning": "Warning: regenerating will invalidate the current URL. Update your CI pipelines." + }, + "settingsRegistries": { + "title": "Container Registries", + "description": "Manage your container registries for image detection.", + "addRegistry": "Add Registry", + "editRegistry": "Edit Registry", + "addNewRegistry": "Add New Registry", + "name": "Name", + "nameHelp": "A friendly name for this registry", + "url": "URL", + "urlHelp": "Registry base URL", + "type": "Type", + "typeHelp": "Registry type for API compatibility", + "token": "Token", + "tokenHelpNew": "API token for authentication", + "tokenHelpEdit": "Leave empty to keep the existing token", + "owner": "Owner", + "ownerHelp": "Package owners, comma-separated (e.g., alexei,my-org)", + "save": "Save", + "saving": "Saving...", + "update": "Update", + "test": "Test", + "testing": "Testing...", + "edit": "Edit", + "delete": "Delete", + "noRegistries": "No registries configured yet.", + "addFirst": "Add your first registry", + "registryUpdated": "Registry updated", + "registryAdded": "Registry added", + "registryDeleted": "Registry \"{name}\" deleted", + "testSuccess": "Connection to \"{name}\" successful", + "saveFailed": "Failed to save registry", + "deleteFailed": "Failed to delete registry", + "testFailed": "Connection test failed", + "loadFailed": "Failed to load registries", + "deleteConfirm": "Delete registry \"{name}\"? This cannot be undone." + }, + "settingsCredentials": { + "title": "Credentials", + "description": "Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at rest.", + "npm": "Nginx Proxy Manager", + "npmDesc": "Credentials for managing proxy hosts via NPM API", + "configured": "Configured", + "npmUrl": "NPM URL", + "npmUrlHelp": "Nginx Proxy Manager API URL", + "email": "Email", + "emailHelp": "NPM admin email", + "password": "Password", + "passwordHelpNew": "NPM admin password (will be encrypted)", + "passwordHelpEdit": "Enter the new password to replace the existing one", + "changeCredentials": "Change Credentials", + "save": "Save", + "saving": "Saving...", + "saved": "NPM credentials saved", + "saveFailed": "Failed to save NPM credentials", + "loadFailed": "Failed to load credentials", + "registryTokens": "Registry Tokens", + "registryTokensDesc": "Registry authentication tokens are managed per-registry in the", + "registriesLink": "Registries", + "registryTokensSuffix": "section. Each registry stores its token encrypted in the database." + }, + "settingsAuth": { + "title": "Authentication Settings", + "description": "Configure authentication mode and manage users.", + "authMode": "Authentication Mode", + "local": "Local (username/password)", + "oidc": "OIDC (SSO)", + "oidcConfig": "OIDC Provider Configuration", + "issuerUrl": "Issuer URL", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "redirectUrl": "Redirect URL", + "saveSettings": "Save Settings", + "saving": "Saving...", + "saved": "Settings saved", + "saveFailed": "Failed to save", + "loadFailed": "Failed to load settings", + "localUsers": "Local Users", + "username": "Username", + "email": "Email", + "role": "Role", + "created": "Created", + "noUsers": "No users found.", + "addUser": "Add User", + "viewer": "Viewer", + "admin": "Admin", + "userCreated": "User created", + "userDeleted": "User deleted", + "createFailed": "Failed to create user", + "deleteFailed": "Failed to delete user", + "deleteConfirm": "Are you sure you want to delete this user?", + "usernameRequired": "Username and password are required" + }, + "login": { + "title": "Docker Watcher", + "subtitle": "Sign in to your account", + "username": "Username", + "password": "Password", + "signIn": "Sign in", + "signingIn": "Signing in...", + "or": "or", + "ssoButton": "Sign in with SSO (OIDC)", + "loginFailed": "Login failed", + "networkError": "Network error" + }, + "common": { + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "edit": "Edit", + "save": "Save", + "retry": "Retry", + "loading": "Loading...", + "noData": "No data", + "project": "Project", + "back": "Back", + "actions": "Actions", + "stop": "Stop", + "start": "Start", + "restart": "Restart", + "remove": "Remove", + "instance": "instance", + "instances": "instances" + }, + "instance": { + "stopConfirm": "This will stop the running container. The instance can be started again later.", + "restartConfirm": "This will restart the container, causing brief downtime.", + "removeConfirm": "This will permanently remove the container and its proxy configuration. This cannot be undone.", + "actionFailed": "Action failed" + }, + "empty": { + "noProjects": "No projects yet", + "noProjectsDesc": "Get started by creating your first project or use Quick Deploy.", + "createProject": "Create Project", + "noInstances": "No instances", + "noInstancesDesc": "Deploy a new version to see instances here.", + "noDeploys": "No deploy history", + "noDeploysDesc": "Deploy history will appear here after your first deployment.", + "noRegistries": "No registries", + "noRegistriesDesc": "Add a container registry to enable image detection.", + "noVolumes": "No volumes", + "noVolumesDesc": "Configure volume mounts for persistent data.", + "noUsers": "No users", + "noUsersDesc": "Add local users to manage access." + }, + "validation": { + "required": "{field} is required", + "invalidUrl": "Invalid URL format", + "invalidDomain": "Invalid domain format", + "invalidIp": "Invalid IP format", + "invalidEmail": "Invalid email format", + "invalidPort": "Port must be between 1 and 65535", + "invalidPollingInterval": "Polling interval must be between 10 and 86400 seconds", + "invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed", + "requiredWhenUpdating": "{field} is required when updating credentials", + "requiredForNew": "{field} is required for new registries" + }, + "confirm": { + "stopInstance": "Stop Instance", + "startInstance": "Start Instance", + "restartInstance": "Restart Instance", + "removeInstance": "Remove Instance" + }, + "theme": { + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "entityPicker": { + "search": "Search...", + "noResults": "No results found" + }, + "language": { + "en": "English", + "ru": "Russian" + } +} diff --git a/web/src/lib/i18n/index.ts b/web/src/lib/i18n/index.ts new file mode 100644 index 0000000..50f0896 --- /dev/null +++ b/web/src/lib/i18n/index.ts @@ -0,0 +1,84 @@ +/** + * Lightweight i18n system using Svelte stores. + * Task 13: EN/RU localization with locale switcher and localStorage persistence. + */ + +import { writable, derived } from 'svelte/store'; +import en from './en.json'; +import ru from './ru.json'; + +export type Locale = 'en' | 'ru'; + +const LOCALE_KEY = 'dw_locale'; + +const translations: Record> = { en, ru }; + +function getInitialLocale(): Locale { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(LOCALE_KEY); + if (stored === 'en' || stored === 'ru') return stored; + } + + if (typeof navigator !== 'undefined') { + const lang = navigator.language.toLowerCase(); + if (lang.startsWith('ru')) return 'ru'; + } + + return 'en'; +} + +export const locale = writable(getInitialLocale()); + +// Persist locale changes to localStorage. +locale.subscribe((value) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(LOCALE_KEY, value); + } +}); + +/** + * Look up a nested key like "dashboard.title" in the translation object. + */ +function getNestedValue(obj: Record, path: string): string { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return path; + } + current = (current as Record)[part]; + } + return typeof current === 'string' ? current : path; +} + +/** + * Derived store that returns a translation function. + * Usage: $t('dashboard.title') or $t('projectDetail.deleteConfirmMessage', { name: 'my-app' }) + */ +export const t = derived(locale, ($locale) => { + const dict = translations[$locale] ?? translations.en; + + return (key: string, params?: Record): string => { + let result = getNestedValue(dict as Record, key); + + // Fallback to English if key not found in current locale. + if (result === key) { + result = getNestedValue(translations.en as Record, key); + } + + // Replace {param} placeholders. + if (params) { + for (const [paramKey, paramValue] of Object.entries(params)) { + result = result.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), paramValue); + } + } + + return result; + }; +}); + +export function setLocale(newLocale: Locale): void { + locale.set(newLocale); +} + +export const availableLocales: readonly Locale[] = ['en', 'ru'] as const; diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json new file mode 100644 index 0000000..fe1a4c6 --- /dev/null +++ b/web/src/lib/i18n/ru.json @@ -0,0 +1,387 @@ +{ + "app": { + "name": "Docker Watcher", + "version": "v0.1" + }, + "nav": { + "dashboard": "Панель", + "projects": "Проекты", + "deploy": "Деплой", + "settings": "Настройки" + }, + "dashboard": { + "title": "Панель управления", + "quickDeploy": "Быстрый деплой", + "totalProjects": "Всего проектов", + "runningInstances": "Запущенных экземпляров", + "failedInstances": "Сбойных экземпляров", + "projects": "Проекты", + "retry": "Повторить", + "noProjects": "Проектов пока нет.", + "addFirst": "Добавьте первый проект", + "loadFailed": "Не удалось загрузить панель" + }, + "projects": { + "title": "Проекты", + "addProject": "Добавить проект", + "cancel": "Отмена", + "newProject": "Новый проект", + "name": "Название", + "image": "Образ", + "port": "Порт", + "registry": "Реестр", + "created": "Создан", + "view": "Открыть", + "noProjects": "Проекты ещё не настроены.", + "getStarted": "Нажмите «Добавить проект» для начала.", + "createProject": "Создать проект", + "creating": "Создание...", + "healthcheck": "Путь проверки здоровья", + "nameRequired": "Название и образ обязательны.", + "loadFailed": "Не удалось загрузить проекты", + "createFailed": "Не удалось создать проект", + "browseImages": "Обзор образов", + "selectImage": "Выберите образ", + "noImages": "Образы не найдены", + "loadingImages": "Загрузка образов...", + "imageLoadFailed": "Не удалось загрузить образы" + }, + "projectDetail": { + "deleteProject": "Удалить проект", + "envVars": "Переменные окружения", + "volumes": "Тома", + "stages": "Стадии", + "noStages": "Для этого проекта не настроены стадии.", + "pattern": "Шаблон", + "autoDeploy": "авто-деплой", + "requiresConfirm": "нужно подтверждение", + "instances": "экземпляров", + "deployNewVersion": "Развернуть новую версию", + "selectTag": "Выберите тег для деплоя", + "loadingTags": "Загрузка тегов...", + "chooseTag": "Выберите тег...", + "enterTag": "Введите тег образа (напр., dev-abc123)", + "deploy": "Развернуть", + "deploying": "Развёртывание...", + "recentDeploys": "Последние деплои", + "noDeployHistory": "Нет истории деплоев для этого проекта.", + "tag": "Тег", + "status": "Статус", + "started": "Начат", + "finished": "Завершён", + "error": "Ошибка", + "noInstancesRunning": "Нет запущенных экземпляров", + "deleteConfirmTitle": "Удалить проект", + "deleteConfirmMessage": "Это безвозвратно удалит проект '{name}' и все его стадии, экземпляры и историю деплоев.", + "loadFailed": "Не удалось загрузить проект", + "deleteFailed": "Не удалось удалить проект", + "deployFailed": "Деплой не удался" + }, + "envEditor": { + "title": "Переменные окружения", + "description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.", + "stage": "Стадия", + "projectDefaults": "Значения проекта по умолчанию", + "stageOverrides": "Переопределения стадии", + "key": "Ключ", + "value": "Значение", + "secret": "Секрет", + "source": "Источник", + "actions": "Действия", + "overridden": "переопределено", + "inherited": "наследуется", + "overridesProject": "переопределяет проект", + "stageOnly": "только стадия", + "edit": "Изменить", + "change": "Изменить", + "delete": "Удалить", + "save": "Сохранить", + "add": "Добавить", + "adding": "Добавление...", + "noStages": "Стадии не настроены. Сначала добавьте стадии к проекту.", + "loadFailed": "Не удалось загрузить проект", + "envAdded": "Переменная окружения добавлена", + "envUpdated": "Переменная окружения обновлена", + "envDeleted": "Переменная окружения удалена", + "addFailed": "Не удалось добавить переменную", + "updateFailed": "Не удалось обновить переменную", + "deleteFailed": "Не удалось удалить переменную", + "loadEnvFailed": "Не удалось загрузить переменные" + }, + "volumeEditor": { + "title": "Тома", + "description": "Настройка монтирования томов для контейнеров.", + "sharedDesc": "Режим «Общий» использует путь источника как есть для всех экземпляров.", + "isolatedDesc": "Режим «Изолированный» добавляет /{stage}-{tag}/ к источнику, создавая свою директорию для каждого экземпляра.", + "sourceHost": "Источник (хост)", + "targetContainer": "Цель (контейнер)", + "mode": "Режим", + "actions": "Действия", + "shared": "Общий", + "isolated": "Изолированный", + "edit": "Изменить", + "delete": "Удалить", + "save": "Сохранить", + "add": "Добавить", + "adding": "Добавление...", + "noVolumes": "Тома ещё не настроены. Добавьте один выше.", + "volumeAdded": "Том добавлен", + "volumeUpdated": "Том обновлён", + "volumeDeleted": "Том удалён", + "loadFailed": "Не удалось загрузить тома", + "addFailed": "Не удалось добавить том", + "updateFailed": "Не удалось обновить том", + "deleteFailed": "Не удалось удалить том" + }, + "quickDeploy": { + "title": "Быстрый деплой", + "description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.", + "step1": "1. Введите URL образа", + "imageUrl": "URL образа", + "imageUrlHelp": "Полный URL образа с тегом (напр., git.example.com/user/app:dev-abc123)", + "inspect": "Проверить", + "inspecting": "Проверка...", + "step2": "2. Проверка конфигурации", + "reviewDesc": "Эти параметры были обнаружены из образа. Измените при необходимости перед деплоем.", + "projectName": "Имя проекта", + "port": "Порт", + "portHelp": "Порт контейнера (1-65535)", + "healthCheckPath": "Путь проверки здоровья", + "healthCheckHelp": "Необязательный HTTP-путь для проверки работоспособности", + "stage": "Стадия", + "development": "Разработка", + "release": "Релиз", + "production": "Продакшн", + "stageHelp": "Стадия развёртывания для этого образа", + "subdomainOverride": "Переопределение поддомена", + "subdomainHelp": "Оставьте пустым для использования шаблона по умолчанию", + "envVars": "Переменные окружения", + "envVarsHelp": "По одной на строку, формат KEY=VALUE", + "step3": "3. Развёртывание", + "deployDesc": "Будет создан новый проект и контейнер будет развёрнут немедленно.", + "deployBtn": "Развернуть", + "inspectedSuccess": "Образ успешно проверен", + "deployedSuccess": "{name} успешно развёрнут!", + "inspectFailed": "Не удалось проверить образ", + "deployFailed": "Развёртывание не удалось", + "browseImages": "Обзор", + "selectImage": "Выберите образ из реестра", + "noImages": "Образы не найдены", + "loadingImages": "Загрузка...", + "imageLoadFailed": "Не удалось загрузить образы" + }, + "settings": { + "title": "Настройки", + "general": "Общие", + "registries": "Реестры", + "credentials": "Учётные данные", + "authentication": "Аутентификация", + "appearance": "Внешний вид" + }, + "settingsGeneral": { + "title": "Общие настройки", + "globalConfig": "Глобальная конфигурация", + "domain": "Домен", + "domainHelp": "Базовый домен для маршрутизации поддоменов", + "serverIp": "IP сервера", + "serverIpHelp": "Публичный IP-адрес сервера", + "dockerNetwork": "Docker-сеть", + "dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров", + "subdomainPattern": "Шаблон поддомена", + "subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов", + "pollingInterval": "Интервал опроса (секунды)", + "pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)", + "notificationUrl": "URL уведомлений", + "notificationUrlHelp": "URL вебхука для уведомлений о деплоях", + "saveSettings": "Сохранить настройки", + "saving": "Сохранение...", + "saved": "Настройки успешно сохранены", + "saveFailed": "Не удалось сохранить настройки", + "loadFailed": "Не удалось загрузить настройки", + "webhookUrl": "URL вебхука", + "webhookDesc": "Этот секретный URL получает уведомления о push-событиях из вашего CI-пайплайна.", + "noWebhookUrl": "URL вебхука не настроен", + "copy": "Копировать", + "copied": "URL вебхука скопирован в буфер обмена", + "regenerateUrl": "Перегенерировать URL", + "regenerating": "Перегенерация...", + "regenerated": "URL вебхука перегенерирован", + "regenerateFailed": "Не удалось перегенерировать URL вебхука", + "regenerateWarning": "Внимание: перегенерация сделает текущий URL недействительным. Обновите ваши CI-пайплайны." + }, + "settingsRegistries": { + "title": "Реестры контейнеров", + "description": "Управление реестрами контейнеров для обнаружения образов.", + "addRegistry": "Добавить реестр", + "editRegistry": "Редактировать реестр", + "addNewRegistry": "Добавить новый реестр", + "name": "Название", + "nameHelp": "Понятное название для этого реестра", + "url": "URL", + "urlHelp": "Базовый URL реестра", + "type": "Тип", + "typeHelp": "Тип реестра для совместимости API", + "token": "Токен", + "tokenHelpNew": "API-токен для аутентификации", + "tokenHelpEdit": "Оставьте пустым, чтобы сохранить текущий токен", + "owner": "Владелец", + "ownerHelp": "Владельцы пакетов через запятую (напр., alexei,my-org)", + "save": "Сохранить", + "saving": "Сохранение...", + "update": "Обновить", + "test": "Тест", + "testing": "Тестирование...", + "edit": "Изменить", + "delete": "Удалить", + "noRegistries": "Реестры ещё не настроены.", + "addFirst": "Добавьте первый реестр", + "registryUpdated": "Реестр обновлён", + "registryAdded": "Реестр добавлен", + "registryDeleted": "Реестр «{name}» удалён", + "testSuccess": "Подключение к «{name}» успешно", + "saveFailed": "Не удалось сохранить реестр", + "deleteFailed": "Не удалось удалить реестр", + "testFailed": "Тест подключения не удался", + "loadFailed": "Не удалось загрузить реестры", + "deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо." + }, + "settingsCredentials": { + "title": "Учётные данные", + "description": "Управление учётными данными для Nginx Proxy Manager и токенами реестров. Все значения зашифрованы.", + "npm": "Nginx Proxy Manager", + "npmDesc": "Учётные данные для управления прокси-хостами через NPM API", + "configured": "Настроено", + "npmUrl": "URL NPM", + "npmUrlHelp": "URL API Nginx Proxy Manager", + "email": "Email", + "emailHelp": "Email администратора NPM", + "password": "Пароль", + "passwordHelpNew": "Пароль администратора NPM (будет зашифрован)", + "passwordHelpEdit": "Введите новый пароль для замены текущего", + "changeCredentials": "Изменить учётные данные", + "save": "Сохранить", + "saving": "Сохранение...", + "saved": "Учётные данные NPM сохранены", + "saveFailed": "Не удалось сохранить учётные данные NPM", + "loadFailed": "Не удалось загрузить учётные данные", + "registryTokens": "Токены реестров", + "registryTokensDesc": "Токены аутентификации реестров управляются для каждого реестра в разделе", + "registriesLink": "Реестры", + "registryTokensSuffix": ". Каждый реестр хранит свой токен в зашифрованном виде." + }, + "settingsAuth": { + "title": "Настройки аутентификации", + "description": "Настройка режима аутентификации и управление пользователями.", + "authMode": "Режим аутентификации", + "local": "Локальный (логин/пароль)", + "oidc": "OIDC (SSO)", + "oidcConfig": "Конфигурация OIDC-провайдера", + "issuerUrl": "URL издателя", + "clientId": "ID клиента", + "clientSecret": "Секрет клиента", + "redirectUrl": "URL перенаправления", + "saveSettings": "Сохранить настройки", + "saving": "Сохранение...", + "saved": "Настройки сохранены", + "saveFailed": "Не удалось сохранить", + "loadFailed": "Не удалось загрузить настройки", + "localUsers": "Локальные пользователи", + "username": "Имя пользователя", + "email": "Email", + "role": "Роль", + "created": "Создан", + "noUsers": "Пользователи не найдены.", + "addUser": "Добавить пользователя", + "viewer": "Наблюдатель", + "admin": "Администратор", + "userCreated": "Пользователь создан", + "userDeleted": "Пользователь удалён", + "createFailed": "Не удалось создать пользователя", + "deleteFailed": "Не удалось удалить пользователя", + "deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?", + "usernameRequired": "Имя пользователя и пароль обязательны" + }, + "login": { + "title": "Docker Watcher", + "subtitle": "Войдите в свой аккаунт", + "username": "Имя пользователя", + "password": "Пароль", + "signIn": "Войти", + "signingIn": "Вход...", + "or": "или", + "ssoButton": "Войти через SSO (OIDC)", + "loginFailed": "Ошибка входа", + "networkError": "Ошибка сети" + }, + "common": { + "cancel": "Отмена", + "confirm": "Подтвердить", + "delete": "Удалить", + "edit": "Изменить", + "save": "Сохранить", + "retry": "Повторить", + "loading": "Загрузка...", + "noData": "Нет данных", + "project": "Проект", + "back": "Назад", + "actions": "Действия", + "stop": "Остановить", + "start": "Запустить", + "restart": "Перезапустить", + "remove": "Удалить", + "instance": "экземпляр", + "instances": "экземпляров" + }, + "instance": { + "stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.", + "restartConfirm": "Контейнер будет перезапущен с кратковременным простоем.", + "removeConfirm": "Контейнер и его прокси-конфигурация будут безвозвратно удалены.", + "actionFailed": "Действие не удалось" + }, + "empty": { + "noProjects": "Проектов пока нет", + "noProjectsDesc": "Начните с создания первого проекта или используйте быстрый деплой.", + "createProject": "Создать проект", + "noInstances": "Нет экземпляров", + "noInstancesDesc": "Разверните новую версию, чтобы увидеть экземпляры здесь.", + "noDeploys": "Нет истории деплоев", + "noDeploysDesc": "История деплоев появится здесь после первого развёртывания.", + "noRegistries": "Нет реестров", + "noRegistriesDesc": "Добавьте реестр контейнеров для обнаружения образов.", + "noVolumes": "Нет томов", + "noVolumesDesc": "Настройте монтирование томов для постоянных данных.", + "noUsers": "Нет пользователей", + "noUsersDesc": "Добавьте локальных пользователей для управления доступом." + }, + "validation": { + "required": "Поле {field} обязательно", + "invalidUrl": "Неверный формат URL", + "invalidDomain": "Неверный формат домена", + "invalidIp": "Неверный формат IP", + "invalidEmail": "Неверный формат email", + "invalidPort": "Порт должен быть от 1 до 65535", + "invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд", + "invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы", + "requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных", + "requiredForNew": "Поле {field} обязательно для новых реестров" + }, + "confirm": { + "stopInstance": "Остановить экземпляр", + "startInstance": "Запустить экземпляр", + "restartInstance": "Перезапустить экземпляр", + "removeInstance": "Удалить экземпляр" + }, + "theme": { + "light": "Светлая", + "dark": "Тёмная", + "system": "Системная" + }, + "entityPicker": { + "search": "Поиск...", + "noResults": "Ничего не найдено" + }, + "language": { + "en": "Английский", + "ru": "Русский" + } +} diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts new file mode 100644 index 0000000..43926a1 --- /dev/null +++ b/web/src/lib/sse.ts @@ -0,0 +1,196 @@ +/** + * SSE client helper with auto-reconnect and exponential backoff. + * + * Provides type-safe event handling for Docker Watcher's real-time + * event streams (deploy logs and instance status changes). + */ + +// ── Types ────────────────────────────────────────────────────────── + +export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status'; + +export interface SSEEvent { + type: SSEEventType; + payload: T; +} + +export interface DeployLogPayload { + deploy_id: string; + message: string; + level: 'info' | 'warn' | 'error'; +} + +export interface InstanceStatusPayload { + instance_id: string; + project_id: string; + stage_id: string; + status: string; +} + +export interface DeployStatusPayload { + deploy_id: string; + project_id: string; + stage_id: string; + image_tag: string; + status: string; + error?: string; +} + +type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload; + +export interface SSEOptions { + /** Called for each SSE event received. */ + onEvent: (event: SSEEvent) => void; + /** Called when the connection is established. */ + onOpen?: () => void; + /** Called when the connection is lost. Receives the retry attempt number. */ + onError?: (attempt: number) => void; + /** Called when reconnection is given up (max retries exceeded). */ + onGiveUp?: () => void; + /** Maximum number of reconnect attempts. 0 = infinite. Default: 0 */ + maxRetries?: number; + /** Initial backoff delay in ms. Default: 1000 */ + initialDelay?: number; + /** Maximum backoff delay in ms. Default: 30000 */ + maxDelay?: number; +} + +// ── SSE Connection ───────────────────────────────────────────────── + +export interface SSEConnection { + /** Close the connection and stop reconnecting. */ + close: () => void; +} + +/** + * Creates an SSE connection to the given URL with auto-reconnect. + * + * Uses exponential backoff with jitter for reconnection. + * Returns an object with a `close` method to cleanly shut down. + */ +export function connectSSE(url: string, options: SSEOptions): SSEConnection { + const { + onEvent, + onOpen, + onError, + onGiveUp, + maxRetries = 0, + initialDelay = 1000, + maxDelay = 30000 + } = options; + + let eventSource: EventSource | null = null; + let retryCount = 0; + let retryTimeout: ReturnType | null = null; + let closed = false; + + function connect(): void { + if (closed) return; + + // Append auth token as query param (EventSource doesn't support custom headers). + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + const authUrl = token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url; + eventSource = new EventSource(authUrl); + + eventSource.onopen = () => { + retryCount = 0; + onOpen?.(); + }; + + eventSource.onmessage = (messageEvent: MessageEvent) => { + try { + const parsed: SSEEvent = JSON.parse(messageEvent.data); + onEvent(parsed); + } catch { + // Ignore malformed events. + } + }; + + eventSource.onerror = () => { + eventSource?.close(); + eventSource = null; + + if (closed) return; + + retryCount++; + onError?.(retryCount); + + if (maxRetries > 0 && retryCount > maxRetries) { + onGiveUp?.(); + return; + } + + // Exponential backoff with jitter. + const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), maxDelay); + const jitter = delay * 0.2 * Math.random(); + const totalDelay = delay + jitter; + + retryTimeout = setTimeout(connect, totalDelay); + }; + } + + connect(); + + return { + close() { + closed = true; + if (retryTimeout !== null) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + eventSource?.close(); + eventSource = null; + } + }; +} + +// ── Convenience Factories ────────────────────────────────────────── + +/** + * Connect to deploy log SSE stream for a specific deploy. + * Streams existing logs first, then real-time updates. + */ +export function connectDeployLogs( + deployId: string, + callbacks: { + onLog: (log: DeployLogPayload) => void; + onStatus?: (status: DeployStatusPayload) => void; + onOpen?: () => void; + onError?: (attempt: number) => void; + } +): SSEConnection { + return connectSSE(`/api/deploys/${deployId}/logs`, { + onEvent(event) { + if (event.type === 'deploy_log') { + callbacks.onLog(event.payload as DeployLogPayload); + } else if (event.type === 'deploy_status') { + callbacks.onStatus?.(event.payload as DeployStatusPayload); + } + }, + onOpen: callbacks.onOpen, + onError: callbacks.onError + }); +} + +/** + * Connect to the global events SSE stream. + * Receives instance status changes and deploy status updates. + */ +export function connectGlobalEvents(callbacks: { + onInstanceStatus?: (payload: InstanceStatusPayload) => void; + onDeployStatus?: (payload: DeployStatusPayload) => void; + onOpen?: () => void; + onError?: (attempt: number) => void; +}): SSEConnection { + return connectSSE('/api/events', { + onEvent(event) { + if (event.type === 'instance_status') { + callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload); + } else if (event.type === 'deploy_status') { + callbacks.onDeployStatus?.(event.payload as DeployStatusPayload); + } + }, + onOpen: callbacks.onOpen, + onError: callbacks.onError + }); +} diff --git a/web/src/lib/stores/instance-status.ts b/web/src/lib/stores/instance-status.ts new file mode 100644 index 0000000..66ad831 --- /dev/null +++ b/web/src/lib/stores/instance-status.ts @@ -0,0 +1,70 @@ +import { writable, get } from 'svelte/store'; +import type { InstanceStatusPayload, DeployStatusPayload } from '$lib/sse'; + +/** + * Global store for real-time instance status updates received via SSE. + * + * Components can subscribe to this store to reactively update when + * instance statuses change without polling. + */ + +interface InstanceStatusState { + /** Map of instance ID to latest status. */ + statuses: Record; + /** Timestamp of last update, useful for triggering reactive refreshes. */ + lastUpdate: number; + /** Latest deploy status events, keyed by deploy ID. */ + deployStatuses: Record; +} + +function createInstanceStatusStore() { + const { subscribe, set, update: storeUpdate } = writable({ + statuses: {}, + lastUpdate: 0, + deployStatuses: {} + }); + + return { + subscribe, + + /** Update an instance's status from an SSE event. */ + update(payload: InstanceStatusPayload) { + storeUpdate((state) => ({ + ...state, + statuses: { + ...state.statuses, + [payload.instance_id]: payload.status + }, + lastUpdate: Date.now() + })); + }, + + /** Record a deploy status change from an SSE event. */ + notifyDeploy(payload: DeployStatusPayload) { + storeUpdate((state) => ({ + ...state, + deployStatuses: { + ...state.deployStatuses, + [payload.deploy_id]: payload + }, + lastUpdate: Date.now() + })); + }, + + /** Get the current status of an instance, or undefined if not tracked. */ + getStatus(instanceId: string): string | undefined { + return get({ subscribe }).statuses[instanceId]; + }, + + /** Reset the store (e.g., on disconnect). */ + reset() { + set({ + statuses: {}, + lastUpdate: 0, + deployStatuses: {} + }); + } + }; +} + +export const instanceStatusStore = createInstanceStatusStore(); diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts new file mode 100644 index 0000000..95dfe40 --- /dev/null +++ b/web/src/lib/stores/theme.ts @@ -0,0 +1,55 @@ +/** + * Dark mode support store. + * Task 12: Toggle in settings, respect system preference, persist to localStorage. + */ + +import { writable, derived } from 'svelte/store'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +const THEME_KEY = 'dw_theme'; + +function getInitialMode(): ThemeMode { + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored; + } + return 'system'; +} + +export const themeMode = writable(getInitialMode()); + +// Persist theme preference. +themeMode.subscribe((value) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(THEME_KEY, value); + } +}); + +/** + * Resolved theme based on mode and system preference. + * Returns 'light' or 'dark'. + */ +export const resolvedTheme = derived(themeMode, ($mode) => { + if ($mode === 'system') { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'light'; + } + return $mode; +}); + +/** + * Apply the theme to the document element. + * Call this in the root layout's $effect. + */ +export function applyTheme(theme: 'light' | 'dark'): void { + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-theme', theme); + } +} + +export function setThemeMode(mode: ThemeMode): void { + themeMode.set(mode); +} diff --git a/web/src/lib/stores/toast.ts b/web/src/lib/stores/toast.ts new file mode 100644 index 0000000..6f487cc --- /dev/null +++ b/web/src/lib/stores/toast.ts @@ -0,0 +1,61 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration: number; +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + let counter = 0; + + function add(message: string, type: ToastType = 'info', duration = 5000): string { + const id = `toast-${++counter}-${Date.now()}`; + const toast: Toast = { id, message, type, duration }; + + update((toasts) => [...toasts, toast]); + + if (duration > 0) { + setTimeout(() => remove(id), duration); + } + + return id; + } + + function remove(id: string): void { + update((toasts) => toasts.filter((t) => t.id !== id)); + } + + function success(message: string, duration = 5000): string { + return add(message, 'success', duration); + } + + function error(message: string, duration = 7000): string { + return add(message, 'error', duration); + } + + function warning(message: string, duration = 5000): string { + return add(message, 'warning', duration); + } + + function info(message: string, duration = 5000): string { + return add(message, 'info', duration); + } + + return { + subscribe, + add, + remove, + success, + error, + warning, + info + }; +} + +export const toasts = createToastStore(); diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css new file mode 100644 index 0000000..229b666 --- /dev/null +++ b/web/src/lib/styles/tokens.css @@ -0,0 +1,258 @@ +/* ── Design Tokens ───────────────────────────────────────────────────── + CSS custom properties for Docker Watcher design system. + Task 1: Color palette, spacing scale, typography, border radius tokens. + Task 12: Dark mode support via [data-theme="dark"] selector. + ───────────────────────────────────────────────────────────────────── */ + +:root { + /* ── Brand Colors ───────────────────────────────────── */ + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; + + /* ── Semantic Colors ────────────────────────────────── */ + --color-success: #16a34a; + --color-success-light: #dcfce7; + --color-success-dark: #15803d; + --color-warning: #d97706; + --color-warning-light: #fef3c7; + --color-warning-dark: #b45309; + --color-danger: #dc2626; + --color-danger-light: #fee2e2; + --color-danger-dark: #b91c1c; + --color-info: #2563eb; + --color-info-light: #dbeafe; + --color-info-dark: #1d4ed8; + + /* ── Surface Colors (Light Mode) ────────────────────── */ + --surface-page: #f8fafc; + --surface-card: #ffffff; + --surface-card-hover: #f8fafc; + --surface-sidebar: #ffffff; + --surface-overlay: rgba(0, 0, 0, 0.3); + --surface-input: #ffffff; + + /* ── Border Colors ──────────────────────────────────── */ + --border-primary: #e2e8f0; + --border-secondary: #f1f5f9; + --border-focus: var(--color-brand-500); + --border-input: #cbd5e1; + + /* ── Text Colors ────────────────────────────────────── */ + --text-primary: #0f172a; + --text-secondary: #475569; + --text-tertiary: #94a3b8; + --text-inverse: #ffffff; + --text-link: var(--color-brand-600); + --text-link-hover: var(--color-brand-700); + + /* ── Spacing Scale (4px base) ───────────────────────── */ + --space-0: 0; + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* ── Typography Scale ───────────────────────────────── */ + --font-family-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-family-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace; + + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.875rem; /* 14px */ + --text-base: 1rem; /* 16px */ + --text-lg: 1.125rem; /* 18px */ + --text-xl: 1.25rem; /* 20px */ + --text-2xl: 1.5rem; /* 24px */ + --text-3xl: 1.875rem; /* 30px */ + + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + + --weight-normal: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* ── Border Radius ──────────────────────────────────── */ + --radius-sm: 0.25rem; /* 4px */ + --radius-md: 0.375rem; /* 6px */ + --radius-lg: 0.5rem; /* 8px */ + --radius-xl: 0.75rem; /* 12px */ + --radius-2xl: 1rem; /* 16px */ + --radius-full: 9999px; + + /* ── Shadows ────────────────────────────────────────── */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + + /* ── Transitions ────────────────────────────────────── */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + /* ── Sidebar ────────────────────────────────────────── */ + --sidebar-width: 16rem; /* 256px */ + --sidebar-collapsed-width: 0; /* mobile collapsed */ + --topbar-height: 4rem; /* 64px */ +} + +/* ── Dark Mode Tokens ─────────────────────────────────────────────── */ + +[data-theme="dark"] { + --surface-page: #0f172a; + --surface-card: #1e293b; + --surface-card-hover: #334155; + --surface-sidebar: #1e293b; + --surface-overlay: rgba(0, 0, 0, 0.6); + --surface-input: #1e293b; + + --border-primary: #334155; + --border-secondary: #1e293b; + --border-focus: var(--color-brand-400); + --border-input: #475569; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + --text-inverse: #0f172a; + --text-link: var(--color-brand-400); + --text-link-hover: var(--color-brand-300); + + --color-success: #22c55e; + --color-success-light: #14532d; + --color-warning: #f59e0b; + --color-warning-light: #451a03; + --color-danger: #ef4444; + --color-danger-light: #450a0a; + --color-info: #3b82f6; + --color-info-light: #172554; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3); +} + +/* ── Animations ───────────────────────────────────────────────────── */ + +@keyframes pulse-status { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes slide-in-right { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes slide-out-right { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +@keyframes skeleton-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scale-in { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +@keyframes button-press { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(0.97); } +} + +.animate-pulse-status { + animation: pulse-status 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-slide-in { + animation: slide-in-right var(--transition-slow) forwards; +} + +.animate-slide-out { + animation: slide-out-right var(--transition-slow) forwards; +} + +.animate-fade-in { + animation: fade-in var(--transition-normal) forwards; +} + +.animate-scale-in { + animation: scale-in var(--transition-normal) forwards; +} + +.active\:animate-press:active { + animation: button-press 150ms ease-in-out; +} + +/* ── Skeleton Loader ──────────────────────────────────────────────── */ + +.skeleton { + background: linear-gradient( + 90deg, + var(--border-secondary) 25%, + var(--border-primary) 50%, + var(--border-secondary) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: var(--radius-md); +} + +/* ── Toggle Switch ────────────────────────────────────────────────── */ + +.toggle-switch { + position: relative; + width: 2.75rem; + height: 1.5rem; + background-color: var(--border-primary); + border-radius: var(--radius-full); + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.toggle-switch[aria-checked="true"] { + background-color: var(--color-brand-600); +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 0.125rem; + left: 0.125rem; + width: 1.25rem; + height: 1.25rem; + background-color: white; + border-radius: var(--radius-full); + box-shadow: var(--shadow-sm); + transition: transform var(--transition-fast); +} + +.toggle-switch[aria-checked="true"]::after { + transform: translateX(1.25rem); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..9cc7f2a --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,161 @@ +// Types matching the Go backend store models (internal/store/models.go). + +export interface Project { + id: string; + name: string; + registry: string; + image: string; + port: number; + healthcheck: string; + env: string; + volumes: string; + created_at: string; + updated_at: string; +} + +export interface Stage { + id: string; + project_id: string; + name: string; + tag_pattern: string; + auto_deploy: boolean; + max_instances: number; + confirm: boolean; + promote_from: string; + subdomain: string; + created_at: string; + updated_at: string; +} + +export interface Instance { + id: string; + stage_id: string; + project_id: string; + container_id: string; + image_tag: string; + subdomain: string; + npm_proxy_id: number; + status: InstanceStatus; + port: number; + created_at: string; + updated_at: string; +} + +export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'removing'; + +export interface Deploy { + id: string; + project_id: string; + stage_id: string; + instance_id: string; + image_tag: string; + status: DeployStatus; + started_at: string; + finished_at: string; + error: string; +} + +export type DeployStatus = + | 'pending' + | 'pulling' + | 'starting' + | 'configuring_proxy' + | 'health_checking' + | 'success' + | 'failed' + | 'rolled_back'; + +export interface DeployLog { + id: number; + deploy_id: string; + message: string; + level: 'info' | 'warn' | 'error'; + created_at: string; +} + +export interface Registry { + id: string; + name: string; + url: string; + type: string; + token: string; + owner: string; + has_token?: boolean; + created_at: string; + updated_at: string; +} + +/** A container image discovered from a registry. */ +export interface RegistryImage { + name: string; + owner: string; + full_ref: string; +} + +export interface Settings { + domain: string; + server_ip: string; + network: string; + subdomain_pattern: string; + notification_url: string; + npm_url: string; + npm_email: string; + npm_password: string; + webhook_secret: string; + polling_interval: string; + base_volume_path: string; + updated_at: string; +} + +/** Standard API envelope returned by all backend endpoints. */ +export interface ApiEnvelope { + success: boolean; + data?: T; + error?: string; +} + +/** Response shape for GET /api/projects/:id */ +export interface ProjectDetail { + project: Project; + stages: Stage[]; +} + +/** Response shape for POST /api/deploy/inspect */ +export interface InspectResult { + image: string; + port: number; + healthcheck: string; +} + +/** Stage environment variable override. */ +export interface StageEnv { + id: string; + stage_id: string; + key: string; + value: string; + encrypted: boolean; + created_at: string; + updated_at: string; +} + +/** Item for the EntityPicker command-palette component. */ +export interface EntityPickerItem { + value: string; + label: string; + description?: string; + icon?: string; + group?: string; + disabled?: boolean; + disabledHint?: string; +} + +/** Volume mount configuration for a project. */ +export interface Volume { + id: string; + project_id: string; + source: string; + target: string; + mode: 'shared' | 'isolated'; + created_at: string; + updated_at: string; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..f03609a --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,181 @@ + + +{#if isLoginPage} + + {@render children()} +{:else} +
+ + {#if sidebarOpen} + + {/if} + + + + + +
+ +
+ +
+ + + +
+ {$t('app.name')} +
+ + +
+
+ {@render children()} +
+
+
+
+{/if} + + diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 0000000..48af047 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1,4 @@ +// Disable SSR for static adapter — all rendering happens client-side. +// Prerender only the SPA shell; dynamic routes use the fallback index.html. +export const ssr = false; +export const prerender = false; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..68fa09b --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,151 @@ + + + + {$t('dashboard.title')} - {$t('app.name')} + + +
+ +
+

{$t('dashboard.title')}

+ + + {$t('dashboard.quickDeploy')} + +
+ + +
+
+
+ +
+
+

{$t('dashboard.totalProjects')}

+

{totalProjects}

+
+
+
+
+ +
+
+

{$t('dashboard.runningInstances')}

+

{totalRunning}

+
+
+
+
+ +
+
+

{$t('dashboard.failedInstances')}

+

{totalFailed}

+
+
+
+ + +
+

{$t('dashboard.projects')}

+ + {#if loading} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if error} +
+

{error}

+ +
+ {:else if projects.length === 0} +
+ +
+ {:else} +
+ {#each projects as project (project.id)} + + {/each} +
+ {/if} +
+
diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte new file mode 100644 index 0000000..52df090 --- /dev/null +++ b/web/src/routes/deploy/+page.svelte @@ -0,0 +1,278 @@ + + + + {$t('quickDeploy.title')} - {$t('app.name')} + + +
+
+

{$t('quickDeploy.title')}

+

{$t('quickDeploy.description')}

+
+ + +
+

{$t('quickDeploy.step1')}

+
+
+ +
+
+ + +
+
+ { showImagePicker = false; }} + /> +
+ + + {#if inspected} +
+

{$t('quickDeploy.step2')}

+

{$t('quickDeploy.reviewDesc')}

+ +
+ + + +
+ + +

{$t('quickDeploy.stageHelp')}

+
+ +
+ +
+ +
+
+ + +
+

{$t('quickDeploy.step3')}

+

{$t('quickDeploy.deployDesc')}

+
+ + +
+
+ {/if} +
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..e44e1d6 --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,152 @@ + + +
+
+
+ +
+
+ + + +
+

{$t('login.title')}

+

{$t('login.subtitle')}

+
+ + {#if error} +
+ {error} +
+ {/if} + +
{ e.preventDefault(); handleLogin(); }} class="space-y-4"> +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+
+
+
+ {$t('login.or')} +
+
+ + +
+
+
+
diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte new file mode 100644 index 0000000..af0115b --- /dev/null +++ b/web/src/routes/projects/+page.svelte @@ -0,0 +1,266 @@ + + + + {$t('projects.title')} - {$t('app.name')} + + +
+
+

{$t('projects.title')}

+ +
+ + + {#if showAddForm} +
+

{$t('projects.newProject')}

+ + {#if formError} +
+

{formError}

+
+ {/if} + +
+ +
+
+ +
+ +
+ { showImagePicker = false; }} + /> + + +
+ +
+ +
+
+ {/if} + + + {#if loading} + + {:else if error} +
+

{error}

+ +
+ {:else if projects.length === 0} + { showAddForm = true; }} + icon="projects" + /> + {:else} +
+ + + + + + + + + + + + + {#each projects as project (project.id)} + + + + + + + + + {/each} + +
{$t('projects.name')}{$t('projects.image')}{$t('projects.port')}{$t('projects.registry')}{$t('projects.created')}
+ + {project.name} + + + {project.image} + + {project.port || '-'} + + {project.registry || '-'} + + {new Date(project.created_at).toLocaleDateString()} + + + {$t('projects.view')} + +
+
+ {/if} +
diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..38a856e --- /dev/null +++ b/web/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,538 @@ + + + + {project?.name ?? $t('common.project')} - {$t('app.name')} + + +{#if loading} +
+
+
+ + + +
+
+
+ {#each Array(4) as _} + + {/each} +
+
+{:else if error} +
+

{error}

+ +
+{:else if project} +
+ +
+
+ +

{project.name}

+

{project.image}

+
+ +
+ + + + + +
+ {#if editing} +
+ + + + +
+
+ + +
+ {:else} +
+
+
+

{$t('projects.port')}

+

{project.port || 'Auto'}

+
+
+

{$t('projects.healthcheck')}

+

{project.healthcheck || 'Auto'}

+
+
+

{$t('projects.registry')}

+

{project.registry || '-'}

+
+
+

{$t('projects.created')}

+

{new Date(project.created_at).toLocaleDateString()}

+
+
+ +
+ {/if} +
+ + +
+
+

{$t('projectDetail.stages')}

+ +
+ + {#if showAddStage} +
+
+ + + +
+ +
+ +
+
+
+
+ +
+
+ {/if} + + {#if stages.length === 0 && !showAddStage} +
+ +
+ {:else} +
+ {#each stages as stage (stage.id)} + {@const stageInstances = instancesByStage[stage.id] ?? []} +
+ +
+
+

{stage.name}

+ {stage.tag_pattern} + {#if stage.auto_deploy} + {$t('projectDetail.autoDeploy')} + {/if} + {#if stage.confirm} + {$t('projectDetail.requiresConfirm')} + {/if} +
+
+ + {stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')} + + + +
+
+ + + {#if deployStageId === stage.id} +
+
+
+ + {#if tagsLoading} +
+ + {$t('projectDetail.loadingTags')} +
+ {:else if availableTags.length > 0} + + {:else} + + {/if} +
+ + +
+ {#if deployError} +

{deployError}

+ {/if} +
+ {/if} + + +
+ {#if stageInstances.length === 0} +

{$t('projectDetail.noInstancesRunning')}

+ {:else} +
+ {#each stageInstances as instance (instance.id)} + + {/each} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+ + +
+

{$t('projectDetail.recentDeploys')}

+ + {#if deploys.length === 0} +

{$t('projectDetail.noDeployHistory')}

+ {:else} +
+ {#each deploys as deploy (deploy.id)} +
+ +
+
+
+ +
+
+ {deploy.image_tag} + +
+
+ {#if deploy.started_at} + + + {new Date(deploy.started_at).toLocaleString()} + + {/if} + {#if deploy.finished_at} + → {new Date(deploy.finished_at).toLocaleString()} + {/if} +
+ {#if deploy.error} +

{deploy.error}

+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ + { showDeleteConfirm = false; }} + /> +{/if} diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte new file mode 100644 index 0000000..1a128d8 --- /dev/null +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -0,0 +1,333 @@ + + + + {$t('envEditor.title')} - {$t('app.name')} + + +
+ +
+ +

{$t('envEditor.title')}

+

{$t('envEditor.description')}

+
+ + {#if loading} +
+ + +
+ {:else if error} +
+

{error}

+
+ {:else} + +
+ + +
+ + {#if stages.length === 0} + + {:else} + + {#if Object.keys(projectEnv).length > 0} +
+

{$t('envEditor.projectDefaults')}

+
+ + + + + + + + + + {#each Object.entries(projectEnv) as [key, value] (key)} + + + + + + {/each} + +
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.source')}
{key}{value} + {#if isOverridden(key)} + {$t('envEditor.overridden')} + {:else} + {$t('envEditor.inherited')} + {/if} +
+
+
+ {/if} + + +
+

{$t('envEditor.stageOverrides')}

+ + {#if envLoading} +
+ + {$t('common.loading')} +
+ {:else} +
+ + + + + + + + + + + + {#each envVars as env (env.id)} + {#if editingId === env.id} + + + + + + + + {:else} + + + + + + + + {/if} + {/each} + + + + + + + + + + +
{$t('envEditor.key')}{$t('envEditor.value')}{$t('envEditor.secret')}{$t('envEditor.source')}{$t('envEditor.actions')}
+ + + + + + +
+ + +
+
{env.key} + {env.encrypted ? '••••••••' : env.value} + + {#if env.encrypted} + + + {$t('envEditor.secret')} + + {/if} + + {#if env.key in projectEnv} + {$t('envEditor.overridesProject')} + {:else} + {$t('envEditor.stageOnly')} + {/if} + +
+ + +
+
+ + + + + + + +
+
+ {/if} +
+ {/if} + {/if} +
diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte new file mode 100644 index 0000000..d52220d --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -0,0 +1,215 @@ + + + + {$t('volumeEditor.title')} - {$t('app.name')} + + +
+ +
+ +

{$t('volumeEditor.title')}

+

+ {$t('volumeEditor.description')} + {$t('volumeEditor.shared')} — {$t('volumeEditor.sharedDesc')} + {$t('volumeEditor.isolated')} — {$t('volumeEditor.isolatedDesc')} +

+
+ + {#if loading} +
+ +
+ {:else if error} +
+

{error}

+ +
+ {:else} +
+ + + + + + + + + + + {#each volumes as vol (vol.id)} + {#if editingId === vol.id} + + + + + + + {:else} + + + + + + + {/if} + {/each} + + + + + + + + + +
{$t('volumeEditor.sourceHost')}{$t('volumeEditor.targetContainer')}{$t('volumeEditor.mode')}{$t('volumeEditor.actions')}
+ + + + + + +
+ + +
+
{vol.source}{vol.target} + {#if vol.mode === 'shared'} + {$t('volumeEditor.shared')} + {:else} + {$t('volumeEditor.isolated')} + {/if} + +
+ + +
+
+ + + + + + + +
+
+ + {#if volumes.length === 0} +

{$t('volumeEditor.noVolumes')}

+ {/if} + {/if} +
diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte new file mode 100644 index 0000000..35acbe7 --- /dev/null +++ b/web/src/routes/settings/+layout.svelte @@ -0,0 +1,65 @@ + + + diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte new file mode 100644 index 0000000..7ca56b8 --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,191 @@ + + + + {$t('settingsGeneral.title')} - {$t('app.name')} + + +
+ {#if loading} +
+ +
+ {#each Array(6) as _} + + {/each} +
+
+ {:else} +
+

{$t('settingsGeneral.globalConfig')}

+
+ + + + + + + +
+
+ +
+
+ + +
+

{$t('settingsGeneral.webhookUrl')}

+

{$t('settingsGeneral.webhookDesc')}

+ + {#if webhookUrl} +
+ + {webhookUrl} + + +
+ {:else} +

{$t('settingsGeneral.noWebhookUrl')}

+ {/if} + +
+ +

{$t('settingsGeneral.regenerateWarning')}

+
+
+ {/if} +
diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte new file mode 100644 index 0000000..f40ba3d --- /dev/null +++ b/web/src/routes/settings/auth/+page.svelte @@ -0,0 +1,200 @@ + + +
+
+

{$t('settingsAuth.title')}

+

{$t('settingsAuth.description')}

+
+ + {#if message} +
{message}
+ {/if} + {#if error} +
{error}
+ {/if} + + +
+

{$t('settingsAuth.authMode')}

+
+ + +
+
+ + + {#if settings.auth_mode === 'oidc'} +
+

{$t('settingsAuth.oidcConfig')}

+
+ {#each [ + { id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/docker-watcher/' }, + { id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' }, + { id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' }, + { id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' } + ] as field} +
+ + +
+ {/each} +
+
+ {/if} + + + + +
+

{$t('settingsAuth.localUsers')}

+ + {#if users.length > 0} +
+ + + + + + + + + + + + {#each users as user} + + + + + + + + {/each} + +
{$t('settingsAuth.username')}{$t('settingsAuth.email')}{$t('settingsAuth.role')}{$t('settingsAuth.created')}
{user.username}{user.email || '-'} + + {user.role} + + {user.created_at} + +
+
+ {:else} +
+ +
+ {/if} + +
+

{$t('settingsAuth.addUser')}

+
+ + + + +
+ +
+
+
diff --git a/web/src/routes/settings/credentials/+page.svelte b/web/src/routes/settings/credentials/+page.svelte new file mode 100644 index 0000000..783116e --- /dev/null +++ b/web/src/routes/settings/credentials/+page.svelte @@ -0,0 +1,126 @@ + + + + {$t('settingsCredentials.title')} - {$t('app.name')} + + +
+
+

{$t('settingsCredentials.title')}

+

{$t('settingsCredentials.description')}

+
+ + {#if loading} +
+ {:else} +
+
+
+

{$t('settingsCredentials.npm')}

+

{$t('settingsCredentials.npmDesc')}

+
+ {#if npmHasCredentials && !editingNpm} + + + {$t('settingsCredentials.configured')} + + {/if} +
+ + {#if !editingNpm && npmHasCredentials} +
+ {#each [{ label: $t('settingsCredentials.npmUrl'), value: npmUrl }, { label: $t('settingsCredentials.email'), value: npmEmail }, { label: $t('settingsCredentials.password'), value: '--------' }] as item} +
+
+

{item.label}

+

{item.value || 'Not set'}

+
+
+ {/each} + +
+ {:else} +
+ + + +
+ + {#if npmHasCredentials} + + {/if} +
+
+ {/if} +
+ +
+

{$t('settingsCredentials.registryTokens')}

+

+ {$t('settingsCredentials.registryTokensDesc')} + {$t('settingsCredentials.registriesLink')} + {$t('settingsCredentials.registryTokensSuffix')} +

+
+ {/if} +
diff --git a/web/src/routes/settings/registries/+page.svelte b/web/src/routes/settings/registries/+page.svelte new file mode 100644 index 0000000..9753819 --- /dev/null +++ b/web/src/routes/settings/registries/+page.svelte @@ -0,0 +1,183 @@ + + + + {$t('settingsRegistries.title')} - {$t('app.name')} + + +
+
+
+

{$t('settingsRegistries.title')}

+

{$t('settingsRegistries.description')}

+
+ {#if !showForm} + + {/if} +
+ + {#if showForm} +
+

+ {editingId ? $t('settingsRegistries.editRegistry') : $t('settingsRegistries.addNewRegistry')} +

+
+ + +
+ + +

{$t('settingsRegistries.typeHelp')}

+
+ + +
+
+ + +
+
+ {/if} + + {#if loading} +
+ {#each Array(2) as _} + + {/each} +
+ {:else if registries.length === 0} + { showForm = true; }} icon="registries" /> + {:else} +
+ {#each registries as registry (registry.id)} +
+
+
+ {#if healthStatus[registry.id] === 'checking'} + + {:else if healthStatus[registry.id] === 'healthy'} + + {:else if healthStatus[registry.id] === 'unhealthy'} + + {/if} +

{registry.name}

+ {registry.type} +
+

{registry.url}{registry.owner ? `/${registry.owner}` : ''}

+
+
+ + + +
+
+ {/each} +
+ {/if} +
diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..791ff50 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,24 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: false + }), + alias: { + $lib: './src/lib' + }, + prerender: { + handleHttpError: 'warn' + } + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..fbb8c0f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +});