|
| 1 | +// Package workspace is the differentiator (spec 03): it runs shared infrastructure |
| 2 | +// ONCE and lets many project stacks attach to it, with per-project data isolation |
| 3 | +// and reference counting derived from live reality. Every mutation of the ledger |
| 4 | +// or the shared stack goes through the machine-global flock (the concurrency |
| 5 | +// spine, DECISIONS D7). |
| 6 | +// |
| 7 | +// This file owns the ledger orchestration — resolving which shared instances a |
| 8 | +// project consumes, registering/unregistering ref rows, self-healing reconcile |
| 9 | +// against live containers, and the `shared status` projection. The daemon I/O |
| 10 | +// (network ensure, compose up, provisioning) is driven through the injected |
| 11 | +// interfaces so the logic is unit/race-testable without a real daemon. |
| 12 | +package workspace |
| 13 | + |
| 14 | +import ( |
| 15 | + "context" |
| 16 | + "fmt" |
| 17 | + "sort" |
| 18 | + |
| 19 | + "github.com/open-source-cloud/devstack/internal/config" |
| 20 | + "github.com/open-source-cloud/devstack/internal/docker" |
| 21 | + "github.com/open-source-cloud/devstack/internal/generate" |
| 22 | + "github.com/open-source-cloud/devstack/internal/lock" |
| 23 | + "github.com/open-source-cloud/devstack/internal/state" |
| 24 | + "github.com/open-source-cloud/devstack/internal/template" |
| 25 | +) |
| 26 | + |
| 27 | +// Manager orchestrates the shared stack and ref-counting for one workspace. |
| 28 | +type Manager struct { |
| 29 | + Model *config.Model |
| 30 | + DB *state.DB |
| 31 | + Docker docker.Client |
| 32 | + Source template.TemplateSource |
| 33 | + LockPath string |
| 34 | +} |
| 35 | + |
| 36 | +// SharedInstance is a resolved shared service: its config name, the DNS alias / |
| 37 | +// ledger instance name it is reached by, and its (engine, majorVersion) identity. |
| 38 | +type SharedInstance struct { |
| 39 | + Name string // config shared name, e.g. "postgres" |
| 40 | + Alias string // ledger/DNS instance name, e.g. "shared-postgres" |
| 41 | + Engine string // capability/engine, e.g. "postgres" (template `provides` or name) |
| 42 | + Major string // major version from params.version, e.g. "16" |
| 43 | + Port int // in-network port |
| 44 | +} |
| 45 | + |
| 46 | +// SharedInstances resolves every declared shared service to its instance |
| 47 | +// identity by reading its template (provides/defaultPort) and params (version). |
| 48 | +func (m *Manager) SharedInstances() (map[string]SharedInstance, error) { |
| 49 | + out := map[string]SharedInstance{} |
| 50 | + for _, name := range sortedKeys(m.Model.Workspace.Shared) { |
| 51 | + ss := m.Model.Workspace.Shared[name] |
| 52 | + res, err := template.Resolve(m.Source, ss.Template, ss.Params) |
| 53 | + if err != nil { |
| 54 | + return nil, fmt.Errorf("shared %q: %w", name, err) |
| 55 | + } |
| 56 | + engine := res.Provides |
| 57 | + if engine == "" { |
| 58 | + engine = ss.Template |
| 59 | + } |
| 60 | + out[name] = SharedInstance{ |
| 61 | + Name: name, |
| 62 | + Alias: generate.SharedAlias(name), |
| 63 | + Engine: engine, |
| 64 | + Major: majorOf(ss.Params), |
| 65 | + Port: res.DefaultPort, |
| 66 | + } |
| 67 | + } |
| 68 | + return out, nil |
| 69 | +} |
| 70 | + |
| 71 | +// consumer is one (project, service) that uses a shared instance. |
| 72 | +type consumer struct{ project, service string } |
| 73 | + |
| 74 | +// consumersOf returns the (project, service) pairs that declare `uses` of the |
| 75 | +// given shared instance, across the whole workspace graph. |
| 76 | +func (m *Manager) consumersOf(sharedName string) []consumer { |
| 77 | + var out []consumer |
| 78 | + for _, pname := range sortedKeys(m.Model.Projects) { |
| 79 | + p := m.Model.Projects[pname] |
| 80 | + for _, sname := range sortedKeys(p.Services) { |
| 81 | + for _, u := range p.Services[sname].Uses { |
| 82 | + if ref, ok := config.ParseRef(u); ok && ref.Kind == config.RefShared && ref.Name == sharedName { |
| 83 | + out = append(out, consumer{pname, sname}) |
| 84 | + } |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + return out |
| 89 | +} |
| 90 | + |
| 91 | +// instancesUsedBy returns the shared instances a single project consumes. |
| 92 | +func (m *Manager) instancesUsedBy(project string, instances map[string]SharedInstance) []SharedInstance { |
| 93 | + seen := map[string]bool{} |
| 94 | + var out []SharedInstance |
| 95 | + p := m.Model.Projects[project] |
| 96 | + for _, sname := range sortedKeys(p.Services) { |
| 97 | + for _, u := range p.Services[sname].Uses { |
| 98 | + ref, ok := config.ParseRef(u) |
| 99 | + if !ok || ref.Kind != config.RefShared { |
| 100 | + continue |
| 101 | + } |
| 102 | + if inst, ok := instances[ref.Name]; ok && !seen[inst.Alias] { |
| 103 | + seen[inst.Alias] = true |
| 104 | + out = append(out, inst) |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + return out |
| 109 | +} |
| 110 | + |
| 111 | +// RegisterUp records ref rows for every shared instance `project` consumes and |
| 112 | +// upserts the corresponding shared_service rows. Idempotent. Acquires the lock. |
| 113 | +func (m *Manager) RegisterUp(ctx context.Context, project string) error { |
| 114 | + instances, err := m.SharedInstances() |
| 115 | + if err != nil { |
| 116 | + return err |
| 117 | + } |
| 118 | + return lock.WithLock(ctx, m.LockPath, func() error { |
| 119 | + for _, inst := range m.instancesUsedBy(project, instances) { |
| 120 | + if err := m.DB.UpsertSharedService(state.SharedService{ |
| 121 | + Name: inst.Alias, Engine: inst.Engine, MajorVersion: inst.Major, Status: "unknown", |
| 122 | + }); err != nil { |
| 123 | + return err |
| 124 | + } |
| 125 | + // One ref row per consuming service of this project. |
| 126 | + for _, c := range m.consumersOf(inst.Name) { |
| 127 | + if c.project != project { |
| 128 | + continue |
| 129 | + } |
| 130 | + if err := m.DB.AddRef(c.project, c.service, inst.Alias); err != nil { |
| 131 | + return err |
| 132 | + } |
| 133 | + } |
| 134 | + m.DB.LogEvent("ref-add", inst.Alias, "up "+project) |
| 135 | + } |
| 136 | + return nil |
| 137 | + }) |
| 138 | +} |
| 139 | + |
| 140 | +// RegisterDown removes all of a project's ref rows. Acquires the lock. Returns |
| 141 | +// the shared instances that dropped to zero refs (candidates for autostop/gc). |
| 142 | +func (m *Manager) RegisterDown(ctx context.Context, project string) ([]string, error) { |
| 143 | + var zeroed []string |
| 144 | + err := lock.WithLock(ctx, m.LockPath, func() error { |
| 145 | + if _, err := m.DB.RemoveProjectRefs(project); err != nil { |
| 146 | + return err |
| 147 | + } |
| 148 | + m.DB.LogEvent("ref-del", project, "down "+project) |
| 149 | + shared, err := m.DB.ListSharedServices() |
| 150 | + if err != nil { |
| 151 | + return err |
| 152 | + } |
| 153 | + for _, s := range shared { |
| 154 | + n, err := m.DB.RefCount(s.Name) |
| 155 | + if err != nil { |
| 156 | + return err |
| 157 | + } |
| 158 | + if n == 0 { |
| 159 | + zeroed = append(zeroed, s.Name) |
| 160 | + } |
| 161 | + } |
| 162 | + sort.Strings(zeroed) |
| 163 | + return nil |
| 164 | + }) |
| 165 | + return zeroed, err |
| 166 | +} |
| 167 | + |
| 168 | +// Reconcile is the self-healing pass run on every command: it derives the set of |
| 169 | +// live projects from tool-labelled containers and prunes ref rows for projects |
| 170 | +// that are no longer up (the count is derived from reality, not a trusted |
| 171 | +// counter). Acquires the lock. Returns the pruned rows. |
| 172 | +func (m *Manager) Reconcile(ctx context.Context) ([]state.Ref, error) { |
| 173 | + containers, err := m.Docker.ListManaged(ctx, map[string]string{generate.LabelManaged: "true"}) |
| 174 | + if err != nil { |
| 175 | + return nil, fmt.Errorf("reconcile: list managed containers: %w", err) |
| 176 | + } |
| 177 | + live := map[string]bool{} |
| 178 | + for _, c := range containers { |
| 179 | + if p := c.Labels[generate.LabelProject]; p != "" && c.Running() { |
| 180 | + live[p] = true |
| 181 | + } |
| 182 | + } |
| 183 | + var pruned []state.Ref |
| 184 | + err = lock.WithLock(ctx, m.LockPath, func() error { |
| 185 | + var e error |
| 186 | + pruned, e = m.DB.PruneRefsForProjectsNotIn(live) |
| 187 | + return e |
| 188 | + }) |
| 189 | + for _, r := range pruned { |
| 190 | + m.DB.LogEvent("ref-prune", r.SharedService, "reconcile: "+r.Project+" not live") |
| 191 | + } |
| 192 | + return pruned, err |
| 193 | +} |
| 194 | + |
| 195 | +// SharedStatus is the projection behind `shared status`. |
| 196 | +type SharedStatus struct { |
| 197 | + Alias string `json:"alias"` |
| 198 | + Engine string `json:"engine"` |
| 199 | + Major string `json:"majorVersion"` |
| 200 | + Status string `json:"status"` |
| 201 | + RefCount int `json:"refCount"` |
| 202 | + Projects []string `json:"projects"` |
| 203 | +} |
| 204 | + |
| 205 | +// Status returns the shared services with their live ref counts and consuming |
| 206 | +// projects. Read-only (lock-free snapshot). |
| 207 | +func (m *Manager) Status() ([]SharedStatus, error) { |
| 208 | + shared, err := m.DB.ListSharedServices() |
| 209 | + if err != nil { |
| 210 | + return nil, err |
| 211 | + } |
| 212 | + out := make([]SharedStatus, 0, len(shared)) |
| 213 | + for _, s := range shared { |
| 214 | + n, err := m.DB.RefCount(s.Name) |
| 215 | + if err != nil { |
| 216 | + return nil, err |
| 217 | + } |
| 218 | + projs, err := m.DB.ProjectsUsing(s.Name) |
| 219 | + if err != nil { |
| 220 | + return nil, err |
| 221 | + } |
| 222 | + out = append(out, SharedStatus{ |
| 223 | + Alias: s.Name, Engine: s.Engine, Major: s.MajorVersion, |
| 224 | + Status: s.Status, RefCount: n, Projects: projs, |
| 225 | + }) |
| 226 | + } |
| 227 | + return out, nil |
| 228 | +} |
| 229 | + |
| 230 | +// --- helpers --------------------------------------------------------------- |
| 231 | + |
| 232 | +// majorOf extracts the major version from a shared service's params, defaulting |
| 233 | +// to "default" when no version is pinned (keeps the (engine,major) key total). |
| 234 | +func majorOf(params map[string]any) string { |
| 235 | + if params == nil { |
| 236 | + return "default" |
| 237 | + } |
| 238 | + if v, ok := params["version"]; ok { |
| 239 | + return fmt.Sprintf("%v", v) |
| 240 | + } |
| 241 | + return "default" |
| 242 | +} |
| 243 | + |
| 244 | +func sortedKeys[V any](mp map[string]V) []string { |
| 245 | + out := make([]string, 0, len(mp)) |
| 246 | + for k := range mp { |
| 247 | + out = append(out, k) |
| 248 | + } |
| 249 | + sort.Strings(out) |
| 250 | + return out |
| 251 | +} |
0 commit comments