fix(CTRL-T2-1,H10): crash-safe deploy state + fail-closed secret encryption

deploy.go, one slice (both edit SaveAppConfig / the deploy goroutine):

CTRL-T2-1 (ghost-deployed on crash): DeployStack wrote app.yaml Deployed:true to
disk BEFORE the async 'docker compose up -d'; a crash during the image-pull
window left a ghost-deployed stack (Deployed:true, no containers) that DeployStack
then refused to redeploy. Now the env is persisted with Deployed:false
(transitional), and Deployed:true is written by runComposeDeploy ONLY after up -d
succeeds. In-memory Deployed stays true during the pull to preserve the
no-stale-Telepítés-button UX. On a post-success save failure, revert so the stack
is redeployable.

H10 (plaintext secret on encrypt failure): SaveAppConfig logged a WARN then fell
through to persist the secret in PLAINTEXT. Now fail-closed: return an error on
crypto.Encrypt failure, never write plaintext. Callers already propagate it.

Regression tests: H10 fail-closed (+ good-key encrypts) and the CTRL-T2-1
transitional durable-state contract (transitional reads not-deployed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 19:13:58 +02:00
parent c20ff56e4a
commit 5a80739799
2 changed files with 142 additions and 10 deletions
+41 -10
View File
@@ -291,15 +291,24 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
}
}
// Save app.yaml
// Save app.yaml.
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
// A crash/power-loss during the image-pull window must NOT leave a
// ghost-deployed stack on disk (Deployed:true with no containers), which
// DeployStack would then refuse to redeploy. The IN-MEMORY Deployed flag is
// still set true below to preserve the "no stale Telepítés button during
// pull" UX; only the durable record waits for success.
appCfg := &AppConfig{
Deployed: true,
Deployed: true, // in-memory truth (see below); the disk write overrides to false
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
LockedFields: lockedFields,
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
diskCfg := *appCfg
diskCfg.Deployed = false // transitional: env saved, not yet marked deployed
if err := SaveAppConfig(stackDir, &diskCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
clearDeploying()
return "", fmt.Errorf("saving app config: %w", err)
}
@@ -359,6 +368,25 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
m.logger.Printf("[INFO] [stacks] Stack %s deployed successfully (took %.1fs)", name, time.Since(start).Seconds())
// CTRL-T2-1: compose up -d succeeded — only NOW mark deployed on disk.
// (DeployStack wrote the env with Deployed:false; flip it true here so the
// durable record matches reality and survives a restart.)
meta := LoadMetadata(stackDir)
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
// Running but not durably recorded as deployed. Revert so the customer
// can cleanly redeploy rather than be stuck with a half-recorded stack.
m.logger.Printf("[ERROR] [stacks] Stack %s: compose succeeded but persisting deployed state failed: %v — reverting", name, err)
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.Deployed = false
s.Deploying = false
s.DeployError = "deploy succeeded but state could not be saved: " + err.Error()
s.AppConfig = nil
}
m.mu.Unlock()
return
}
// Clear deploying flag
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
@@ -653,14 +681,17 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
}
for k, v := range cfg.Env {
if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" {
if enc, err := crypto.Encrypt(encKey, v); err == nil {
saveCfg.Env[k] = enc
encryptedCount++
continue
} else {
// H10 fix: log encryption failure — value will be saved in plaintext.
log.Printf("[WARN] [stacks] Failed to encrypt env var %q: %v — saving as plaintext", k, err)
enc, err := crypto.Encrypt(encKey, v)
if err != nil {
// H10 (fail-closed): NEVER persist a sensitive value in plaintext.
// Earlier code logged a WARN and fell through to a plaintext write;
// that leaked the secret to disk. Abort the save instead — callers
// already propagate this error and the deploy fails cleanly.
return fmt.Errorf("encrypting sensitive env var %q (refusing to persist plaintext): %w", k, err)
}
saveCfg.Env[k] = enc
encryptedCount++
continue
}
saveCfg.Env[k] = v
}