Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/orchestrate/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 →
Expand All @@ -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
Expand Down
41 changes: 40 additions & 1 deletion internal/orchestrate/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
Loading