diff --git a/internal/engine/secretspush.go b/internal/engine/secretspush.go index b00488c6..a7f8caaf 100644 --- a/internal/engine/secretspush.go +++ b/internal/engine/secretspush.go @@ -301,6 +301,20 @@ func (e *Engine) currentSecretEngine(ctx context.Context, current string) (*Engi return releaseEngine, nil } +// DeployedSecretGeneration reads the active generation against the immutable +// declaration graph that produced the deployed release. A new project may add +// or remove secret-consuming workloads; using its graph to inspect the old +// runtime mistakes that intentional transition for partial live state. +func (e *Engine) DeployedSecretGeneration(ctx context.Context, releaseID string, composeBytes []byte) (string, error) { + releaseEngine, err := e.engineFromReleaseSnapshotFor(ctx, releaseID, "deploy planning") + if err != nil { + return "", err + } + graph := releaseEngine.Spec.SecretDeclarationGraph() + workloads := affectedLiveSecretWorkloads(releaseEngine.Spec, graph) + return app.SecretGenerationFromCompose(composeBytes, workloads) +} + func (e *Engine) cleanupSecretUploads(ctx context.Context) error { return e.mutateChecked(ctx, "clean abandoned secret uploads", "rm -rf "+q(e.base()+"/.secret-upload-")+"*") } diff --git a/internal/onebox/plan_deploy.go b/internal/onebox/plan_deploy.go index 97f6ff63..a81fdb68 100644 --- a/internal/onebox/plan_deploy.go +++ b/internal/onebox/plan_deploy.go @@ -71,7 +71,7 @@ func (s *Service) PlanDeploy(ctx context.Context, _ PlanDeployRequest) (DeployPl secretGraph := lp.resolved.SecretDeclarationGraph() if len(secretGraph) > 0 { if hostState.CurrentRelease != "" { - activeSecretGeneration, err = app.SecretGenerationFromCompose([]byte(liveRedacted), secretGraphWorkloads(secretGraph)) + activeSecretGeneration, err = e.DeployedSecretGeneration(ctx, hostState.CurrentRelease, []byte(liveRedacted)) if err != nil { return DeployPlan{}, fmt.Errorf("read live secret generation: %w", err) } @@ -230,16 +230,6 @@ func (s *Service) PlanDeploy(ctx context.Context, _ PlanDeployRequest) (DeployPl return plan, nil } -func secretGraphWorkloads(graph []app.SecretDeclaration) []string { - set := map[string]bool{} - for _, declaration := range graph { - for _, workload := range declaration.AffectedWorkloads { - set[workload] = true - } - } - return sortedNames(set) -} - func classifyDeployment(steps []OperationStep, currentRelease string) (RiskClass, ReversibilityClass, ApprovalClass) { for _, step := range steps { if step.DataEffect == DataEffectDestructive { diff --git a/internal/onebox/service_test.go b/internal/onebox/service_test.go index 6fbfbd11..d9029934 100644 --- a/internal/onebox/service_test.go +++ b/internal/onebox/service_test.go @@ -210,3 +210,86 @@ func TestPlanDeployRetainsComposeBuildImageForBoundReplay(t *testing.T) { } } } + +func TestPlanDeployUsesDeployedSecretGraphDuringTransition(t *testing.T) { + fakeSops(t) + dir := t.TempDir() + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + imageWeb := "ghcr.io/example/web@sha256:" + strings.Repeat("1", 64) + imageWorker := "ghcr.io/example/worker@sha256:" + strings.Repeat("2", 64) + project := func(workerSecret bool) string { + workerEnv := "" + if workerSecret { + workerEnv = "\n env_files: [{file: worker.enc.env, provider: sops}]" + } + return `api_version: onebox.run/v1 +app: demo +environments: + production: {server: deploy@example.invalid} +workloads: + web: + role: application + image: ` + imageWeb + ` + strategy: recreate + env_files: [{file: web.enc.env, provider: sops}] + worker: + role: daemon + image: ` + imageWorker + workerEnv + ` +deployment: {order: [web, worker]} +` + } + write("web.enc.env", "WEB_TOKEN=web\n") + write("worker.enc.env", "WORKER_TOKEN=worker\n") + write("ob.yml", project(true)) + + const oldGeneration = "sg-111111111111111111111111" + liveCompose := `services: + web: + image: ` + imageWeb + ` + env_file: [.ob-secret-generations/` + oldGeneration + `/.ob-decrypted-sops-web.enc.env] + labels: {ob.app: demo, ob.release: R0, ob.workload: web, ob.secret-generation: ` + oldGeneration + `} + worker: + image: ` + imageWorker + ` + labels: {ob.app: demo, ob.release: R0, ob.workload: worker} +` + fake := serviceFake() + baseDynamic := fake.Dynamic + fake.Dynamic = func(command string) (transport.Result, bool) { + switch { + case strings.Contains(command, "ob.snapshot.yml"): + return transport.Result{Stdout: project(false)}, true + case strings.Contains(command, "cat ") && strings.Contains(command, "compose.yaml"): + return transport.Result{Stdout: liveCompose}, true + case strings.Contains(command, "docker ps") && strings.Contains(command, "--format"): + return transport.Result{Stdout: "S1|web|R0|Up\nW1|worker|R0|Up\n"}, true + default: + return baseDynamic(command) + } + } + service := New(Options{ + ConfigPath: filepath.Join(dir, "ob.yml"), + Now: func() time.Time { return time.Date(2026, 8, 23, 19, 0, 0, 0, time.UTC) }, + Connect: func(context.Context, transport.Route) (transport.Transport, error) { + return fake, nil + }, + }) + plan, err := service.PlanDeploy(context.Background(), PlanDeployRequest{}) + if err != nil { + t.Fatal(err) + } + if plan.Artifact.SecretGeneration == "" || plan.Artifact.SecretGeneration == oldGeneration { + t.Fatalf("replacement generation = %q", plan.Artifact.SecretGeneration) + } + for _, secret := range []string{"web.enc.env", "worker.enc.env"} { + staged := app.EnvFile{File: secret, Provider: "sops"}.StagedPath() + want := app.SecretGenerationPath(plan.Artifact.SecretGeneration, staged) + if !strings.Contains(plan.Artifact.RenderedCompose, want) { + t.Fatalf("planned runtime does not select %s:\n%s", want, plan.Artifact.RenderedCompose) + } + } +}