feat: Docker Watcher core - full implementation
Complete self-hosted Docker deployment tool with: - Go backend: SQLite store, Docker SDK, NPM proxy client, registry polling, webhook handler, deployer with blue-green & rollback, auth (local + OIDC), structured logging, config export - SvelteKit frontend: dashboard, project management, quick deploy, settings, env vars, volumes, dark mode, i18n (EN/RU) - Auto-discovery of container images from Gitea registries - EntityPicker command-palette for item selection - SSE real-time updates, graceful shutdown - Multi-stage Dockerfile, docker-compose.yml
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
node_modules
|
||||
web/node_modules
|
||||
web/build
|
||||
data
|
||||
*.md
|
||||
plans/
|
||||
.claude/
|
||||
.dockerignore
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
web/build/
|
||||
data/
|
||||
.env
|
||||
+49
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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/<secret-uuid>
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <token>
|
||||
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")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, "-")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 "", ""
|
||||
}
|
||||
}
|
||||
@@ -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 <duration>.
|
||||
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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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`)
|
||||
@@ -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
|
||||
<!-- This is the final phase — no handoff needed. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Generated
+3225
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers as Record<string, string>)
|
||||
};
|
||||
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<T>;
|
||||
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<T>(path: string): Promise<T> {
|
||||
return request<T>(path);
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function put<T>(path: string, body: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
function del<T>(path: string): Promise<T> {
|
||||
return request<T>(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────────────
|
||||
|
||||
export function listProjects(): Promise<Project[]> {
|
||||
return get<Project[]>('/api/projects');
|
||||
}
|
||||
|
||||
export function getProject(id: string): Promise<ProjectDetail> {
|
||||
return get<ProjectDetail>(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
export function createProject(data: Partial<Project>): Promise<Project> {
|
||||
return post<Project>('/api/projects', data);
|
||||
}
|
||||
|
||||
export function updateProject(id: string, data: Partial<Project>): Promise<Project> {
|
||||
return put<Project>(`/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<Stage>): Promise<Stage> {
|
||||
return post<Stage>(`/api/projects/${projectId}/stages`, data);
|
||||
}
|
||||
|
||||
export function updateStage(projectId: string, stageId: string, data: Partial<Stage>): Promise<Stage> {
|
||||
return put<Stage>(`/api/projects/${projectId}/stages/${stageId}`, data);
|
||||
}
|
||||
|
||||
export function deleteStage(projectId: string, stageId: string): Promise<void> {
|
||||
return del<void>(`/api/projects/${projectId}/stages/${stageId}`);
|
||||
}
|
||||
|
||||
// ── Instances ───────────────────────────────────────────────────────
|
||||
|
||||
export function listInstances(projectId: string, stageId: string): Promise<Instance[]> {
|
||||
return get<Instance[]>(`/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<Deploy[]> {
|
||||
return get<Deploy[]>(`/api/deploys?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
|
||||
return get<DeployLog[]>(`/api/deploys/${deployId}/logs`);
|
||||
}
|
||||
|
||||
export function inspectImage(image: string): Promise<InspectResult> {
|
||||
return post<InspectResult>('/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<Registry[]> {
|
||||
return get<Registry[]>('/api/registries');
|
||||
}
|
||||
|
||||
export function createRegistry(data: Partial<Registry>): Promise<Registry> {
|
||||
return post<Registry>('/api/registries', data);
|
||||
}
|
||||
|
||||
export function updateRegistry(id: string, data: Partial<Registry>): Promise<Registry> {
|
||||
return put<Registry>(`/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<string[]> {
|
||||
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
||||
}
|
||||
|
||||
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
|
||||
return get<RegistryImage[]>(`/api/registries/${registryId}/images`);
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────
|
||||
|
||||
export function getSettings(): Promise<Settings> {
|
||||
return get<Settings>('/api/settings');
|
||||
}
|
||||
|
||||
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
||||
return put<Settings>('/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<StageEnv[]> {
|
||||
return get<StageEnv[]>(`/api/projects/${projectId}/stages/${stageId}/env`);
|
||||
}
|
||||
|
||||
export function createStageEnv(
|
||||
projectId: string,
|
||||
stageId: string,
|
||||
data: { key: string; value: string; encrypted?: boolean }
|
||||
): Promise<StageEnv> {
|
||||
return post<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env`, data);
|
||||
}
|
||||
|
||||
export function updateStageEnv(
|
||||
projectId: string,
|
||||
stageId: string,
|
||||
envId: string,
|
||||
data: { key?: string; value?: string; encrypted?: boolean }
|
||||
): Promise<StageEnv> {
|
||||
return put<StageEnv>(`/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<Volume[]> {
|
||||
return get<Volume[]>(`/api/projects/${projectId}/volumes`);
|
||||
}
|
||||
|
||||
export function createVolume(
|
||||
projectId: string,
|
||||
data: { source: string; target: string; mode?: string }
|
||||
): Promise<Volume> {
|
||||
return post<Volume>(`/api/projects/${projectId}/volumes`, data);
|
||||
}
|
||||
|
||||
export function updateVolume(
|
||||
projectId: string,
|
||||
volId: string,
|
||||
data: { source?: string; target?: string; mode?: string }
|
||||
): Promise<Volume> {
|
||||
return put<Volume>(`/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 };
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
Confirm dialog with fade/scale-in animation.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
confirmVariant?: 'danger' | 'primary';
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
confirmVariant = 'primary',
|
||||
onconfirm,
|
||||
oncancel
|
||||
}: Props = $props();
|
||||
|
||||
const confirmClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-[var(--color-danger)] hover:bg-[var(--color-danger-dark)] focus-visible:outline-[var(--color-danger)]'
|
||||
: 'bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] focus-visible:outline-[var(--color-brand-600)]'
|
||||
);
|
||||
|
||||
const iconBgClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-[var(--color-danger-light)]'
|
||||
: 'bg-[var(--color-brand-50)]'
|
||||
);
|
||||
|
||||
const iconColorClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'text-[var(--color-danger)]'
|
||||
: 'text-[var(--color-brand-600)]'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={oncancel}></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full {iconBgClass}">
|
||||
<IconAlert size={20} class={iconColorClass} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)] leading-relaxed">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors active:animate-press"
|
||||
onclick={oncancel}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-white {confirmClass} shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 active:animate-press"
|
||||
onclick={onconfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!--
|
||||
Task 9: Empty state component with SVG illustration and call-to-action.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
onaction?: () => void;
|
||||
icon?: 'projects' | 'instances' | 'deploys' | 'registries' | 'volumes' | 'users';
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = '',
|
||||
actionLabel = '',
|
||||
actionHref = '',
|
||||
onaction,
|
||||
icon = 'projects'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center animate-fade-in">
|
||||
<!-- SVG Illustration -->
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--color-brand-50)]">
|
||||
{#if icon === 'projects'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
<path d="M12 10v6" /><path d="M9 13h6" />
|
||||
</svg>
|
||||
{:else if icon === 'instances'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
{:else if icon === 'deploys'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
|
||||
</svg>
|
||||
{:else if icon === 'registries'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" />
|
||||
</svg>
|
||||
{:else if icon === 'volumes'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
{:else if icon === 'users'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
|
||||
{#if description}
|
||||
<p class="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">{description}</p>
|
||||
{/if}
|
||||
|
||||
{#if actionLabel}
|
||||
{#if actionHref}
|
||||
<a
|
||||
href={actionHref}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{actionLabel}
|
||||
</a>
|
||||
{:else if onaction}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaction}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{actionLabel}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,443 @@
|
||||
<!--
|
||||
EntityPicker — command-palette style modal for selecting items from a list.
|
||||
Supports search filtering, keyboard navigation, grouped items, and current-item highlighting.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconSearch, IconX } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
items: EntityPickerItem[];
|
||||
current?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
onselect: (value: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
open = $bindable(),
|
||||
items,
|
||||
current = '',
|
||||
placeholder,
|
||||
title = '',
|
||||
onselect,
|
||||
onclose
|
||||
}: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let highlightIndex = $state(0);
|
||||
let searchInput: HTMLInputElement | undefined = $state(undefined);
|
||||
let listEl: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.description?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
/** Group filtered items, preserving insertion order. */
|
||||
const grouped = $derived.by(() => {
|
||||
const groups: { name: string; items: EntityPickerItem[] }[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const item of filtered) {
|
||||
const groupName = item.group ?? '';
|
||||
const idx = seen.get(groupName);
|
||||
if (idx !== undefined) {
|
||||
groups[idx].items.push(item);
|
||||
} else {
|
||||
seen.set(groupName, groups.length);
|
||||
groups.push({ name: groupName, items: [item] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
/** Flat list of filtered items for keyboard indexing. */
|
||||
const flatFiltered = $derived(filtered);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
query = '';
|
||||
highlightIndex = 0;
|
||||
// Focus the search input after the modal renders.
|
||||
requestAnimationFrame(() => {
|
||||
searchInput?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reset highlight when filter changes.
|
||||
$effect(() => {
|
||||
// Access filtered.length to create a dependency.
|
||||
void filtered.length;
|
||||
highlightIndex = 0;
|
||||
});
|
||||
|
||||
function scrollHighlightedIntoView() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listEl?.querySelector('[data-highlighted="true"]');
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
highlightIndex = (highlightIndex + 1) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
highlightIndex = (highlightIndex - 1 + flatFiltered.length) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
event.preventDefault();
|
||||
const item = flatFiltered[highlightIndex];
|
||||
if (item && !item.disabled) {
|
||||
onselect(item.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleItemClick(item: EntityPickerItem) {
|
||||
if (item.disabled) return;
|
||||
onselect(item.value);
|
||||
}
|
||||
|
||||
/** Track the flat index across groups for highlight matching. */
|
||||
function flatIndexOf(groupIdx: number, itemIdx: number): number {
|
||||
let index = 0;
|
||||
for (let g = 0; g < groupIdx; g++) {
|
||||
index += grouped[g].items.length;
|
||||
}
|
||||
return index + itemIdx;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="entity-picker-backdrop"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="entity-picker-container" onkeydown={handleKeydown}>
|
||||
<div class="entity-picker-modal" role="dialog" aria-modal="true" aria-label={title}>
|
||||
{#if title}
|
||||
<div class="entity-picker-header">
|
||||
<h2 class="entity-picker-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="entity-picker-close"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="entity-picker-search">
|
||||
<IconSearch size={16} class="entity-picker-search-icon" />
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
type="text"
|
||||
class="entity-picker-search-input"
|
||||
placeholder={placeholder ?? $t('entityPicker.search')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if !title}
|
||||
<button
|
||||
type="button"
|
||||
class="entity-picker-close-inline"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="entity-picker-list" bind:this={listEl}>
|
||||
{#if flatFiltered.length === 0}
|
||||
<div class="entity-picker-empty">
|
||||
{$t('entityPicker.noResults')}
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group, gIdx}
|
||||
{#if group.name}
|
||||
<div class="entity-picker-group-header">{group.name}</div>
|
||||
{/if}
|
||||
{#each group.items as item, iIdx}
|
||||
{@const flatIdx = flatIndexOf(gIdx, iIdx)}
|
||||
{@const isHighlighted = flatIdx === highlightIndex}
|
||||
{@const isCurrent = item.value === current}
|
||||
<button
|
||||
type="button"
|
||||
class="entity-picker-item"
|
||||
class:entity-picker-item--highlighted={isHighlighted && !item.disabled}
|
||||
class:entity-picker-item--current={isCurrent}
|
||||
class:entity-picker-item--disabled={item.disabled}
|
||||
data-highlighted={isHighlighted}
|
||||
onclick={() => handleItemClick(item)}
|
||||
onmouseenter={() => { highlightIndex = flatIdx; }}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="entity-picker-item-icon">{@html item.icon}</span>
|
||||
{/if}
|
||||
<span class="entity-picker-item-content">
|
||||
<span class="entity-picker-item-label">{item.label}</span>
|
||||
{#if item.disabledHint}
|
||||
<span class="entity-picker-item-hint">{item.disabledHint}</span>
|
||||
{:else if item.description}
|
||||
<span class="entity-picker-item-description">{item.description}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.entity-picker-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: var(--surface-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fade-in var(--transition-normal) forwards;
|
||||
}
|
||||
|
||||
.entity-picker-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 61;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 12vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity-picker-modal {
|
||||
pointer-events: auto;
|
||||
width: 90vw;
|
||||
max-width: 500px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
overflow: hidden;
|
||||
animation: scale-in var(--transition-normal) forwards;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entity-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.entity-picker-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entity-picker-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.entity-picker-close:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entity-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
:global(.entity-picker-search-icon) {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entity-picker-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
.entity-picker-search-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entity-picker-close-inline {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.entity-picker-close-inline:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entity-picker-list {
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.entity-picker-empty {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entity-picker-group-header {
|
||||
padding: var(--space-2) var(--space-4) var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.entity-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--font-family-sans);
|
||||
transition: background var(--transition-fast);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.entity-picker-item--highlighted {
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
.entity-picker-item--current {
|
||||
border-left-color: var(--color-brand-500);
|
||||
}
|
||||
|
||||
.entity-picker-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entity-picker-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-picker-item-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entity-picker-item-description {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entity-picker-item--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity-picker-item-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
Task 6: Consistent form field with design tokens.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
helpText?: string;
|
||||
oninput?: (e: Event) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
required = false,
|
||||
disabled = false,
|
||||
error = '',
|
||||
helpText = '',
|
||||
oninput
|
||||
}: Props = $props();
|
||||
|
||||
const inputBase = 'w-full rounded-lg border px-3 py-2 text-sm transition-all duration-150 focus:outline-none focus:ring-2 bg-[var(--surface-input)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)]';
|
||||
const inputNormal = 'border-[var(--border-input)] focus:ring-[var(--color-brand-500)] focus:border-[var(--color-brand-500)]';
|
||||
const inputError = 'border-[var(--color-danger)] focus:ring-[var(--color-danger)]';
|
||||
const inputDisabled = 'opacity-60 cursor-not-allowed bg-[var(--surface-card-hover)]';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for={name} class="text-sm font-medium text-[var(--text-primary)]">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-[var(--color-danger)]">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if type === 'textarea'}
|
||||
<textarea
|
||||
id={name}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{oninput}
|
||||
class="{inputBase} {error ? inputError : inputNormal} {disabled ? inputDisabled : ''}"
|
||||
rows="3"
|
||||
></textarea>
|
||||
{:else}
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
{type}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{oninput}
|
||||
class="{inputBase} {error ? inputError : inputNormal} {disabled ? inputDisabled : ''}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if helpText && !error}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{helpText}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!--
|
||||
Task 5: Instance card with inline status badges, icon action buttons, improved layout.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Instance } from '$lib/types';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import * as api from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
instance: Instance;
|
||||
projectId: string;
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
const { instance, projectId, onchange }: Props = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
||||
|
||||
const subdomainUrl = $derived(
|
||||
instance.subdomain ? `https://${instance.subdomain}` : ''
|
||||
);
|
||||
|
||||
const timeSinceCreated = $derived(() => {
|
||||
const created = new Date(instance.created_at);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - created.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
});
|
||||
|
||||
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
|
||||
loading = true;
|
||||
error = '';
|
||||
confirmAction = null;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'stop':
|
||||
await api.stopInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'start':
|
||||
await api.startInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'restart':
|
||||
await api.restartInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
case 'remove':
|
||||
await api.removeInstance(projectId, instance.stage_id, instance.id);
|
||||
break;
|
||||
}
|
||||
onchange?.();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('instance.actionFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
|
||||
confirmAction = action;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
|
||||
{instance.image_tag}
|
||||
</span>
|
||||
<StatusBadge status={instance.status} size="sm" />
|
||||
</div>
|
||||
|
||||
{#if subdomainUrl}
|
||||
<a
|
||||
href={subdomainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1.5 inline-flex items-center gap-1 text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
||||
>
|
||||
{instance.subdomain}
|
||||
<IconExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
|
||||
<span>{timeSinceCreated()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="ml-3 flex items-center gap-1">
|
||||
{#if instance.status === 'running'}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-amber-50 hover:text-amber-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
title={$t('common.stop')}
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('stop')}
|
||||
>
|
||||
<IconStop size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
title={$t('common.restart')}
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('restart')}
|
||||
>
|
||||
<IconRestart size={16} />
|
||||
</button>
|
||||
{:else if instance.status === 'stopped'}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
title={$t('common.start')}
|
||||
disabled={loading}
|
||||
onclick={() => handleAction('start')}
|
||||
>
|
||||
<IconPlay size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
title={$t('common.remove')}
|
||||
disabled={loading}
|
||||
onclick={() => requestConfirm('remove')}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmAction !== null}
|
||||
title={confirmAction ? $t(`confirm.${confirmAction}Instance`) : ''}
|
||||
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
|
||||
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
|
||||
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
||||
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}
|
||||
oncancel={() => { confirmAction = null; }}
|
||||
/>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
Task 13: Locale switcher component.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { locale, setLocale, availableLocales, type Locale } from '$lib/i18n';
|
||||
import { IconGlobe } from '$lib/components/icons';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<IconGlobe size={14} class="text-[var(--text-tertiary)]" />
|
||||
<div class="flex items-center gap-0.5 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5">
|
||||
{#each availableLocales as loc}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-0.5 text-xs font-medium transition-all duration-150
|
||||
{$locale === loc
|
||||
? 'bg-[var(--color-brand-100)] text-[var(--color-brand-700)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
|
||||
onclick={() => setLocale(loc)}
|
||||
>
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!--
|
||||
Task 4: Redesigned project card with status indicators, instance count badges, hover effects.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Project, Instance } from '$lib/types';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconContainer, IconBox } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
instances?: Instance[];
|
||||
}
|
||||
|
||||
const { project, instances = [] }: Props = $props();
|
||||
|
||||
const runningCount = $derived(instances.filter((i) => i.status === 'running').length);
|
||||
const stoppedCount = $derived(instances.filter((i) => i.status === 'stopped').length);
|
||||
const failedCount = $derived(instances.filter((i) => i.status === 'failed').length);
|
||||
const totalCount = $derived(instances.length);
|
||||
|
||||
const overallStatus = $derived<string>(() => {
|
||||
if (failedCount > 0) return 'failed';
|
||||
if (runningCount > 0) return 'running';
|
||||
if (stoppedCount > 0) return 'stopped';
|
||||
return 'stopped';
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="group block rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)] hover:-translate-y-0.5"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-50)] text-[var(--color-brand-600)] transition-colors group-hover:bg-[var(--color-brand-100)]">
|
||||
<IconBox size={16} />
|
||||
</div>
|
||||
<h3 class="truncate text-base font-semibold text-[var(--text-primary)]">{project.name}</h3>
|
||||
</div>
|
||||
<p class="mt-2 truncate font-mono text-xs text-[var(--text-tertiary)]">{project.image}</p>
|
||||
</div>
|
||||
<StatusBadge status={overallStatus()} size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Instance count badges -->
|
||||
<div class="mt-4 flex items-center gap-3 text-sm">
|
||||
{#if totalCount > 0}
|
||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-xs font-medium">{runningCount}</span>
|
||||
</span>
|
||||
{#if stoppedCount > 0}
|
||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
||||
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
<span class="text-xs font-medium">{stoppedCount}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if failedCount > 0}
|
||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
<span class="text-xs font-medium">{failedCount}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">
|
||||
{totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="mt-3 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
||||
{#if project.port}
|
||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{project.port}</span>
|
||||
{/if}
|
||||
{#if project.healthcheck}
|
||||
<span class="truncate">{project.healthcheck}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
Task 8: Skeleton loader component for data fetching states.
|
||||
Renders a shimmering placeholder in any size/shape.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
class: className = '',
|
||||
width = '100%',
|
||||
height = '1rem',
|
||||
rounded = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton {className}"
|
||||
style="width: {width}; height: {height}; {rounded ? 'border-radius: 9999px;' : ''}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Task 8: Skeleton card placeholder for project cards during loading.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Skeleton from './Skeleton.svelte';
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="60%" height="1.25rem" />
|
||||
<Skeleton width="80%" height="0.875rem" />
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1.5rem" rounded />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<Skeleton width="5rem" height="0.875rem" />
|
||||
<Skeleton width="4rem" height="0.875rem" />
|
||||
</div>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<Skeleton width="3.5rem" height="0.75rem" />
|
||||
<Skeleton width="4.5rem" height="0.75rem" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
Task 8: Skeleton table placeholder for list views during loading.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Skeleton from './Skeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
const { rows = 5, cols = 4 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<div class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-6 py-3">
|
||||
<div class="flex gap-6">
|
||||
{#each Array(cols) as _, i}
|
||||
<Skeleton width={i === 0 ? '6rem' : '5rem'} height="0.75rem" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#each Array(rows) as _, i}
|
||||
<div class="flex gap-6 border-b border-[var(--border-secondary)] px-6 py-4 last:border-b-0">
|
||||
{#each Array(cols) as _, j}
|
||||
<Skeleton width={j === 0 ? '40%' : '20%'} height="0.875rem" />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!--
|
||||
Task 5, 11: Status badge with pulse animation for "running" status.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { InstanceStatus, DeployStatus } from '$lib/types';
|
||||
|
||||
type Status = InstanceStatus | DeployStatus | string;
|
||||
|
||||
interface Props {
|
||||
status: Status;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const { status, size = 'md' }: Props = $props();
|
||||
|
||||
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
running: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
success: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
stopped: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' },
|
||||
failed: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
|
||||
rolled_back: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
|
||||
removing: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
pending: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
|
||||
pulling: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
|
||||
starting: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
configuring_proxy: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
health_checking: { bg: 'bg-violet-50 dark:bg-violet-950', text: 'text-violet-700 dark:text-violet-300', dot: 'bg-violet-500' }
|
||||
};
|
||||
|
||||
const fallback = { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' };
|
||||
|
||||
const colors = $derived(colorMap[status] ?? fallback);
|
||||
const sizeClass = $derived(size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-0.5');
|
||||
const dotSize = $derived(size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2');
|
||||
const label = $derived(status.replace(/_/g, ' '));
|
||||
const isAnimated = $derived(status === 'running' || status === 'pulling' || status === 'starting' || status === 'health_checking');
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full font-medium {colors.bg} {colors.text} {sizeClass}">
|
||||
<span class="relative flex {dotSize}">
|
||||
{#if isAnimated}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {colors.dot} opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex rounded-full {dotSize} {colors.dot}"></span>
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user