Skip to content

Commit 2247c9a

Browse files
feat(orchestrate): S6 — secrets saga phase resolves + injects secret:// env (spec 04 §6) (#44)
Complete M4: the up saga now resolves every secret:// reference and injects the values into containers without any value touching disk. - New `secrets` phase (AlwaysRun, never cached, no global mutation): for each requested project it collects the env-key→ref map (generate.SecretRefs, whose keys match the valueless keys generate already emits), batches one Resolve per provider via the registry, and stashes the resolved KEY=VALUE env per project. - compose-up injects that env via docker.Compose.Env → Compose substitutes the valueless keys from the child process env; the values live only in memory + the child env (§7.5). Registry is built from workspace.secrets.providers + RegisterBuiltins (SOPS+age), or injected (tests). - generate.SecretRefs(model, project): the project's env-key→secret-ref map, keys identical to projectEnv's valueless keys (raw verbatim; prefixed as <SERVICE>_<k>). Tests: the resolved value reaches compose-up's process env (DB_PASSWORD=…) via a fake registry/provider, and ONLY there; the happy-path re-run treats `secrets` as AlwaysRun. Generate determinism unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ae8c6fe commit 2247c9a

3 files changed

Lines changed: 216 additions & 9 deletions

File tree

internal/generate/secrets.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package generate
2+
3+
import (
4+
"github.com/open-source-cloud/devstack/internal/config"
5+
"github.com/open-source-cloud/devstack/internal/secrets"
6+
)
7+
8+
// SecretRefs returns the compose env-key → secret:// reference for every
9+
// secret-valued env entry across a project's services. The KEY here is byte-for-
10+
// byte the valueless key projectEnv emits (env.raw verbatim; env.prefixed as
11+
// <SERVICE>_<k>), so the up saga can inject `KEY=<resolved>` through the
12+
// compose-up process env and have Compose substitute the valueless key (§7.5) —
13+
// no secret value ever in a generated file.
14+
func SecretRefs(m *config.Model, project string) map[string]string {
15+
out := map[string]string{}
16+
p, ok := m.Projects[project]
17+
if !ok {
18+
return out
19+
}
20+
for _, sname := range sortedKeys(p.Services) {
21+
svc := p.Services[sname]
22+
for k, v := range svc.Env.Raw {
23+
if secrets.IsRef(v) {
24+
out[k] = v
25+
}
26+
}
27+
for k, v := range svc.Env.Prefixed {
28+
if secrets.IsRef(v) {
29+
out[envPrefix(sname)+"_"+k] = v
30+
}
31+
}
32+
}
33+
return out
34+
}

