From d4659146fcb955e7f891a81a196273dfa653031e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Mar 2026 23:28:59 +0300 Subject: [PATCH] feat(docker-watcher): phase 13 - volumes & environment Per-stage env var overrides with encryption for secrets. Volume mounts with shared/isolated modes (isolated appends /{stage}-{tag}/ to source path). Store CRUD, API endpoints, and frontend editors for both. Env merge during deploy. --- PLAN.md | 29 +- internal/api/router.go | 12 + internal/api/stage_env.go | 176 ++++++++ internal/api/volumes.go | 143 +++++++ internal/deployer/bluegreen.go | 4 +- internal/deployer/deployer.go | 81 +++- internal/docker/container.go | 5 + internal/store/models.go | 22 + internal/store/stage_env.go | 112 ++++++ internal/store/store.go | 21 + internal/store/volumes.go | 112 ++++++ plans/docker-watcher-core/PLAN.md | 6 +- web/src/lib/api.ts | 63 ++- web/src/lib/types.ts | 22 + web/src/routes/projects/[id]/+page.svelte | 16 + web/src/routes/projects/[id]/env/+page.svelte | 380 ++++++++++++++++++ .../routes/projects/[id]/volumes/+page.svelte | 269 +++++++++++++ 17 files changed, 1466 insertions(+), 7 deletions(-) create mode 100644 internal/api/stage_env.go create mode 100644 internal/api/volumes.go create mode 100644 internal/store/stage_env.go create mode 100644 internal/store/volumes.go create mode 100644 web/src/routes/projects/[id]/env/+page.svelte create mode 100644 web/src/routes/projects/[id]/volumes/+page.svelte diff --git a/PLAN.md b/PLAN.md index 8bca141..836fc8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -287,7 +287,7 @@ Full dashboard for visibility, manual control, and configuration. 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: Volumes & Environment +### Phase 4: Volumes & Environment (Phase 13) -- COMPLETED Persistent storage and app-specific configuration for deployed containers. @@ -298,6 +298,21 @@ Persistent storage and app-specific configuration for deployed containers. 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: @@ -405,6 +420,18 @@ 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) diff --git a/internal/api/router.go b/internal/api/router.go index bc4ece4..f532386 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -118,6 +118,12 @@ func (s *Server) Router() chi.Router { 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) @@ -127,6 +133,12 @@ func (s *Server) Router() chi.Router { 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. diff --git a/internal/api/stage_env.go b/internal/api/stage_env.go new file mode 100644 index 0000000..5d8e746 --- /dev/null +++ b/internal/api/stage_env.go @@ -0,0 +1,176 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" +) + +// stageEnvRequest is the expected JSON body for creating/updating a stage env override. +type stageEnvRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Encrypted *bool `json:"encrypted"` +} + +// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env. +func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + envs, err := s.store.GetStageEnvByStageID(stageID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list stage env: "+err.Error()) + return + } + + // Mask encrypted values in the response. + masked := make([]store.StageEnv, len(envs)) + for i, env := range envs { + masked[i] = env + if env.Encrypted { + masked[i].Value = "••••••••" + } + } + + respondJSON(w, http.StatusOK, masked) +} + +// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env. +func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req stageEnvRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Key == "" { + respondError(w, http.StatusBadRequest, "key is required") + return + } + + encrypted := false + if req.Encrypted != nil { + encrypted = *req.Encrypted + } + + value := req.Value + if encrypted && value != "" { + enc, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error()) + return + } + value = enc + } + + env, err := s.store.CreateStageEnv(store.StageEnv{ + StageID: stageID, + Key: req.Key, + Value: value, + Encrypted: encrypted, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage env: "+err.Error()) + return + } + + // Mask encrypted value in the response. + if env.Encrypted { + env.Value = "••••••••" + } + + respondJSON(w, http.StatusCreated, env) +} + +// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}. +func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) { + envID := chi.URLParam(r, "envId") + + existing, err := s.store.GetStageEnvByID(envID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage env") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage env: "+err.Error()) + return + } + + var req stageEnvRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Key != "" { + updated.Key = req.Key + } + if req.Encrypted != nil { + updated.Encrypted = *req.Encrypted + } + + // Only update value if provided (allows updating key/encrypted without changing the value). + if req.Value != "" { + value := req.Value + if updated.Encrypted { + enc, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error()) + return + } + value = enc + } + updated.Value = value + } + + if err := s.store.UpdateStageEnv(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update stage env: "+err.Error()) + return + } + + // Mask encrypted value in the response. + if updated.Encrypted { + updated.Value = "••••••••" + } + + respondJSON(w, http.StatusOK, updated) +} + +// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}. +func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) { + envID := chi.URLParam(r, "envId") + if err := s.store.DeleteStageEnv(envID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage env") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete stage env: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": envID}) +} diff --git a/internal/api/volumes.go b/internal/api/volumes.go new file mode 100644 index 0000000..01c52e6 --- /dev/null +++ b/internal/api/volumes.go @@ -0,0 +1,143 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// volumeRequest is the expected JSON body for creating/updating a volume. +type volumeRequest struct { + Source string `json:"source"` + Target string `json:"target"` + Mode string `json:"mode"` +} + +// listVolumes handles GET /api/projects/{id}/volumes. +func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + vols, err := s.store.GetVolumesByProjectID(projectID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, vols) +} + +// createVolume handles POST /api/projects/{id}/volumes. +func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + var req volumeRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Source == "" { + respondError(w, http.StatusBadRequest, "source is required") + return + } + if req.Target == "" { + respondError(w, http.StatusBadRequest, "target is required") + return + } + if req.Mode == "" { + req.Mode = "shared" + } + if req.Mode != "shared" && req.Mode != "isolated" { + respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + return + } + + vol, err := s.store.CreateVolume(store.Volume{ + ProjectID: projectID, + Source: req.Source, + Target: req.Target, + Mode: req.Mode, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, vol) +} + +// updateVolume handles PUT /api/projects/{id}/volumes/{volId}. +func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { + volID := chi.URLParam(r, "volId") + + existing, err := s.store.GetVolumeByID(volID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "volume") + return + } + respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error()) + return + } + + var req volumeRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Source != "" { + updated.Source = req.Source + } + if req.Target != "" { + updated.Target = req.Target + } + if req.Mode != "" { + if req.Mode != "shared" && req.Mode != "isolated" { + respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + return + } + updated.Mode = req.Mode + } + + if err := s.store.UpdateVolume(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}. +func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) { + volID := chi.URLParam(r, "volId") + if err := s.store.DeleteVolume(volID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "volume") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) +} diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index d7c4089..84bf6b4 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -73,7 +73,8 @@ func (d *Deployer) blueGreenDeploy( subdomain := d.buildSubdomain(project, stage, settings, imageTag) containerName := docker.ContainerName(project.Name, stage.Name, imageTag) portStr := fmt.Sprintf("%d/tcp", project.Port) - envVars := d.parseEnvVars(project.Env) + envVars := d.mergeEnvVars(project, stage.ID) + mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag) containerCfg := docker.ContainerConfig{ Name: containerName, @@ -85,6 +86,7 @@ func (d *Deployer) blueGreenDeploy( Project: project.Name, Stage: stage.Name, InstanceID: instanceID, + Mounts: mounts, } d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info") diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 2d1597d..e88d970 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "path/filepath" "sort" "sync" "sync/atomic" @@ -16,6 +17,7 @@ import ( "github.com/alexei/docker-watcher/internal/notify" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" + "github.com/docker/docker/api/types/mount" "github.com/google/uuid" ) @@ -229,7 +231,8 @@ func (d *Deployer) executeDeploy( containerName := docker.ContainerName(project.Name, stage.Name, imageTag) portStr := fmt.Sprintf("%d/tcp", project.Port) - envVars := d.parseEnvVars(project.Env) + envVars := d.mergeEnvVars(project, stage.ID) + mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag) containerCfg := docker.ContainerConfig{ Name: containerName, @@ -241,6 +244,7 @@ func (d *Deployer) executeDeploy( Project: project.Name, Stage: stage.Name, InstanceID: instanceID, + Mounts: mounts, } d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info") @@ -524,6 +528,81 @@ func (d *Deployer) parseEnvVars(envJSON string) []string { 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 + } + + mounts := make([]mount.Mount, 0, len(vols)) + for _, vol := range vols { + source := vol.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) { diff --git a/internal/docker/container.go b/internal/docker/container.go index ce61f4d..bafa887 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -9,6 +9,7 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/go-connections/nat" ) @@ -46,6 +47,9 @@ type ContainerConfig struct { // 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 @@ -94,6 +98,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri hostCfg := &container.HostConfig{ PortBindings: portBindings, RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled}, + Mounts: cfg.Mounts, } // Attach to network at creation time if specified. diff --git a/internal/store/models.go b/internal/store/models.go index 11d8eb1..4f44b43 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -91,3 +91,25 @@ type DeployLog struct { Level string `json:"level"` // info, warn, error CreatedAt string `json:"created_at"` } + +// StageEnv represents a per-stage environment variable override. +type StageEnv struct { + ID string `json:"id"` + StageID string `json:"stage_id"` + Key string `json:"key"` + Value string `json:"value"` + Encrypted bool `json:"encrypted"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Volume represents a volume mount configuration for a project. +type Volume struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Source string `json:"source"` + Target string `json:"target"` + Mode string `json:"mode"` // shared or isolated + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/store/stage_env.go b/internal/store/stage_env.go new file mode 100644 index 0000000..cddd707 --- /dev/null +++ b/internal/store/stage_env.go @@ -0,0 +1,112 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateStageEnv inserts a new stage environment variable override. +func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) { + env.ID = uuid.New().String() + env.CreatedAt = now() + env.UpdatedAt = env.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + env.ID, env.StageID, env.Key, env.Value, boolToInt(env.Encrypted), + env.CreatedAt, env.UpdatedAt, + ) + if err != nil { + return StageEnv{}, fmt.Errorf("insert stage env: %w", err) + } + return env, nil +} + +// GetStageEnvByStageID returns all environment variable overrides for a stage. +func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) { + rows, err := s.db.Query( + `SELECT id, stage_id, key, value, encrypted, created_at, updated_at + FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID, + ) + if err != nil { + return nil, fmt.Errorf("query stage env: %w", err) + } + defer rows.Close() + + var envs []StageEnv + for rows.Next() { + env, err := scanStageEnv(rows) + if err != nil { + return nil, err + } + envs = append(envs, env) + } + return envs, rows.Err() +} + +// GetStageEnvByID returns a single stage env override by ID. +func (s *Store) GetStageEnvByID(id string) (StageEnv, error) { + var env StageEnv + var encrypted int + err := s.db.QueryRow( + `SELECT id, stage_id, key, value, encrypted, created_at, updated_at + FROM stage_env WHERE id = ?`, id, + ).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, + &env.CreatedAt, &env.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound) + } + if err != nil { + return StageEnv{}, fmt.Errorf("query stage env: %w", err) + } + env.Encrypted = encrypted != 0 + return env, nil +} + +// UpdateStageEnv updates an existing stage environment variable override. +func (s *Store) UpdateStageEnv(env StageEnv) error { + env.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=? + WHERE id=?`, + env.Key, env.Value, boolToInt(env.Encrypted), env.UpdatedAt, env.ID, + ) + if err != nil { + return fmt.Errorf("update stage env: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound) + } + return nil +} + +// DeleteStageEnv removes a stage env override by ID. +func (s *Store) DeleteStageEnv(id string) error { + result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete stage env: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("stage env %s: %w", id, ErrNotFound) + } + return nil +} + +// scanStageEnv scans a stage env row from a *sql.Rows cursor. +func scanStageEnv(rows *sql.Rows) (StageEnv, error) { + var env StageEnv + var encrypted int + err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted, + &env.CreatedAt, &env.UpdatedAt) + if err != nil { + return StageEnv{}, fmt.Errorf("scan stage env: %w", err) + } + env.Encrypted = encrypted != 0 + return env, nil +} diff --git a/internal/store/store.go b/internal/store/store.go index e48be54..0a3e7bf 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -180,6 +180,27 @@ 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. diff --git a/internal/store/volumes.go b/internal/store/volumes.go new file mode 100644 index 0000000..3ab1acd --- /dev/null +++ b/internal/store/volumes.go @@ -0,0 +1,112 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateVolume inserts a new volume configuration for a project. +func (s *Store) CreateVolume(vol Volume) (Volume, error) { + vol.ID = uuid.New().String() + vol.CreatedAt = now() + vol.UpdatedAt = vol.CreatedAt + + if vol.Mode == "" { + vol.Mode = "shared" + } + + _, err := s.db.Exec( + `INSERT INTO volumes (id, project_id, source, target, mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode, + vol.CreatedAt, vol.UpdatedAt, + ) + if err != nil { + return Volume{}, fmt.Errorf("insert volume: %w", err) + } + return vol, nil +} + +// GetVolumesByProjectID returns all volume configurations for a project. +func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { + rows, err := s.db.Query( + `SELECT id, project_id, source, target, mode, created_at, updated_at + FROM volumes WHERE project_id = ? ORDER BY target`, projectID, + ) + if err != nil { + return nil, fmt.Errorf("query volumes: %w", err) + } + defer rows.Close() + + var vols []Volume + for rows.Next() { + vol, err := scanVolume(rows) + if err != nil { + return nil, err + } + vols = append(vols, vol) + } + return vols, rows.Err() +} + +// GetVolumeByID returns a single volume by its ID. +func (s *Store) GetVolumeByID(id string) (Volume, error) { + var vol Volume + err := s.db.QueryRow( + `SELECT id, project_id, source, target, mode, created_at, updated_at + FROM volumes WHERE id = ?`, id, + ).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, + &vol.CreatedAt, &vol.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound) + } + if err != nil { + return Volume{}, fmt.Errorf("query volume: %w", err) + } + return vol, nil +} + +// UpdateVolume updates an existing volume configuration. +func (s *Store) UpdateVolume(vol Volume) error { + vol.UpdatedAt = now() + result, err := s.db.Exec( + `UPDATE volumes SET source=?, target=?, mode=?, updated_at=? + WHERE id=?`, + vol.Source, vol.Target, vol.Mode, vol.UpdatedAt, vol.ID, + ) + if err != nil { + return fmt.Errorf("update volume: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound) + } + return nil +} + +// DeleteVolume removes a volume configuration by ID. +func (s *Store) DeleteVolume(id string) error { + result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete volume: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("volume %s: %w", id, ErrNotFound) + } + return nil +} + +// scanVolume scans a volume row from a *sql.Rows cursor. +func scanVolume(rows *sql.Rows) (Volume, error) { + var vol Volume + err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, + &vol.CreatedAt, &vol.UpdatedAt) + if err != nil { + return Volume{}, fmt.Errorf("scan volume: %w", err) + } + return vol, nil +} diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 9bc5de9..41f8ca0 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -35,7 +35,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [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) -- [ ] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) +- [x] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md) - [ ] Phase 14: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md) ### Parallel Execution Notes @@ -58,8 +58,8 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | 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 | ⬜ Not Started | ⬜ | ⏭️ 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 | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ | ## Amendment Log diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 464bb62..15c30f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,7 +7,9 @@ import type { Project, ProjectDetail, Registry, - Settings + Settings, + StageEnv, + Volume } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -237,4 +239,63 @@ export function exportConfigUrl(): string { return '/api/config/export'; } +// ── Stage Env Overrides ────────────────────────────────────────────── + +export function listStageEnv(projectId: string, stageId: string): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/env`); +} + +export function createStageEnv( + projectId: string, + stageId: string, + data: { key: string; value: string; encrypted?: boolean } +): Promise { + return post(`/api/projects/${projectId}/stages/${stageId}/env`, data); +} + +export function updateStageEnv( + projectId: string, + stageId: string, + envId: string, + data: { key?: string; value?: string; encrypted?: boolean } +): Promise { + return put(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data); +} + +export function deleteStageEnv( + projectId: string, + stageId: string, + envId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`); +} + +// ── Volumes ────────────────────────────────────────────────────────── + +export function listVolumes(projectId: string): Promise { + return get(`/api/projects/${projectId}/volumes`); +} + +export function createVolume( + projectId: string, + data: { source: string; target: string; mode?: string } +): Promise { + return post(`/api/projects/${projectId}/volumes`, data); +} + +export function updateVolume( + projectId: string, + volId: string, + data: { source?: string; target?: string; mode?: string } +): Promise { + return put(`/api/projects/${projectId}/volumes/${volId}`, data); +} + +export function deleteVolume( + projectId: string, + volId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); +} + export { ApiError }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ef83665..5955ad0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -116,3 +116,25 @@ export interface InspectResult { port: number; healthcheck: string; } + +/** Stage environment variable override. */ +export interface StageEnv { + id: string; + stage_id: string; + key: string; + value: string; + encrypted: boolean; + created_at: string; + updated_at: string; +} + +/** Volume mount configuration for a project. */ +export interface Volume { + id: string; + project_id: string; + source: string; + target: string; + mode: 'shared' | 'isolated'; + created_at: string; + updated_at: string; +} diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index c73f071..be3e534 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -155,6 +155,22 @@ + + +
diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte new file mode 100644 index 0000000..3edd5a7 --- /dev/null +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -0,0 +1,380 @@ + + + + Environment Variables - Docker Watcher + + +
+ +
+ Project + / +

