|
| 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 | +} |
0 commit comments