Skip to content

Commit 8eb96e8

Browse files
feat(orchestrate): provision saga phase — per-project Postgres role+db (D8) — M2 capstone (#73)
Wires the long-deferred provision phase into the up saga. Per-project data isolation on the shared Postgres (spec 03, DECISIONS D8): each active project that `uses: workspace.shared.postgres` gets its own login role + owned database, created idempotently via the existing `internal/provision` (existence-guarded pgx SQL). Determinism-safe host port: provisioning runs pgx FROM THE HOST, so the shared Postgres needs a reachable port. Rather than publish it in the deterministic, golden-asserted generated compose (which would also flip the "no host ports by default" posture for everyone), the shared phase writes an UP-TIME overlay (`.devstack/shared/compose.provision.yaml`) mapping `127.0.0.1:<ledger port>:5432` and brings the shared stack up with `-f compose.yaml -f compose.provision.yaml`. `generate` output is untouched (CI determinism still byte-identical) and the port is loopback-only. `docker.Compose` gained `Overrides` for the extra `-f`. Flow: shared (postgres healthy, port published) → provision (pgx-connect to 127.0.0.1:<port> as the template admin, EnsureProject per project, record role+db ownership in the ledger under the flock) → per-project compose-up. The host port is ledger-allocated (`Manager.FreeHostPort`, purpose `pg-provision`) and re-derived idempotently by the provision phase, so it survives re-runs/crashes; the shared fingerprint folds in the provisioned set so adding a consumer re-publishes. Dev-credential default: the per-project password is the project name — a predictable credential for a loopback-only, network-isolated dev DB (THREAT-MODEL: container isolation is a non-goal), so nothing secret is generated/stored and an app opts in via `postgres://<project>:<project>@shared-postgres:5432/<project>` (never auto-overriding the app's own DB config). `--no-provision` opts out. Tested daemon-free via an injectable `PgConnect` seam: the full saga provisions app's role+db (CREATE ROLE/DATABASE on the create path), connects on loopback, records ownership, brings shared up WITH the overlay (loopback-bound to 5432), and `--no-provision` skips the phase entirely. `make ci` + `make determinism` green. Unblocks X3 firstRun (provision scope_key now exists). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7879ec9 commit 8eb96e8

5 files changed

Lines changed: 429 additions & 12 deletions

File tree

internal/cli/up.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
2929
build bool
3030
noHooks bool
3131
noPreflight bool
32+
noProvision bool
3233
profiles []string
3334
)
3435
cmd := &cobra.Command{
@@ -48,6 +49,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
4849
d.Build = build
4950
d.NoHooks = noHooks
5051
d.NoPreflight = noPreflight
52+
d.NoProvision = noProvision
5153
d.Profiles = profiles
5254

5355
// Memory-budget warning (spec 12 §budget): opt-in — only when the
@@ -90,6 +92,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
9092
cmd.Flags().BoolVar(&build, "build", false, "build images before starting (compose build)")
9193
cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks")
9294
cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks")
95+
cmd.Flags().BoolVar(&noProvision, "no-provision", false, "skip per-project Postgres role/db provisioning")
9396
cmd.Flags().StringArrayVarP(&profiles, "profile", "p", nil,
9497
"service slice(s) to start — repeatable & comma-separated (spec 12); empty → defaultProfile or all")
9598
return cmd

internal/docker/compose.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ func (e *CmdError) Unwrap() error { return e.Err }
7171
// project name and compose file (DECISIONS D5). Lifecycle verbs run here;
7272
// container enumeration stays on the read-only SDK Client.
7373
type Compose struct {
74-
Project string // -p <project>
75-
File string // -f <compose file>
76-
Dir string // working dir (build contexts resolve relative to it)
77-
Env []string // extra env (resolved secrets), appended to os.Environ
78-
Runner Runner
74+
Project string // -p <project>
75+
File string // -f <compose file>
76+
Overrides []string // additional -f overlays, applied in order after File (up-time only)
77+
Dir string // working dir (build contexts resolve relative to it)
78+
Env []string // extra env (resolved secrets), appended to os.Environ
79+
Runner Runner
7980
}
8081

8182
// NewCompose builds a Compose driver using the real exec runner.
@@ -89,7 +90,13 @@ func (c *Compose) base() []string {
8990
if c.File == "" {
9091
return []string{"compose", "-p", c.Project}
9192
}
92-
return []string{"compose", "-p", c.Project, "-f", c.File}
93+
args := []string{"compose", "-p", c.Project, "-f", c.File}
94+
// Overlays (e.g. the up-time provision port mapping) are applied after the base
95+
// file so their values win; later files override earlier ones (compose merge).
96+
for _, ov := range c.Overrides {
97+
args = append(args, "-f", ov)
98+
}
99+
return args
93100
}
94101

95102
// Up brings the stack (or the named subset of services) up detached. With no

internal/orchestrate/provision.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package orchestrate
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
11+
"github.com/open-source-cloud/devstack/internal/config"
12+
"github.com/open-source-cloud/devstack/internal/generate"
13+
"github.com/open-source-cloud/devstack/internal/lock"
14+
"github.com/open-source-cloud/devstack/internal/provision"
15+
)
16+
17+
// This file is the provision saga phase (M2 capstone, DECISIONS D8): per-project
18+
// Postgres role+database isolation on the shared engine. Because provisioning runs
19+
// pgx FROM THE HOST, the shared Postgres needs a reachable port — published as a
20+
// 127.0.0.1-only mapping via an UP-TIME compose overlay (so the deterministic,
21+
// golden-asserted `generate` output is untouched and the "no host ports by
22+
// default" posture holds for every other service). The per-project password is the
23+
// project name: a predictable dev credential for a loopback-only, network-isolated
24+
// dev database (THREAT-MODEL: container isolation is a non-goal), so nothing secret
25+
// is generated or stored, and an app opts in via the documented DSN
26+
// `postgres://<project>:<project>@shared-postgres:5432/<project>`.
27+
28+
const (
29+
provisionPurpose = "pg-provision" // ledger port_alloc purpose
30+
provisionPortBase = 45432 // host port search base for shared Postgres
31+
provisionFile = "compose.provision.yaml"
32+
pgTemplate = "postgres" // shared engine template that this phase provisions
33+
)
34+
35+
// PgConnector opens an admin connection to a Postgres DSN. Injectable so the
36+
// provision phase is unit-testable without a live server (the default wraps
37+
// provision.Connect / pgx).
38+
type PgConnector func(ctx context.Context, dsn string) (provision.Conn, func() error, error)
39+
40+
func defaultPgConnect(ctx context.Context, dsn string) (provision.Conn, func() error, error) {
41+
c, closeFn, err := provision.Connect(ctx, dsn)
42+
if err != nil {
43+
return nil, nil, err
44+
}
45+
return c, closeFn, nil
46+
}
47+
48+
// provTarget is one (project, shared-Postgres-instance) pair to provision.
49+
type provTarget struct {
50+
project string
51+
instance string
52+
}
53+
54+
// pgInstances returns the set of shared services that are Postgres engines.
55+
func pgInstances(m *config.Model) map[string]bool {
56+
out := map[string]bool{}
57+
for name, s := range m.Workspace.Shared {
58+
if s.Template == pgTemplate {
59+
out[name] = true
60+
}
61+
}
62+
return out
63+
}
64+
65+
// provTargets returns the (project, instance) pairs to provision: every active
66+
// project that `uses` a shared Postgres instance. Sorted and de-duplicated.
67+
func provTargets(m *config.Model, activeServices map[string][]string, pg map[string]bool) []provTarget {
68+
seen := map[string]bool{}
69+
var out []provTarget
70+
for _, project := range sortedStringSlice(keysOf(activeServices)) {
71+
p, ok := m.Projects[project]
72+
if !ok {
73+
continue
74+
}
75+
for _, sname := range activeServices[project] {
76+
for _, u := range p.Services[sname].Uses {
77+
ref, ok := config.ParseRef(u)
78+
if !ok || ref.Kind != config.RefShared || !pg[ref.Name] {
79+
continue
80+
}
81+
key := project + "\x00" + ref.Name
82+
if seen[key] {
83+
continue
84+
}
85+
seen[key] = true
86+
out = append(out, provTarget{project: project, instance: ref.Name})
87+
}
88+
}
89+
}
90+
return out
91+
}
92+
93+
// provInstanceList returns the sorted distinct instances across targets.
94+
func provInstanceList(targets []provTarget) []string {
95+
set := map[string]bool{}
96+
for _, t := range targets {
97+
set[t.instance] = true
98+
}
99+
return sortedStringSlice(keysOf(set))
100+
}
101+
102+
// writeProvisionOverlay writes the up-time compose overlay that publishes each
103+
// provisioned Postgres instance on 127.0.0.1:<hostPort>. Returns the overlay path.
104+
// Loopback-only so nothing is exposed beyond the host (spec 03 / no host ports).
105+
func writeProvisionOverlay(root string, ports map[string]int) (string, error) {
106+
var b strings.Builder
107+
b.WriteString("services:\n")
108+
insts := make([]string, 0, len(ports))
109+
for inst := range ports {
110+
insts = append(insts, inst)
111+
}
112+
sort.Strings(insts)
113+
for _, inst := range insts {
114+
fmt.Fprintf(&b, " %s:\n ports:\n - \"127.0.0.1:%d:5432\"\n", inst, ports[inst])
115+
}
116+
dir := filepath.Join(root, generate.GenDir, "shared")
117+
if err := os.MkdirAll(dir, 0o755); err != nil {
118+
return "", err
119+
}
120+
path := filepath.Join(dir, provisionFile)
121+
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
122+
return "", err
123+
}
124+
return path, nil
125+
}
126+
127+
// provisionPhase creates each project's role+database on its shared Postgres,
128+
// idempotently, holding the flock for the SQL mutations (DECISIONS D7/D8). It
129+
// re-derives the host port from the ledger (the same one sharedPhase published),
130+
// so it is safe across re-runs and crashes. Compensation is intentionally empty:
131+
// provisioned roles/dbs are data and survive a failed `up`.
132+
func provisionPhase(d UpDeps, targets []provTarget) Phase {
133+
return Phase{
134+
Name: "provision",
135+
Mutating: true,
136+
Fingerprint: func(context.Context) (string, error) {
137+
keys := make([]string, 0, len(targets))
138+
for _, t := range targets {
139+
keys = append(keys, t.project+"@"+t.instance)
140+
}
141+
return Fingerprint(append([]string{"provision"}, keys...)...), nil
142+
},
143+
Run: func(ctx context.Context) (any, error) {
144+
connect := d.PgConnect
145+
if connect == nil {
146+
connect = defaultPgConnect
147+
}
148+
byInst := map[string][]string{}
149+
for _, t := range targets {
150+
byInst[t.instance] = append(byInst[t.instance], t.project)
151+
}
152+
153+
// Resolve each instance's published host port (FreeHostPort self-locks
154+
// and is idempotent — returns the port sharedPhase already allocated).
155+
ports := map[string]int{}
156+
for _, inst := range sortedStringSlice(keysOf(byInst)) {
157+
p, err := d.Manager.FreeHostPort(ctx, generate.SharedAlias(inst), provisionPurpose, provisionPortBase)
158+
if err != nil {
159+
return nil, fmt.Errorf("resolve provision port for %s: %w", inst, err)
160+
}
161+
ports[inst] = p
162+
}
163+
164+
provisioned := []map[string]any{}
165+
// Hold the flock for the role/db mutations (provision pkg contract).
166+
err := lock.WithLock(ctx, d.LockPath, func() error {
167+
for _, inst := range sortedStringSlice(keysOf(byInst)) {
168+
params := d.Model.Workspace.Shared[inst].Params
169+
user := paramString(params, "rootUser", "devstack")
170+
pass := paramString(params, "rootPassword", "devstack")
171+
dsn := provision.DSN("127.0.0.1", ports[inst], user, pass, user)
172+
conn, closeConn, err := connect(ctx, dsn)
173+
if err != nil {
174+
return fmt.Errorf("connect to shared %s on 127.0.0.1:%d: %w", inst, ports[inst], err)
175+
}
176+
for _, project := range byInst[inst] {
177+
creds, err := provision.Postgres{}.EnsureProject(ctx, conn, project, project)
178+
if err != nil {
179+
_ = closeConn()
180+
return fmt.Errorf("provision %s on %s: %w", project, inst, err)
181+
}
182+
if err := d.DB.RecordProvisioned(project, "role", creds.Role); err != nil {
183+
_ = closeConn()
184+
return err
185+
}
186+
if err := d.DB.RecordProvisioned(project, "database", creds.Database); err != nil {
187+
_ = closeConn()
188+
return err
189+
}
190+
d.DB.LogEvent("provision", project, "role+db on "+generate.SharedAlias(inst))
191+
provisioned = append(provisioned, map[string]any{
192+
"project": project, "instance": inst, "role": creds.Role, "database": creds.Database,
193+
})
194+
}
195+
if err := closeConn(); err != nil {
196+
return err
197+
}
198+
}
199+
return nil
200+
})
201+
if err != nil {
202+
return nil, err
203+
}
204+
return map[string]any{"provisioned": provisioned}, nil
205+
},
206+
}
207+
}
208+
209+
// paramString reads a string param with a default.
210+
func paramString(params map[string]any, key, def string) string {
211+
if v, ok := params[key]; ok {
212+
if s, ok := v.(string); ok && s != "" {
213+
return s
214+
}
215+
}
216+
return def
217+
}
218+
219+
func keysOf[V any](m map[string]V) []string {
220+
out := make([]string, 0, len(m))
221+
for k := range m {
222+
out = append(out, k)
223+
}
224+
return out
225+
}
226+
227+
func sortedStringSlice(s []string) []string {
228+
sort.Strings(s)
229+
return s
230+
}

