Skip to content

Commit fef34a2

Browse files
feat(orchestrate): N5 — fenced trust phase in the up saga (spec 05/09) (#51)
Add the spec-09 phase-7 trust step: when network.proxy.httpsLocal is set, the up saga installs the local CA (trust.Install / mkcert -install). It is FENCED — a missing mkcert or no sudo degrades to a warning detail and NEVER aborts `up` (local HTTPS is opt-in). A no-op when httpsLocal is off. Trust is injectable (UpDeps.Trust) for tests. With the proxy-label generate wiring (PR #29) and the doctor trust/dns probes, this completes the M5 saga integration (N5). Test: httpsLocal on + a failing mkcert → phase returns ok with status=warning (fenced); httpsLocal off → skipped. Re-run treats `trust` as AlwaysRun. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4e06f5 commit fef34a2

2 files changed

Lines changed: 69 additions & 1 deletion

File tree

internal/orchestrate/up.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/open-source-cloud/devstack/internal/secrets"
1818
"github.com/open-source-cloud/devstack/internal/state"
1919
"github.com/open-source-cloud/devstack/internal/template"
20+
"github.com/open-source-cloud/devstack/internal/trust"
2021
"github.com/open-source-cloud/devstack/internal/workspace"
2122
)
2223

@@ -50,6 +51,9 @@ type UpDeps struct {
5051
// Secrets resolves secret:// refs; nil → built from workspace.secrets.providers
5152
// with the built-in factories (SOPS+age). Injected for tests.
5253
Secrets *secrets.Registry
54+
// Trust installs the local CA when network.proxy.httpsLocal; nil → trust.New().
55+
// Injected for tests (the trust phase is fenced — failure never aborts up).
56+
Trust *trust.Trust
5357

5458
Build bool // compose up --build
5559
NoHooks bool // skip the hooks phase
@@ -90,6 +94,7 @@ func BuildUp(d UpDeps) ([]Phase, error) {
9094
networkPhase(d),
9195
generatePhase(d, gen),
9296
secretsPhase(d, projects, secretEnv),
97+
trustPhase(d),
9398
sharedPhase(d, projects),
9499
)
95100
// Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up →
@@ -112,6 +117,30 @@ func BuildUp(d UpDeps) ([]Phase, error) {
112117
return phases, nil
113118
}
114119

120+
// trustPhase installs the local CA when network.proxy.httpsLocal is set (spec 05
121+
// §trust, spec 09 phase 7). It is FENCED: a missing mkcert / no sudo degrades to
122+
// a warning and never aborts `up`. A no-op when httpsLocal is off.
123+
func trustPhase(d UpDeps) Phase {
124+
return Phase{
125+
Name: "trust",
126+
AlwaysRun: true,
127+
Run: func(ctx context.Context) (any, error) {
128+
if !d.Model.Workspace.Network.Proxy.HTTPSLocal {
129+
return map[string]any{"status": "skipped (httpsLocal off)"}, nil
130+
}
131+
t := d.Trust
132+
if t == nil {
133+
t = trust.New()
134+
}
135+
if err := t.Install(ctx); err != nil {
136+
// Fenced: never fail the saga on a trust problem.
137+
return map[string]any{"status": "warning", "error": err.Error()}, nil
138+
}
139+
return map[string]any{"status": "installed"}, nil
140+
},
141+
}
142+
}
143+
115144
// secretsPhase resolves every secret:// ref the requested projects reference and
116145
// stashes the resolved KEY=VALUE env per project (spec 04 §6). It ALWAYS runs
117146
// (never cached — values stay in memory) and mutates nothing global, so it has no

internal/orchestrate/up_test.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/open-source-cloud/devstack/internal/secrets"
1515
"github.com/open-source-cloud/devstack/internal/state"
1616
"github.com/open-source-cloud/devstack/internal/template"
17+
"github.com/open-source-cloud/devstack/internal/trust"
1718
"github.com/open-source-cloud/devstack/internal/workspace"
1819
"github.com/open-source-cloud/devstack/templates"
1920
)
@@ -178,7 +179,7 @@ func TestBuildUpHappyPath(t *testing.T) {
178179
}
179180
for _, r := range recs2 {
180181
switch r.Phase {
181-
case "preflight", "secrets", "preUp", "postUp": // AlwaysRun phases
182+
case "preflight", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases
182183
if r.Status != StatusOK {
183184
t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status)
184185
}
@@ -363,3 +364,41 @@ func TestBuildUpHookOrdering(t *testing.T) {
363364
idx["preUp"], idx["preUp@app"], idx["compose-up@app"])
364365
}
365366
}
367+
368+
// fakeTrustRunner makes trust.Install attempt mkcert and fail (to test fencing).
369+
type fakeTrustRunner struct{}
370+
371+
func (fakeTrustRunner) Output(context.Context, string, ...string) ([]byte, error) {
372+
return nil, nil
373+
}
374+
func (fakeTrustRunner) Run(context.Context, string, ...string) error {
375+
return errors.New("mkcert -install: permission denied")
376+
}
377+
func (fakeTrustRunner) LookPath(string) (string, error) { return "/usr/bin/mkcert", nil }
378+
379+
func TestTrustPhaseFenced(t *testing.T) {
380+
// httpsLocal on + Install fails → the phase is FENCED (no error, warning).
381+
d := UpDeps{
382+
Model: &config.Model{Workspace: config.Workspace{
383+
Network: config.Network{Proxy: config.Proxy{Engine: "caddy", HTTPSLocal: true}},
384+
}},
385+
Trust: &trust.Trust{Runner: fakeTrustRunner{}},
386+
}
387+
detail, err := trustPhase(d).Run(context.Background())
388+
if err != nil {
389+
t.Fatalf("trust phase must be fenced (no error), got %v", err)
390+
}
391+
if m, _ := detail.(map[string]any); m["status"] != "warning" {
392+
t.Errorf("expected a warning status on install failure, got %v", detail)
393+
}
394+
395+
// httpsLocal off → skipped no-op.
396+
d.Model.Workspace.Network.Proxy.HTTPSLocal = false
397+
detail, err = trustPhase(d).Run(context.Background())
398+
if err != nil {
399+
t.Fatal(err)
400+
}
401+
if m, _ := detail.(map[string]any); m["status"] == "" {
402+
t.Errorf("httpsLocal off should report skipped, got %v", detail)
403+
}
404+
}

0 commit comments

Comments
 (0)