Skip to content

Commit 8a8011f

Browse files
gustavobertoiclaude
andcommitted
feat(workspace): M2 differentiator core — ref-counting + reconcile + ports (spec 03)
internal/workspace ties config + state + docker under the flock (the differentiator: shared infra runs once, many projects attach): - SharedInstances: resolve each declared shared service to its (engine, majorVersion) identity + DNS alias by reading its template. - RegisterUp/RegisterDown: ref rows per consuming service, keyed by the shared alias; idempotent; RegisterDown reports instances that hit zero refs. - Reconcile: self-healing pass — derive live projects from tool-labelled running containers and prune stale ref rows (count derived from reality, not a trusted counter); event-logged. - Status: the `shared status` projection (ref counts + consuming projects). - FreeHostPort: allocate inside the lock, persisted immediately; freeness = ledger ∪ advisory bind-test ∪ live Docker-published ports (the last covers the Docker-Desktop VM proxy a host bind-test cannot see). Unit + race tested with the mock docker client and a temp ledger. Exports generate.SharedAlias so the ledger keys match the generated compose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de18fd7 commit 8a8011f

4 files changed

Lines changed: 503 additions & 0 deletions

File tree

internal/generate/names.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func projectStackName(project string) string { return "devstack-" + project }
4747
// shared network (never the bare service name — the collision guardrail).
4848
func sharedAlias(name string) string { return "shared-" + name }
4949

50+
// SharedAlias is the exported form of sharedAlias, used by internal/workspace to
51+
// key the ledger by the same instance name the generated compose reaches.
52+
func SharedAlias(name string) string { return sharedAlias(name) }
53+
5054
// envPrefix upper-cases and underscore-sanitizes a name for use as an env-var
5155
// prefix (e.g. the "postgres" import → POSTGRES_HOST).
5256
func envPrefix(name string) string {

internal/workspace/ports.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package workspace
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
8+
"github.com/open-source-cloud/devstack/internal/generate"
9+
"github.com/open-source-cloud/devstack/internal/lock"
10+
)
11+
12+
// Default host-port base ranges (spec 03): app ports and DB-GUI ports live in
13+
// separate bands so a psql/GUI port never collides with an app port.
14+
const (
15+
PortBaseApp = 13000
16+
PortBaseDBGUI = 15432
17+
portRangeSpan = 1000
18+
)
19+
20+
// FreeHostPort allocates a stable host port for (owner, purpose), persisting it
21+
// inside the lock. A port is considered free only if it is ALL of: not already
22+
// persisted in the ledger, bindable on 127.0.0.1 (advisory), and not published
23+
// by a live tool-managed container. The last check is essential on Docker Desktop
24+
// (macOS/WSL2), where a host bind-test does not reflect the VM's port proxy, so
25+
// the bind-test alone would hand out a port Docker already holds (spec 03/08).
26+
func (m *Manager) FreeHostPort(ctx context.Context, owner, purpose string, base int) (int, error) {
27+
published, err := m.publishedPorts(ctx)
28+
if err != nil {
29+
return 0, err
30+
}
31+
var port int
32+
err = lock.WithLock(ctx, m.LockPath, func() error {
33+
var e error
34+
port, e = m.DB.AllocatePort(owner, purpose, base, base+portRangeSpan, func(p int) bool {
35+
return !published[p] && bindable(p)
36+
})
37+
return e
38+
})
39+
return port, err
40+
}
41+
42+
// publishedPorts returns the set of host ports currently published by live
43+
// tool-managed containers (the union half the bind-test cannot see).
44+
func (m *Manager) publishedPorts(ctx context.Context) (map[int]bool, error) {
45+
cs, err := m.Docker.ListManaged(ctx, map[string]string{generate.LabelManaged: "true"})
46+
if err != nil {
47+
return nil, fmt.Errorf("enumerate published ports: %w", err)
48+
}
49+
out := map[int]bool{}
50+
for _, c := range cs {
51+
for _, p := range c.Ports {
52+
if p.HostPort != 0 {
53+
out[p.HostPort] = true
54+
}
55+
}
56+
}
57+
return out, nil
58+
}
59+
60+
// bindable advisory-tests whether a TCP port can be bound on loopback. TOCTOU by
61+
// nature — it is the lock + immediate persistence that makes allocation safe;
62+
// this only avoids obviously-taken ports.
63+
func bindable(port int) bool {
64+
l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
65+
if err != nil {
66+
return false
67+
}
68+
_ = l.Close()
69+
return true
70+
}

internal/workspace/workspace.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)