Environment Variables

+
+

+ Manage per-stage environment variable overrides. Stage-level values override project-level defaults. +

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

{error}

+
+ {:else} + +
+ + +
+ + {#if stages.length === 0} +
+

No stages configured. Add stages to the project first.

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

Project-Level Defaults

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

Stage Overrides

+ + {#if envLoading} +
+
+
+ {:else} +
+ + + + + + + + + + + + {#each envVars as env (env.id)} + {#if editingId === env.id} + + + + + + + + {:else} + + + + + + + + {/if} + {/each} + + + + + + + + + + +
KeyValueSecretSourceActions
+ + + + + + + + +
{env.key} + {env.encrypted ? '••••••••' : env.value} + + {#if env.encrypted} + + secret + + {/if} + + {#if isInherited(env.key)} + + overrides project + + {:else} + + stage only + + {/if} + + + +
+ + + + + + + +
+
+ {/if} +
+ {/if} + {/if} +
diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte new file mode 100644 index 0000000..7560c30 --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -0,0 +1,269 @@ + + + + Volumes - Docker Watcher + + +
+ +
+ Project + / +

Volume Mounts

+
+

+ Configure volume mounts for containers. + Shared mode uses the source path as-is for all instances. + Isolated mode appends /{'{'}stage{'}'}-{'{'}tag{'}'}/ to the source, giving each instance its own directory. +

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

{error}

+ +
+ {:else} +
+ + + + + + + + + + + {#each volumes as vol (vol.id)} + {#if editingId === vol.id} + + + + + + + {:else} + + + + + + + {/if} + {/each} + + + + + + + + + +
Source (Host)Target (Container)ModeActions
+ + + + + + + + +
{vol.source}{vol.target} + {#if vol.mode === 'shared'} + + shared + + {:else} + + isolated + + {/if} + + + +
+ + + + + + + +
+
+ + {#if volumes.length === 0} +

No volumes configured yet. Add one above.

+ {/if} + {/if} +