fix: comprehensive security, performance, and quality hardening
Security: apply AdminOnly middleware to mutating routes, require ENCRYPTION_KEY and ADMIN_PASSWORD (no insecure defaults), restrict CORS to same-origin, fix OIDC token delivery via cookie instead of URL query param, add rate limiting on login, add MaxBytesReader, validate volume paths against traversal, add security headers, validate user roles, add Secure flag to OIDC cookie. Performance: set SQLite MaxOpenConns(1) to prevent SQLITE_BUSY, add FK indexes on 8 columns, track notifier goroutines with WaitGroup for graceful shutdown, use GetRegistryByName instead of GetAllRegistries in deployer, pass basePath param to avoid redundant settings query, return empty slices from store to remove reflection. Quality: refactor TriggerDeploy to delegate to runDeploy (~100 lines removed), consolidate duplicated utilities (extractPort, boolToInt, now, isTerminalStatus) into shared exports, migrate all log.Printf to slog structured logging, use consistent webhook response envelope, remove dead code (parseEnvVars, duplicate auth types). UX: clean up NPM proxy on instance removal via API, add README with quickstart guide, add .env.example, require ADMIN_PASSWORD in docker-compose, document staging-net prerequisite.
This commit is contained in:
+12
-113
@@ -192,8 +192,7 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
|
||||
}
|
||||
|
||||
// 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).
|
||||
// It validates inputs, creates a deploy record, and delegates to runDeploy.
|
||||
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")
|
||||
@@ -202,7 +201,6 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
|
||||
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)
|
||||
@@ -213,17 +211,10 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
|
||||
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,
|
||||
@@ -234,69 +225,9 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
|
||||
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.
|
||||
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -351,7 +282,7 @@ func (d *Deployer) executeDeploy(
|
||||
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)
|
||||
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
|
||||
|
||||
containerCfg := docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
@@ -597,25 +528,18 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
registries, err := d.store.GetAllRegistries()
|
||||
reg, err := d.store.GetRegistryByName(project.Registry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get registries: %w", err)
|
||||
return "", fmt.Errorf("get registry %s: %w", project.Registry, 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
|
||||
if reg.Token != "" {
|
||||
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt registry token: %w", err)
|
||||
}
|
||||
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -628,25 +552,6 @@ func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error)
|
||||
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)
|
||||
@@ -696,7 +601,7 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
|
||||
// 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 {
|
||||
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount {
|
||||
vols, err := d.store.GetVolumesByProjectID(projectID)
|
||||
if err != nil {
|
||||
slog.Warn("get project volumes", "project_id", projectID, "error", err)
|
||||
@@ -707,12 +612,6 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user