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.
This commit is contained in:
2026-03-27 23:28:59 +03:00
parent 32de5b26a8
commit d4659146fc
17 changed files with 1466 additions and 7 deletions
+3 -1
View File
@@ -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")
+80 -1
View File
@@ -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) {