internal/orchestrate/up.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,32 @@ type UpDeps struct {
5656
// Trust installs the local CA when network.proxy.httpsLocal; nil → trust.New().
5757
// Injected for tests (the trust phase is fenced — failure never aborts up).
5858
Trust *trust.Trust
59+
// PgConnect opens an admin Postgres connection for the provision phase; nil →
60+
// the pgx-backed default. Injected for tests (so provisioning runs daemon-free).
61+
PgConnect PgConnector
5962

6063
Build bool // compose up --build
6164
NoHooks bool // skip the hooks phase
6265
NoPreflight bool // skip the preflight phase (fast inner loops)
66+
NoProvision bool // skip the per-project Postgres provision phase
6367
HealthTimeout time.Duration // per-shared-service gate cap (0 → health.Compile default)
6468
}
6569

70+
// intersect returns the elements of a that are also in b, preserving a's order.
71+
func intersect(a, b []string) []string {
72+
set := make(map[string]bool, len(b))
73+
for _, x := range b {
74+
set[x] = true
75+
}
76+
var out []string
77+
for _, x := range a {
78+
if set[x] {
79+
out = append(out, x)
80+
}
81+
}
82+
return out
83+
}
84+
6685
// BuildUp assembles the ordered up-saga phases for the requested projects.
6786
func BuildUp(d UpDeps) ([]Phase, error) {
6887
if d.Runner == nil {
@@ -105,13 +124,26 @@ func BuildUp(d UpDeps) ([]Phase, error) {
105124
if !d.NoPreflight {
106125
phases = append(phases, preflightPhase(d))
107126
}
127+
// Provision targets: active projects that `uses` a shared Postgres get a
128+
// per-project role+db (DECISIONS D8). The instances they need are published on
129+
// 127.0.0.1 by the shared phase so host-side pgx can reach them.
130+
var targets []provTarget
131+
var provInstances []string
132+
if !d.NoProvision {
133+
targets = provTargets(d.Model, active.Services, pgInstances(d.Model))
134+
provInstances = provInstanceList(targets)
135+
}
136+
108137
phases = append(phases,
109138
networkPhase(d),
110139
generatePhase(d, gen),
111140
secretsPhase(d, projects, secretEnv),
112141
trustPhase(d),
113-
sharedPhase(d, projects, active.Shared),
142+
sharedPhase(d, projects, active.Shared, provInstances),
114143
)
144+
if len(targets) > 0 {
145+
phases = append(phases, provisionPhase(d, targets))
146+
}
115147
// Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up →
116148
// postUp) → workspace postUp.
117149
if !d.NoHooks {
@@ -293,12 +325,16 @@ func generatePhase(d UpDeps, gen *generate.Generator) Phase {
293325

294326
// shared — register ref rows, bring up only the shared services the requested
295327
// projects use, then health-gate them. Compensation drops the ref rows.
296-
func sharedPhase(d UpDeps, projects, names []string) Phase {
328+
func sharedPhase(d UpDeps, projects, names, provInstances []string) Phase {
329+
// Only provision instances that are actually being brought up this run.
330+
prov := intersect(provInstances, names)
297331
return Phase{
298332
Name: "shared",
299333
Mutating: true,
300334
Fingerprint: func(context.Context) (string, error) {
301-
return Fingerprint(append([]string{"shared"}, names...)...), nil
335+
// Fold the provisioned set in: newly publishing a port (a consumer was
336+
// added) must re-run shared-up rather than skip on a stale fingerprint.
337+
return Fingerprint(append(append([]string{"shared"}, names...), append([]string{"prov"}, prov...)...)...), nil
302338
},
303339
Run: func(ctx context.Context) (any, error) {
304340
if len(names) == 0 {
@@ -315,6 +351,24 @@ func sharedPhase(d UpDeps, projects, names []string) Phase {
315351
File: filepath.Join(outDir, generate.ComposeFile),
316352
Dir: outDir, Runner: d.Runner,
317353
}
354+
// Publish each provisioned Postgres on 127.0.0.1:<ledger port> via an
355+
// up-time overlay so host-side pgx (the provision phase) can reach it,
356+
// without touching the deterministic generated compose.
357+
if len(prov) > 0 {
358+
ports := map[string]int{}
359+
for _, inst := range prov {
360+
port, err := d.Manager.FreeHostPort(ctx, generate.SharedAlias(inst), provisionPurpose, provisionPortBase)
361+
if err != nil {
362+
return nil, fmt.Errorf("allocate provision port for %s: %w", inst, err)
363+
}
364+
ports[inst] = port
365+
}
366+
overlay, err := writeProvisionOverlay(d.Model.Root, ports)
367+
if err != nil {
368+
return nil, err
369+
}
370+
cp.Overrides = []string{overlay}
371+
}
318372
if err := cp.Up(ctx, names...); err != nil {
319373
return nil, fmt.Errorf("compose up shared: %w", err)
320374
}

0 commit comments

Comments
 (0)