From 0085bc00f440a6a0c09d426b3d6953d3bd8b0895 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 14:26:58 -0300 Subject: [PATCH] =?UTF-8?q?feat(orchestrate):=20N5=20=E2=80=94=20fenced=20?= =?UTF-8?q?trust=20phase=20in=20the=20up=20saga=20(spec=2005/09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/orchestrate/up.go | 29 +++++++++++++++++++++++ internal/orchestrate/up_test.go | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index b48b4c4..3d9dbc4 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -17,6 +17,7 @@ import ( "github.com/open-source-cloud/devstack/internal/secrets" "github.com/open-source-cloud/devstack/internal/state" "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/trust" "github.com/open-source-cloud/devstack/internal/workspace" ) @@ -50,6 +51,9 @@ type UpDeps struct { // Secrets resolves secret:// refs; nil → built from workspace.secrets.providers // with the built-in factories (SOPS+age). Injected for tests. Secrets *secrets.Registry + // Trust installs the local CA when network.proxy.httpsLocal; nil → trust.New(). + // Injected for tests (the trust phase is fenced — failure never aborts up). + Trust *trust.Trust Build bool // compose up --build NoHooks bool // skip the hooks phase @@ -90,6 +94,7 @@ func BuildUp(d UpDeps) ([]Phase, error) { networkPhase(d), generatePhase(d, gen), secretsPhase(d, projects, secretEnv), + trustPhase(d), sharedPhase(d, projects), ) // Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up → @@ -112,6 +117,30 @@ func BuildUp(d UpDeps) ([]Phase, error) { return phases, nil } +// trustPhase installs the local CA when network.proxy.httpsLocal is set (spec 05 +// §trust, spec 09 phase 7). It is FENCED: a missing mkcert / no sudo degrades to +// a warning and never aborts `up`. A no-op when httpsLocal is off. +func trustPhase(d UpDeps) Phase { + return Phase{ + Name: "trust", + AlwaysRun: true, + Run: func(ctx context.Context) (any, error) { + if !d.Model.Workspace.Network.Proxy.HTTPSLocal { + return map[string]any{"status": "skipped (httpsLocal off)"}, nil + } + t := d.Trust + if t == nil { + t = trust.New() + } + if err := t.Install(ctx); err != nil { + // Fenced: never fail the saga on a trust problem. + return map[string]any{"status": "warning", "error": err.Error()}, nil + } + return map[string]any{"status": "installed"}, nil + }, + } +} + // secretsPhase resolves every secret:// ref the requested projects reference and // stashes the resolved KEY=VALUE env per project (spec 04 §6). It ALWAYS runs // (never cached — values stay in memory) and mutates nothing global, so it has no diff --git a/internal/orchestrate/up_test.go b/internal/orchestrate/up_test.go index 8de78e9..4355c95 100644 --- a/internal/orchestrate/up_test.go +++ b/internal/orchestrate/up_test.go @@ -14,6 +14,7 @@ import ( "github.com/open-source-cloud/devstack/internal/secrets" "github.com/open-source-cloud/devstack/internal/state" "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/trust" "github.com/open-source-cloud/devstack/internal/workspace" "github.com/open-source-cloud/devstack/templates" ) @@ -178,7 +179,7 @@ func TestBuildUpHappyPath(t *testing.T) { } for _, r := range recs2 { switch r.Phase { - case "preflight", "secrets", "preUp", "postUp": // AlwaysRun phases + case "preflight", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases if r.Status != StatusOK { t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status) } @@ -363,3 +364,41 @@ func TestBuildUpHookOrdering(t *testing.T) { idx["preUp"], idx["preUp@app"], idx["compose-up@app"]) } } + +// fakeTrustRunner makes trust.Install attempt mkcert and fail (to test fencing). +type fakeTrustRunner struct{} + +func (fakeTrustRunner) Output(context.Context, string, ...string) ([]byte, error) { + return nil, nil +} +func (fakeTrustRunner) Run(context.Context, string, ...string) error { + return errors.New("mkcert -install: permission denied") +} +func (fakeTrustRunner) LookPath(string) (string, error) { return "/usr/bin/mkcert", nil } + +func TestTrustPhaseFenced(t *testing.T) { + // httpsLocal on + Install fails → the phase is FENCED (no error, warning). + d := UpDeps{ + Model: &config.Model{Workspace: config.Workspace{ + Network: config.Network{Proxy: config.Proxy{Engine: "caddy", HTTPSLocal: true}}, + }}, + Trust: &trust.Trust{Runner: fakeTrustRunner{}}, + } + detail, err := trustPhase(d).Run(context.Background()) + if err != nil { + t.Fatalf("trust phase must be fenced (no error), got %v", err) + } + if m, _ := detail.(map[string]any); m["status"] != "warning" { + t.Errorf("expected a warning status on install failure, got %v", detail) + } + + // httpsLocal off → skipped no-op. + d.Model.Workspace.Network.Proxy.HTTPSLocal = false + detail, err = trustPhase(d).Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if m, _ := detail.(map[string]any); m["status"] == "" { + t.Errorf("httpsLocal off should report skipped, got %v", detail) + } +}