internal/orchestrate/up.go

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/open-source-cloud/devstack/internal/health"
1515
"github.com/open-source-cloud/devstack/internal/hooks"
1616
"github.com/open-source-cloud/devstack/internal/lock"
17+
"github.com/open-source-cloud/devstack/internal/secrets"
1718
"github.com/open-source-cloud/devstack/internal/state"
1819
"github.com/open-source-cloud/devstack/internal/template"
1920
"github.com/open-source-cloud/devstack/internal/workspace"
@@ -46,6 +47,9 @@ type UpDeps struct {
4647
Env map[string]string // generate env (nil → process env, via generate default)
4748
Profile string
4849
Projects []string // explicit subset; empty → every project in the workspace
50+
// Secrets resolves secret:// refs; nil → built from workspace.secrets.providers
51+
// with the built-in factories (SOPS+age). Injected for tests.
52+
Secrets *secrets.Registry
4953

5054
Build bool // compose up --build
5155
NoHooks bool // skip the hooks phase
@@ -73,17 +77,23 @@ func BuildUp(d UpDeps) ([]Phase, error) {
7377
return nil, err
7478
}
7579

80+
// Resolved secret env per project, shared between the secrets phase (writes)
81+
// and each compose-up phase (reads). Values live only here + in the child
82+
// process env — never on disk.
83+
secretEnv := map[string][]string{}
84+
7685
var phases []Phase
7786
if !d.NoPreflight {
7887
phases = append(phases, preflightPhase(d))
7988
}
8089
phases = append(phases,
8190
networkPhase(d),
8291
generatePhase(d, gen),
92+
secretsPhase(d, projects, secretEnv),
8393
sharedPhase(d, projects),
8494
)
8595
for _, p := range projects {
86-
phases = append(phases, composeUpPhase(d, p))
96+
phases = append(phases, composeUpPhase(d, p, secretEnv))
8797
}
8898
if !d.NoHooks {
8999
for _, p := range projects {
@@ -93,6 +103,76 @@ func BuildUp(d UpDeps) ([]Phase, error) {
93103
return phases, nil
94104
}
95105

106+
// secretsPhase resolves every secret:// ref the requested projects reference and
107+
// stashes the resolved KEY=VALUE env per project (spec 04 §6). It ALWAYS runs
108+
// (never cached — values stay in memory) and mutates nothing global, so it has no
109+
// compensation. A nil Secrets registry with no secret refs is a no-op; refs with
110+
// no registry is a clear error.
111+
func secretsPhase(d UpDeps, projects []string, out map[string][]string) Phase {
112+
return Phase{
113+
Name: "secrets",
114+
AlwaysRun: true,
115+
Run: func(ctx context.Context) (any, error) {
116+
total := 0
117+
for _, p := range projects {
118+
keyRefs := generate.SecretRefs(d.Model, p)
119+
if len(keyRefs) == 0 {
120+
continue
121+
}
122+
reg, err := d.secretRegistry()
123+
if err != nil {
124+
return nil, err
125+
}
126+
raws := make([]string, 0, len(keyRefs))
127+
for _, raw := range keyRefs {
128+
raws = append(raws, raw)
129+
}
130+
refs, err := secrets.Collect(raws...)
131+
if err != nil {
132+
return nil, err
133+
}
134+
resolved, err := secrets.Resolve(ctx, reg, refs)
135+
if err != nil {
136+
return nil, fmt.Errorf("resolve secrets for %s: %w", p, err)
137+
}
138+
env := make([]string, 0, len(keyRefs))
139+
for _, key := range sortedStringKeys(keyRefs) {
140+
env = append(env, key+"="+resolved[keyRefs[key]])
141+
}
142+
out[p] = env
143+
total += len(env)
144+
}
145+
return map[string]any{"resolved": total}, nil
146+
},
147+
}
148+
}
149+
150+
// secretRegistry returns the injected registry, or builds one from the
151+
// workspace's declared providers with the built-in factories (SOPS+age).
152+
func (d UpDeps) secretRegistry() (*secrets.Registry, error) {
153+
if d.Secrets != nil {
154+
return d.Secrets, nil
155+
}
156+
reg := secrets.NewRegistry()
157+
secrets.RegisterBuiltins(reg)
158+
for _, pr := range d.Model.Workspace.Secrets.Providers {
159+
reg.Configure(secrets.ProviderConfig{
160+
Name: pr.Name, Kind: pr.Kind, Env: pr.Env,
161+
ProjectID: pr.ProjectID, Region: pr.Region,
162+
})
163+
}
164+
return reg, nil
165+
}
166+
167+
func sortedStringKeys(m map[string]string) []string {
168+
out := make([]string, 0, len(m))
169+
for k := range m {
170+
out = append(out, k)
171+
}
172+
sort.Strings(out)
173+
return out
174+
}
175+
96176
// preflight — daemon reachable (critical). The full doctor matrix is X6.
97177
func preflightPhase(d UpDeps) Phase {
98178
return Phase{
@@ -243,13 +323,17 @@ func gateShared(ctx context.Context, d UpDeps, names []string) ([]map[string]any
243323

244324
// composeUpPhase brings one project stack up. Compensation tears it back down
245325
// (idempotent) — refs are owned by the shared phase, not unwound here.
246-
func composeUpPhase(d UpDeps, project string) Phase {
326+
func composeUpPhase(d UpDeps, project string, secretEnv map[string][]string) Phase {
247327
outDir := filepath.Join(d.Model.ProjectDir(project), generate.GenDir)
248328
cp := func() docker.Compose {
249329
return docker.Compose{
250330
Project: "devstack-" + project,
251331
File: filepath.Join(outDir, generate.ComposeFile),
252332
Dir: outDir, Runner: d.Runner,
333+
// Resolved secret values reach the containers ONLY here, via the
334+
// compose-up process env (Compose substitutes the valueless keys); they
335+
// are never written to a file (§7.5).
336+
Env: secretEnv[project],
253337
}
254338
}
255339
return Phase{

internal/orchestrate/up_test.go

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,45 @@ import (
1111
"github.com/open-source-cloud/devstack/internal/config"
1212
"github.com/open-source-cloud/devstack/internal/docker"
1313
"github.com/open-source-cloud/devstack/internal/generate"
14+
"github.com/open-source-cloud/devstack/internal/secrets"
1415
"github.com/open-source-cloud/devstack/internal/state"
1516
"github.com/open-source-cloud/devstack/internal/template"
1617
"github.com/open-source-cloud/devstack/internal/workspace"
1718
"github.com/open-source-cloud/devstack/templates"
1819
)
1920

20-
// fakeRunner records compose invocations and can fail selectively.
21+
// fakeRunner records compose invocations (with their injected env) and can fail
22+
// selectively.
2123
type fakeRunner struct {
2224
cmds [][]string
25+
envs [][]string // env passed alongside each cmd (same index)
2326
fail func(args []string) bool
2427
}
2528

26-
func (f *fakeRunner) record(name string, args []string) error {
29+
func (f *fakeRunner) record(env []string, name string, args []string) error {
2730
f.cmds = append(f.cmds, append([]string{name}, args...))
31+
f.envs = append(f.envs, env)
2832
if f.fail != nil && f.fail(args) {
2933
return errors.New("compose failed")
3034
}
3135
return nil
3236
}
33-
func (f *fakeRunner) Run(_ context.Context, _ []string, _, name string, args ...string) error {
34-
return f.record(name, args)
37+
func (f *fakeRunner) Run(_ context.Context, env []string, _, name string, args ...string) error {
38+
return f.record(env, name, args)
3539
}
36-
func (f *fakeRunner) Output(_ context.Context, _ []string, _, name string, args ...string) ([]byte, error) {
37-
return nil, f.record(name, args)
40+
func (f *fakeRunner) Output(_ context.Context, env []string, _, name string, args ...string) ([]byte, error) {
41+
return nil, f.record(env, name, args)
42+
}
43+
44+
// envForUp returns the env injected for a project's compose up (or nil).
45+
func (f *fakeRunner) envForUp(project string) []string {
46+
for i, c := range f.cmds {
47+
joined := strings.Join(c, " ")
48+
if strings.Contains(joined, "-p "+project) && strings.Contains(joined, " up ") {
49+
return f.envs[i]
50+
}
51+
}
52+
return nil
3853
}
3954
func (f *fakeRunner) sawUp(project string) bool {
4055
for _, c := range f.cmds {
@@ -163,7 +178,7 @@ func TestBuildUpHappyPath(t *testing.T) {
163178
}
164179
for _, r := range recs2 {
165180
switch r.Phase {
166-
case "preflight", "hooks":
181+
case "preflight", "secrets", "hooks": // AlwaysRun phases
167182
if r.Status != StatusOK {
168183
t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status)
169184
}
@@ -225,3 +240,77 @@ func scopeSuffix(scope string) string {
225240
}
226241
return "@" + scope
227242
}
243+
244+
// fakeSecretProvider resolves any ref to a fixed value (S6 injection test).
245+
type fakeSecretProvider struct{ val string }
246+
247+
func (fakeSecretProvider) Name() string { return "fake" }
248+
func (p fakeSecretProvider) Resolve(_ context.Context, refs []secrets.Ref) (map[string]string, error) {
249+
out := map[string]string{}
250+
for _, r := range refs {
251+
out[r.Raw] = p.val
252+
}
253+
return out, nil
254+
}
255+
256+
func TestBuildUpInjectsSecretEnv(t *testing.T) {
257+
root := t.TempDir()
258+
write := func(rel, body string) {
259+
p := filepath.Join(root, rel)
260+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
261+
t.Fatal(err)
262+
}
263+
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
264+
t.Fatal(err)
265+
}
266+
}
267+
write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nprojects:\n - { name: app, path: app }\n")
268+
write("app/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web:\n template: node.vite\n env:\n raw: { DB_PASSWORD: \"secret://vault/f#k\" }\n")
269+
270+
m, err := config.LoadAt(root)
271+
if err != nil {
272+
t.Fatalf("load: %v", err)
273+
}
274+
db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx")
275+
if err != nil {
276+
t.Fatal(err)
277+
}
278+
t.Cleanup(func() { db.Close() })
279+
mc := &docker.MockClient{}
280+
src := template.NewFSSource(templates.FS)
281+
lockPath := filepath.Join(root, "lock")
282+
fr := &fakeRunner{}
283+
284+
// Inject a registry whose provider resolves the ref to a known value.
285+
reg := secrets.NewRegistry()
286+
reg.RegisterFactory("fake", func(secrets.ProviderConfig) (secrets.Provider, error) {
287+
return fakeSecretProvider{val: "resolved-pw"}, nil
288+
})
289+
reg.Configure(secrets.ProviderConfig{Name: "vault", Kind: "fake"})
290+
291+
d := UpDeps{
292+
Model: m, DB: db, Docker: mc,
293+
Manager: &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath},
294+
Source: src, LockPath: lockPath, Runner: fr, Env: map[string]string{},
295+
Secrets: reg,
296+
}
297+
phases, err := BuildUp(d)
298+
if err != nil {
299+
t.Fatal(err)
300+
}
301+
saga := &Saga{Workspace: m.Workspace.Name, DB: db, LockPath: lockPath}
302+
if _, err := saga.Run(context.Background(), phases); err != nil {
303+
t.Fatalf("saga: %v", err)
304+
}
305+
// The resolved secret reaches compose up via its process env — and only there.
306+
env := fr.envForUp("devstack-app")
307+
found := false
308+
for _, kv := range env {
309+
if kv == "DB_PASSWORD=resolved-pw" {
310+
found = true
311+
}
312+
}
313+
if !found {
314+
t.Errorf("compose-up env should carry DB_PASSWORD=resolved-pw, got %v", env)
315+
}
316+
}

0 commit comments

Comments
 (0)