From 2b02c0ff4754faae6e52ad48c6321264d2d1d39f Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 21:30:33 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(resource):=20Stage=20A=20substrate=20?= =?UTF-8?q?=E2=80=94=20Provisioner=20family=20+=20crypto/rand=20cred=20+?= =?UTF-8?q?=20RemoveProvisioned=20(spec=2027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior-preserving generalization of Postgres provisioning: - internal/resource: Resource model, Provisioner interface + Registry, Postgres provisioner wrapping provision.EnsureProject verbatim (same guarded/idempotent SQL, predictable dev-cred), plus engine->kinds catalog. - internal/secrets: RandomPassword(n) via crypto/rand for the 'generated' policy. - internal/state: RemoveProvisioned(project,kind,name) single-resource teardown. The up-saga provision phase is untouched; all orchestrate tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/resource/postgres.go | 150 ++++++++++++++++++++++++ internal/resource/postgres_test.go | 182 +++++++++++++++++++++++++++++ internal/resource/resource.go | 139 ++++++++++++++++++++++ internal/secrets/cred.go | 32 +++++ internal/secrets/cred_test.go | 37 ++++++ internal/state/ledger.go | 13 +++ internal/state/ledger_test.go | 33 ++++++ 7 files changed, 586 insertions(+) create mode 100644 internal/resource/postgres.go create mode 100644 internal/resource/postgres_test.go create mode 100644 internal/resource/resource.go create mode 100644 internal/secrets/cred.go create mode 100644 internal/secrets/cred_test.go diff --git a/internal/resource/postgres.go b/internal/resource/postgres.go new file mode 100644 index 0000000..6d70375 --- /dev/null +++ b/internal/resource/postgres.go @@ -0,0 +1,150 @@ +package resource + +import ( + "context" + "fmt" + "strings" + + "github.com/open-source-cloud/devstack/internal/provision" +) + +// PgConnector opens an admin Postgres connection to a DSN. Injectable so the +// Postgres provisioner is unit-testable without a live server (the default wraps +// provision.Connect / pgx). Mirrors orchestrate.PgConnector. +type PgConnector func(ctx context.Context, dsn string) (provision.Conn, func() error, error) + +func defaultPgConnect(ctx context.Context, dsn string) (provision.Conn, func() error, error) { + c, closeFn, err := provision.Connect(ctx, dsn) + if err != nil { + return nil, nil, err + } + return c, closeFn, nil +} + +// Postgres is the postgres Provisioner: per-project role+database isolation on a +// shared Postgres, wrapping provision.EnsureProject verbatim (same existence- +// guarded, idempotent SQL; same predictable dev-cred where password == owner +// project). It is the reference implementation the other engine provisioners copy. +type Postgres struct { + // Connect opens the admin connection from a Target DSN; nil → the pgx default. + Connect PgConnector +} + +// Engine reports the shared-template capability this provisioner serves. +func (Postgres) Engine() string { return "postgres" } + +// Kinds are the resource kinds this provisioner can create. +func (Postgres) Kinds() []string { return []string{"database", "role", "user"} } + +func (p Postgres) connect(ctx context.Context, dsn string) (provision.Conn, func() error, error) { + if p.Connect != nil { + return p.Connect(ctx, dsn) + } + return defaultPgConnect(ctx, dsn) +} + +// dsn builds the admin DSN for the target instance from its admin creds. +func (Postgres) dsn(t Target) string { + user := t.AdminEnv["user"] + pass := t.AdminEnv["password"] + adminDB := t.AdminEnv["database"] + if adminDB == "" { + adminDB = user + } + return provision.DSN(t.Host, t.Port, user, pass, adminDB) +} + +// password resolves the resource's credential: an explicit generated value +// (Params["password"]) when present, else the predictable dev cred (== owner). +func (Postgres) password(r Resource) string { + if v, ok := r.Params["password"].(string); ok && v != "" { + return v + } + return r.Owner +} + +// Ensure idempotently provisions the role+database for the resource, wrapping +// provision.EnsureProject (the identity defaults to the owner project, matching +// the implicit up-time provisioning byte-for-byte). Returns the connection facts; +// the secret password is included so callers can surface/mask it. +func (p Postgres) Ensure(ctx context.Context, t Target, r Resource) (Attrs, error) { + identity := r.Name + if identity == "" { + identity = r.Owner + } + pass := p.password(r) + conn, closeConn, err := p.connect(ctx, p.dsn(t)) + if err != nil { + return nil, fmt.Errorf("connect to shared %s on %s:%d: %w", t.Instance, t.Host, t.Port, err) + } + defer func() { _ = closeConn() }() + + creds, err := provision.Postgres{}.EnsureProject(ctx, conn, identity, pass) + if err != nil { + return nil, err + } + return Attrs{ + "host": sharedHost(t.Instance), + "port": "5432", + "user": creds.Role, + "role": creds.Role, + "database": creds.Database, + "password": creds.Password, + }, nil +} + +// Drop removes the resource's database and/or role, guarded so it is idempotent +// and never bounces the shared container. For a database it terminates the +// tenant's own sessions before DROP DATABASE (never other tenants'), then drops +// the owning role; for a role/user it drops just the role. +func (p Postgres) Drop(ctx context.Context, t Target, r Resource) error { + identity := r.Name + if identity == "" { + identity = r.Owner + } + ident := pgIdent(identity) + conn, closeConn, err := p.connect(ctx, p.dsn(t)) + if err != nil { + return fmt.Errorf("connect to shared %s on %s:%d: %w", t.Instance, t.Host, t.Port, err) + } + defer func() { _ = closeConn() }() + + if r.Kind == "database" { + // Terminate only THIS database's backends (never other tenants'). + if err := conn.Exec(ctx, + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, + ident); err != nil { + return fmt.Errorf("terminate sessions on %q: %w", ident, err) + } + if err := conn.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(ident)); err != nil { + return fmt.Errorf("drop database %q: %w", ident, err) + } + } + // The role is safe to drop once its owned database is gone (IF EXISTS → + // idempotent). A role that still owns objects will error, surfaced to the caller. + if err := conn.Exec(ctx, `DROP ROLE IF EXISTS `+quoteIdent(ident)); err != nil { + return fmt.Errorf("drop role %q: %w", ident, err) + } + return nil +} + +// Preflight verifies the admin endpoint is reachable (a connect + close). Absence +// of a live engine degrades this provisioner's verbs, never blocks `up`. +func (p Postgres) Preflight(ctx context.Context, t Target) error { + _, closeConn, err := p.connect(ctx, p.dsn(t)) + if err != nil { + return err + } + return closeConn() +} + +// sharedHost is the stable DNS alias a consumer container reaches the instance by. +func sharedHost(instance string) string { return "shared-" + instance } + +// pgIdent maps a project/resource name to a safe Postgres identifier (hyphens → +// underscores), matching provision.pgIdent so identities are stable across the +// implicit and explicit provisioning paths. +func pgIdent(name string) string { return strings.ReplaceAll(name, "-", "_") } + +// quoteIdent double-quotes a Postgres identifier, doubling embedded quotes. +func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } diff --git a/internal/resource/postgres_test.go b/internal/resource/postgres_test.go new file mode 100644 index 0000000..2bf7658 --- /dev/null +++ b/internal/resource/postgres_test.go @@ -0,0 +1,182 @@ +package resource + +import ( + "context" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/provision" +) + +// mockConn records the SQL a provisioner runs and answers Exists from a set. It +// mirrors the fakePgConn model in orchestrate/provision tests. +type mockConn struct { + execs []string + args [][]any + exists map[string]bool // guard-query substring → present +} + +func (c *mockConn) Exec(_ context.Context, sql string, args ...any) error { + c.execs = append(c.execs, sql) + c.args = append(c.args, args) + return nil +} + +func (c *mockConn) Exists(_ context.Context, sql string, _ ...any) (bool, error) { + for sub, ok := range c.exists { + if strings.Contains(sql, sub) { + return ok, nil + } + } + return false, nil +} + +func mockConnector(c *mockConn) PgConnector { + return func(context.Context, string) (provision.Conn, func() error, error) { + return c, func() error { return nil }, nil + } +} + +func target() Target { + return Target{ + Instance: "postgres", Host: "127.0.0.1", Port: 45432, + AdminEnv: map[string]string{"user": "devstack", "password": "devstack"}, + } +} + +func TestPostgresEngineAndKinds(t *testing.T) { + p := Postgres{} + if p.Engine() != "postgres" { + t.Errorf("Engine() = %q, want postgres", p.Engine()) + } + if got := p.Kinds(); len(got) == 0 || got[0] != "database" { + t.Errorf("Kinds() = %v, want database first", got) + } +} + +func TestPostgresEnsureCreatePath(t *testing.T) { + tests := []struct { + name string + resource Resource + wantPass string // password baked into ALTER/CREATE ROLE literal + wantDB string + }{ + { + name: "predictable default name", + resource: Resource{Engine: "postgres", Kind: "database", Owner: "acme", CredKind: CredPredictable}, + wantPass: "acme", + wantDB: "acme", + }, + { + name: "hyphenated project sanitized", + resource: Resource{Engine: "postgres", Kind: "database", Name: "my-app", Owner: "my-app", CredKind: CredPredictable}, + wantPass: "my-app", + wantDB: "my_app", + }, + { + name: "generated credential via params", + resource: Resource{Engine: "postgres", Kind: "database", Owner: "acme", CredKind: CredGenerated, + Params: map[string]any{"password": "s3cr3t-random"}}, + wantPass: "s3cr3t-random", + wantDB: "acme", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &mockConn{} // Exists → false: the create path runs + p := Postgres{Connect: mockConnector(c)} + attrs, err := p.Ensure(context.Background(), target(), tc.resource) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + joined := strings.Join(c.execs, " | ") + if !strings.Contains(joined, "CREATE ROLE") || !strings.Contains(joined, "CREATE DATABASE") { + t.Errorf("missing role/db creation: %s", joined) + } + if !strings.Contains(joined, "'"+tc.wantPass+"'") { + t.Errorf("password literal %q not in SQL: %s", tc.wantPass, joined) + } + if attrs["database"] != tc.wantDB { + t.Errorf("database attr = %q, want %q", attrs["database"], tc.wantDB) + } + if attrs["host"] != "shared-postgres" { + t.Errorf("host attr = %q, want shared-postgres", attrs["host"]) + } + if attrs["password"] != tc.wantPass { + t.Errorf("password attr = %q, want %q", attrs["password"], tc.wantPass) + } + }) + } +} + +func TestPostgresEnsureIdempotentAlterPath(t *testing.T) { + // Role + database already exist → EnsureProject takes the ALTER (no CREATE) + // path, proving existence-guarded idempotency (D8). + c := &mockConn{exists: map[string]bool{"pg_roles": true, "pg_database": true}} + p := Postgres{Connect: mockConnector(c)} + if _, err := p.Ensure(context.Background(), target(), + Resource{Engine: "postgres", Kind: "database", Owner: "acme"}); err != nil { + t.Fatalf("Ensure: %v", err) + } + joined := strings.Join(c.execs, " | ") + if strings.Contains(joined, "CREATE ROLE") || strings.Contains(joined, "CREATE DATABASE") { + t.Errorf("existing role/db must not be re-created: %s", joined) + } + if !strings.Contains(joined, "ALTER ROLE") { + t.Errorf("expected ALTER ROLE to keep password in sync: %s", joined) + } +} + +func TestPostgresDropDatabase(t *testing.T) { + c := &mockConn{} + p := Postgres{Connect: mockConnector(c)} + if err := p.Drop(context.Background(), target(), + Resource{Engine: "postgres", Kind: "database", Name: "my-app", Owner: "my-app"}); err != nil { + t.Fatalf("Drop: %v", err) + } + joined := strings.Join(c.execs, " | ") + if !strings.Contains(joined, "pg_terminate_backend") { + t.Errorf("Drop must terminate the tenant's sessions first: %s", joined) + } + if !strings.Contains(joined, `DROP DATABASE IF EXISTS "my_app"`) { + t.Errorf("Drop must drop the sanitized database: %s", joined) + } + if !strings.Contains(joined, `DROP ROLE IF EXISTS "my_app"`) { + t.Errorf("Drop must drop the owning role: %s", joined) + } +} + +func TestPostgresDropRoleOnly(t *testing.T) { + c := &mockConn{} + p := Postgres{Connect: mockConnector(c)} + if err := p.Drop(context.Background(), target(), + Resource{Engine: "postgres", Kind: "role", Name: "acme", Owner: "acme"}); err != nil { + t.Fatalf("Drop: %v", err) + } + joined := strings.Join(c.execs, " | ") + if strings.Contains(joined, "DROP DATABASE") { + t.Errorf("role-kind Drop must not drop a database: %s", joined) + } + if !strings.Contains(joined, `DROP ROLE IF EXISTS "acme"`) { + t.Errorf("role-kind Drop must drop the role: %s", joined) + } +} + +func TestRegistryAndKinds(t *testing.T) { + reg := NewRegistry(Postgres{}) + if _, ok := reg.For("postgres"); !ok { + t.Error("postgres provisioner not registered") + } + if _, ok := reg.For("minio"); ok { + t.Error("minio should not be registered in the default set") + } + if !SupportsKind("postgres", "database") { + t.Error("postgres should support database") + } + if SupportsKind("postgres", "bucket") { + t.Error("postgres must not support bucket") + } + if !SupportsKind("customengine", "anything") { + t.Error("unknown engine must be forward-tolerant (kind check skipped)") + } +} diff --git a/internal/resource/resource.go b/internal/resource/resource.go new file mode 100644 index 0000000..234e795 --- /dev/null +++ b/internal/resource/resource.go @@ -0,0 +1,139 @@ +// Package resource is the data-plane resource layer (spec 27): the +// generalization of today's per-project Postgres provisioning into a first-class +// Resource model + a Provisioner interface family. A Resource is a per-project +// object that lives INSIDE a shared engine container (a database, role, bucket, +// queue, …), owned by exactly one project (tenant-scoping is the load-bearing +// invariant) and tracked in the `provisioned` ownership ledger. +// +// The Provisioner is the engine-agnostic seam the orchestrator + the `resource` +// cobra commands depend on — one implementation per shared engine. It mirrors the +// existing internal/provision.Conn pattern: small, existence-guarded, idempotent, +// and mockable so race/unit tests run without a live engine. Postgres is the only +// live provisioner in this milestone (it wraps provision.EnsureProject verbatim); +// other engines land in the Full scope (spec 27 §Dependencies). +package resource + +import "context" + +// CredentialPolicy selects how a resource's credential is produced (spec 27 +// §Credential surfacing). `predictable` is the Postgres dev-cred model (password +// == project name, nothing secret stored, attrs reach containers via env.import); +// `generated` is a random value pushed to a secrets provider (opt-in). +type CredentialPolicy string + +const ( + // CredPredictable is the loopback dev-cred default (password == owner project). + CredPredictable CredentialPolicy = "predictable" + // CredGenerated is a crypto/rand value surfaced via the secrets Pusher. + CredGenerated CredentialPolicy = "generated" +) + +// Resource is the tuple (engine, kind, name, owner, attributes) — one per-project +// object inside a shared engine. Name is tenant-namespaced (defaults to the owner +// project); Owner is the ONLY project that may read or mutate it. +type Resource struct { + Engine string // == template provides: ; ledger 'engine' column + Kind string // database|role|user|bucket|lifecycle|queue|stream|topic + Name string // engine-level identifier (tenant-namespaced) + Owner string // owning project (provisioned.project) + Params map[string]any // kind-specific knobs (e.g. lifecycle: expireDays) + CredKind CredentialPolicy // predictable | generated +} + +// Attrs are the connection-surfacing facts a consumer needs (host/port/user/ +// database/bucket/subject …) plus a credential reference. Derived, never stored. +type Attrs map[string]string + +// Target is the resolved, host-reachable admin endpoint for ONE shared instance, +// produced by the overlay + ledger-port resolution (mirrors provision.DSN today). +type Target struct { + Instance string // shared service name (e.g. "postgres") + Host string // "127.0.0.1" + Port int // ledger-allocated published host port + AdminEnv map[string]string // root creds from the instance's params (user/password/database) +} + +// Provisioner is the engine-agnostic contract the orchestrator + cobra commands +// depend on. One implementation per shared engine. Every method is safe to call +// while the caller holds the machine-global flock; Ensure/Drop MUST be +// existence-guarded and idempotent (CREATE DATABASE / CREATE ROLE are not, D8). +type Provisioner interface { + Engine() string // matches template provides: + Kinds() []string // the kinds it can create + Ensure(ctx context.Context, t Target, r Resource) (Attrs, error) // idempotent, existence-guarded + Drop(ctx context.Context, t Target, r Resource) error // teardown for gc / --purge-data + Preflight(ctx context.Context, t Target) error // tool present + version-compatible +} + +// supportedKinds is the static engine→kinds catalog (spec 27 §engine table). It +// is the single source of truth the config resolver consults to reject a kind the +// target engine's provisioner does not list (no ledger row nothing can drop). It +// is a plain map — no provisioner needs constructing — so config can validate +// without a live connection. Engines absent here are forward-tolerant (unknown → +// no kind check; the config resolver only enforces membership for known engines). +var supportedKinds = map[string][]string{ + "postgres": {"database", "role", "user"}, + "redis": {"redis_index", "acl_user"}, + "minio": {"bucket", "lifecycle", "access_key"}, + "localstack": {"bucket", "queue", "topic", "stream", "table"}, + "nats": {"stream", "consumer", "kv"}, + "kafka": {"topic", "acl"}, +} + +// Kinds returns the kinds a given engine's provisioner can create, or nil if the +// engine is not in the catalog (an unknown/custom engine, treated leniently). +func Kinds(engine string) []string { + ks := supportedKinds[engine] + if ks == nil { + return nil + } + out := make([]string, len(ks)) + copy(out, ks) + return out +} + +// SupportsKind reports whether engine's provisioner lists kind. Unknown engines +// return true (forward-tolerant: we cannot know, so we do not reject). +func SupportsKind(engine, kind string) bool { + ks := supportedKinds[engine] + if ks == nil { + return true + } + for _, k := range ks { + if k == kind { + return true + } + } + return false +} + +// Registry maps an engine to its Provisioner. The orchestrator and the `resource` +// commands share one, built with the injected Postgres connector so provisioning +// is daemon-free in tests. +type Registry struct { + byEngine map[string]Provisioner +} + +// NewRegistry builds a Registry from the given provisioners (last wins per engine). +func NewRegistry(ps ...Provisioner) *Registry { + r := &Registry{byEngine: map[string]Provisioner{}} + for _, p := range ps { + r.byEngine[p.Engine()] = p + } + return r +} + +// For returns the provisioner for engine, ok=false if none is registered. +func (r *Registry) For(engine string) (Provisioner, bool) { + p, ok := r.byEngine[engine] + return p, ok +} + +// Engines returns the registered engine names (unordered). +func (r *Registry) Engines() []string { + out := make([]string, 0, len(r.byEngine)) + for e := range r.byEngine { + out = append(out, e) + } + return out +} diff --git a/internal/secrets/cred.go b/internal/secrets/cred.go new file mode 100644 index 0000000..071a64e --- /dev/null +++ b/internal/secrets/cred.go @@ -0,0 +1,32 @@ +package secrets + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +// This file is the credential generator behind the `generated` resource +// credential policy (spec 27 §Credential surfacing) — distinct from +// GenerateAgeKey (age/SOPS key material). It produces a random, URL-safe secret +// via crypto/rand (pure-Go, no new dependency) that a provisioner pushes to a +// secrets provider (the Pusher) and injects as a valueless env key; the value is +// never written to a generated file. + +// RandomPassword returns a cryptographically-random, URL-safe password with at +// least n characters (n must be positive). It draws from crypto/rand and encodes +// with base64 raw-url (no padding), so the result is safe in a DSN and free of +// shell-special characters. +func RandomPassword(n int) (string, error) { + if n <= 0 { + return "", fmt.Errorf("RandomPassword: length %d must be positive", n) + } + // base64 raw-url yields ~4 chars per 3 bytes; request enough bytes to cover n. + nbytes := (n*3 + 3) / 4 + buf := make([]byte, nbytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("RandomPassword: read random bytes: %w", err) + } + s := base64.RawURLEncoding.EncodeToString(buf) + return s[:n], nil +} diff --git a/internal/secrets/cred_test.go b/internal/secrets/cred_test.go new file mode 100644 index 0000000..d96bb6a --- /dev/null +++ b/internal/secrets/cred_test.go @@ -0,0 +1,37 @@ +package secrets + +import "testing" + +func TestRandomPasswordLengthAndUniqueness(t *testing.T) { + for _, n := range []int{1, 8, 16, 32, 64} { + p, err := RandomPassword(n) + if err != nil { + t.Fatalf("RandomPassword(%d): %v", n, err) + } + if len(p) != n { + t.Errorf("RandomPassword(%d) len = %d, want %d (%q)", n, len(p), n, p) + } + } + // URL-safe alphabet only (no shell/DSN-hostile characters). + p, _ := RandomPassword(128) + for _, r := range p { + if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')) { + t.Fatalf("RandomPassword produced non-url-safe rune %q in %q", r, p) + } + } + // Two draws must differ (astronomically likely). + a, _ := RandomPassword(32) + b, _ := RandomPassword(32) + if a == b { + t.Errorf("two RandomPassword(32) draws were identical: %q", a) + } +} + +func TestRandomPasswordRejectsNonPositive(t *testing.T) { + if _, err := RandomPassword(0); err == nil { + t.Error("RandomPassword(0) should error") + } + if _, err := RandomPassword(-5); err == nil { + t.Error("RandomPassword(-5) should error") + } +} diff --git a/internal/state/ledger.go b/internal/state/ledger.go index 8038192..4358559 100644 --- a/internal/state/ledger.go +++ b/internal/state/ledger.go @@ -324,6 +324,19 @@ func (db *DB) OrphanedProvisioned(active map[string]bool) ([]Provisioned, error) return orphans, nil } +// RemoveProvisioned drops a single ownership row (project, kind, name) after the +// underlying resource has been dropped — the single-resource teardown used by +// `resource rm` and `resource gc` (spec 27). Idempotent (a no-op if absent). +// `kind` is free-text, so no migration is needed for new kinds. Hold the lock. +func (db *DB) RemoveProvisioned(project, kind, name string) error { + _, err := db.Exec(`DELETE FROM provisioned WHERE ctx=? AND project=? AND kind=? AND name=?`, + db.Ctx, project, kind, name) + if err != nil { + return fmt.Errorf("remove provisioned %s %s/%s: %w", kind, project, name, err) + } + return nil +} + // RemoveProvisionedForProject drops a project's ownership rows (after the actual // db/role/bucket has been dropped). Hold the lock. func (db *DB) RemoveProvisionedForProject(project string) (int, error) { diff --git a/internal/state/ledger_test.go b/internal/state/ledger_test.go index aa2bd39..a065a44 100644 --- a/internal/state/ledger_test.go +++ b/internal/state/ledger_test.go @@ -161,6 +161,39 @@ func TestProvisionedLedger(t *testing.T) { } } +func TestRemoveProvisionedSingleRow(t *testing.T) { + db := openTestDB(t) + _ = db.RecordProvisioned("api", "database", "api") + _ = db.RecordProvisioned("api", "role", "api") + _ = db.RecordProvisioned("api", "bucket", "api-uploads") // free-text kind, no migration + + // Remove exactly one (kind,name); the siblings survive (tenant-scoped teardown). + if err := db.RemoveProvisioned("api", "bucket", "api-uploads"); err != nil { + t.Fatalf("RemoveProvisioned: %v", err) + } + rows, _ := db.ProvisionedFor("api") + if len(rows) != 2 { + t.Fatalf("after single remove, rows = %d, want 2 (%v)", len(rows), rows) + } + for _, r := range rows { + if r.Kind == "bucket" { + t.Errorf("bucket row should be gone, still present: %v", r) + } + } + // Idempotent: removing an absent row is a no-op, not an error. + if err := db.RemoveProvisioned("api", "bucket", "api-uploads"); err != nil { + t.Errorf("RemoveProvisioned of absent row should be a no-op, got %v", err) + } + // Does not touch another project's identically-named resource. + _ = db.RecordProvisioned("other", "database", "api") + if err := db.RemoveProvisioned("api", "database", "api"); err != nil { + t.Fatalf("RemoveProvisioned: %v", err) + } + if rows, _ := db.ProvisionedFor("other"); len(rows) != 1 { + t.Errorf("other project's row must survive, got %v", rows) + } +} + func TestRedisIndexAllocation(t *testing.T) { db := openTestDB(t) a, err := db.AllocateRedisIndex("api") From 3037573307ba695bfbdaf33b47a4593920333bfe Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 21:35:21 -0300 Subject: [PATCH 2/3] feat(resource): Stage B config resources: block + up-saga resources phase (spec 27) - internal/config: additive ResourceDecl on Project, cross-ref validated (uses targets a declared shared instance, kind supported by the engine, no (engine,kind,name) collision); forward-tolerant to unknown keys. - internal/orchestrate: new 'resources' saga phase provisions every declared resource via the engine Provisioner under the flock, records ledger + event, and REPORTS drift without auto-dropping. Wired into BuildUp after the implicit postgres provision phase; gated on --no-provision. Provision phase untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/model.go | 19 +- internal/config/resources_test.go | 119 +++++++++++ internal/config/validate.go | 54 +++++ internal/orchestrate/resources.go | 277 +++++++++++++++++++++++++ internal/orchestrate/resources_test.go | 184 ++++++++++++++++ internal/orchestrate/up.go | 9 + 6 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 internal/config/resources_test.go create mode 100644 internal/orchestrate/resources.go create mode 100644 internal/orchestrate/resources_test.go diff --git a/internal/config/model.go b/internal/config/model.go index 11f877e..409f192 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -103,7 +103,24 @@ type Project struct { Kind string `yaml:"kind" validate:"required,eq=Project"` Name string `yaml:"name" validate:"required,dsname"` Services map[string]Service `yaml:"services" validate:"required,dive"` - Hooks Hooks `yaml:"hooks"` // spec 11 — project-scope lifecycle hooks + Hooks Hooks `yaml:"hooks"` // spec 11 — project-scope lifecycle hooks + Resources []ResourceDecl `yaml:"resources" validate:"dive"` // spec 27 — declarative data-plane resources +} + +// ResourceDecl is one declarative data-plane resource a project needs INSIDE a +// shared engine (spec 27): a database, bucket, lifecycle policy, queue, … `up` +// provisions each idempotently. The block is additive and forward-tolerant +// (unknown keys are ignored, never fatal); the cross-ref resolver checks `uses` +// targets a declared shared instance, `kind` is one the engine supports, and no +// two resources collide on (engine, name). Removing an entry NEVER auto-drops the +// resource (Q-RESOURCE-DRIFT) — teardown is always explicit + confirmed. +type ResourceDecl struct { + Uses string `yaml:"uses" validate:"required"` // workspace.shared. + Kind string `yaml:"kind" validate:"required"` // database|user|bucket|lifecycle|queue|stream|topic + Name string `yaml:"name"` // engine-level identifier (default: project name) + Engine string `yaml:"engine"` // optional; inferred from the uses target's template + Params map[string]any `yaml:"params"` // kind-specific knobs + Credentials string `yaml:"credentials" validate:"omitempty,oneof=predictable generated"` } // Service is one container in a project stack. diff --git a/internal/config/resources_test.go b/internal/config/resources_test.go new file mode 100644 index 0000000..ecb77da --- /dev/null +++ b/internal/config/resources_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "strings" + "testing" +) + +const resWorkspace = `apiVersion: devstack/v1 +kind: Workspace +name: acme +shared: + postgres: { template: postgres } + minio: { template: minio } +projects: + - { name: api, path: api } +` + +func projectWithResources(body string) map[string]string { + return map[string]string{ + "workspace.yaml": resWorkspace, + "api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n" + body, + } +} + +func TestResourcesValid(t *testing.T) { + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: database } + - { uses: workspace.shared.minio, kind: bucket, name: api-uploads, credentials: generated } + - { uses: workspace.shared.minio, kind: lifecycle, name: api-uploads, params: { expireDays: 7 } } +`)) + m, err := LoadAt(root) + if err != nil { + t.Fatalf("LoadAt: %v", err) + } + rs := m.Projects["api"].Resources + if len(rs) != 3 { + t.Fatalf("resources = %d, want 3", len(rs)) + } + if rs[0].Kind != "database" || rs[0].Uses != "workspace.shared.postgres" { + t.Errorf("resource[0] = %+v", rs[0]) + } + if rs[1].Credentials != "generated" || rs[1].Name != "api-uploads" { + t.Errorf("resource[1] = %+v", rs[1]) + } +} + +func TestResourcesUnknownShared(t *testing.T) { + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.ghost, kind: database } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Fatalf("want an unknown-shared error naming ghost, got %v", err) + } +} + +func TestResourcesBadUsesForm(t *testing.T) { + root := writeTree(t, projectWithResources(`resources: + - { uses: postgres, kind: database } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "workspace.shared.") { + t.Fatalf("want a uses-form error, got %v", err) + } +} + +func TestResourcesKindNotSupportedByEngine(t *testing.T) { + // A bucket on postgres is rejected (postgres provisioner lists no bucket kind). + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: bucket } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "not supported by engine") { + t.Fatalf("want a kind/engine error, got %v", err) + } +} + +func TestResourcesDuplicateCollision(t *testing.T) { + // Two databases defaulting to the project name collide on (engine, name). + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: database } + - { uses: workspace.shared.postgres, kind: database } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "duplicate database resource") { + t.Fatalf("want a duplicate-resource error, got %v", err) + } +} + +func TestResourcesBadCredentials(t *testing.T) { + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: database, credentials: bogus } +`)) + _, err := LoadAt(root) + if err == nil || !strings.Contains(err.Error(), "predictable, generated") { + t.Fatalf("want a credentials oneof error, got %v", err) + } +} + +func TestResourcesForwardTolerantUnknownKeys(t *testing.T) { + // Unknown keys inside a resource entry are ignored, never fatal (additive block). + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: database, futureField: whatever } +`)) + if _, err := LoadAt(root); err != nil { + t.Fatalf("unknown resource keys must be tolerated, got %v", err) + } +} + +func TestResourcesExplicitEngineOverride(t *testing.T) { + // An explicit engine that supports the kind is accepted even if it differs from + // the inferred template (forward-tolerance for custom shared templates). + root := writeTree(t, projectWithResources(`resources: + - { uses: workspace.shared.postgres, kind: bucket, engine: minio, name: api-uploads } +`)) + if _, err := LoadAt(root); err != nil { + t.Fatalf("explicit valid engine override should pass, got %v", err) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index ac6da07..534cea7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -9,6 +9,8 @@ import ( "time" "github.com/go-playground/validator/v10" + + "github.com/open-source-cloud/devstack/internal/resource" ) // dsNameRE is the safe-identifier pattern for workspace/project/service/alias @@ -64,9 +66,61 @@ func validateModel(m *Model, ws *source, projSrc map[string]*source) error { if err := validateProfiles(m, ws); err != nil { return err } + if err := validateResources(m, projSrc); err != nil { + return err + } return detectCycles(m) } +// validateResources checks the spec-27 declarative `resources:` block on each +// project: every `uses` targets a declared shared instance, `kind` is one the +// target engine's provisioner supports (for known engines — unknown/custom +// engines are forward-tolerant), and no two resources collide on (engine, name) +// within a project (tenant-scoping: names are per-project). Positioned to the +// project file. +func validateResources(m *Model, projSrc map[string]*source) error { + for _, pname := range sortedKeys(m.Projects) { + p := m.Projects[pname] + src := projSrc[pname] + seen := map[string]bool{} + for i, d := range p.Resources { + r := parseRef(d.Uses) + if r.kind != refShared || r.attr != "" { + return src.errAt(fmt.Sprintf("$.resources[%d].uses", i), + "resources: uses %q must be a shared service reference of the form workspace.shared.", d.Uses) + } + svc, ok := m.Workspace.Shared[r.name] + if !ok { + return src.errAt(fmt.Sprintf("$.resources[%d].uses", i), + "resources: shared service %q does not exist%s", r.name, suggest(r.name, m.SharedNames())) + } + engine := d.Engine + if engine == "" { + engine = svc.Template // inferred: the shared template name == its engine capability + } + if !resource.SupportsKind(engine, d.Kind) { + return src.errAt(fmt.Sprintf("$.resources[%d].kind", i), + "resources: kind %q is not supported by engine %q (supported: %s)", + d.Kind, engine, strings.Join(resource.Kinds(engine), ", ")) + } + name := d.Name + if name == "" { + name = pname // default: the project name + } + // Collide on (engine, kind, name): a bucket and its lifecycle may share a + // name (they reference the same object), but two databases of the same + // name may not. + key := engine + "\x00" + d.Kind + "\x00" + name + if seen[key] { + return src.errAt(fmt.Sprintf("$.resources[%d].name", i), + "resources: duplicate %s resource %q on engine %q (a project may declare it once)", d.Kind, name, engine) + } + seen[key] = true + } + } + return nil +} + // validateProfiles checks the spec-12 service-slice config: every group's // services reference a real service, and defaultProfile (if set) names a defined // group (or the reserved "all"). Positioned to the workspace file. diff --git a/internal/orchestrate/resources.go b/internal/orchestrate/resources.go new file mode 100644 index 0000000..b6bfb67 --- /dev/null +++ b/internal/orchestrate/resources.go @@ -0,0 +1,277 @@ +package orchestrate + +import ( + "context" + "fmt" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/profile" + "github.com/open-source-cloud/devstack/internal/resource" + "github.com/open-source-cloud/devstack/internal/secrets" +) + +// This file is the spec-27 `resources` saga phase: the declarative complement to +// the implicit Postgres `provision` phase. It provisions every resource a project +// declares in its `devstack.yaml` `resources:` block (databases, buckets, +// lifecycle policies, …) through the matching engine Provisioner — idempotent, +// existence-guarded, under the flock — records each in the `provisioned` +// ownership ledger + the event log, and REPORTS drift (a ledger resource no +// longer declared) WITHOUT auto-dropping it (Q-RESOURCE-DRIFT: teardown is always +// explicit + confirmed). +// +// It runs alongside the `provision` phase (which still handles the implicit +// postgres role+db from `uses`, byte-identically). A declared postgres database +// that overlaps the implicit one is a harmless no-op (existence-guarded + the +// ledger INSERT OR IGNORE). Non-postgres engines without a live provisioner in +// this milestone are skipped with a note (their provisioners land in Full scope). + +// perEngineOverlay is the per-engine host-reachability registry (spec 27 +// §"host-port overlay"): each engine publishes 127.0.0.1:: +// under its own (purpose, portBase). Postgres reuses the provision phase's values +// so one overlay/port covers both phases. +type perEngineOverlay struct { + purpose string + portBase int + containerPort int +} + +var engineOverlays = map[string]perEngineOverlay{ + "postgres": {provisionPurpose, provisionPortBase, 5432}, + "redis": {"redis-provision", 46379, 6379}, + "minio": {"minio-provision", 49000, 9000}, +} + +// declaredKind reports whether a ledger kind is one the declarative resources +// phase manages (so drift detection ignores the implicitly-provisioned +// role/database/redis_index kinds and never false-flags them). +func declaredKind(kind string) bool { + switch kind { + case "role", "database", "redis_index": + return false + default: + return true + } +} + +// resDecl is one resolved declarative resource to provision. +type resDecl struct { + project string + instance string + engine string + kind string + name string + cred resource.CredentialPolicy + params map[string]any +} + +// collectResourceDecls gathers every active project's `resources:` entries whose +// target shared instance is in the up set. Active projects are those with active +// services (spec 12 slicing); the instance must be one being brought up. +func collectResourceDecls(m *config.Model, active profile.Active) []resDecl { + upInstances := map[string]bool{} + for _, n := range active.Shared { + upInstances[n] = true + } + var out []resDecl + for _, project := range sortedStringSlice(keysOf(active.Services)) { + if len(active.Services[project]) == 0 { + continue + } + p, ok := m.Projects[project] + if !ok { + continue + } + for _, d := range p.Resources { + ref, ok := config.ParseRef(d.Uses) + if !ok || ref.Kind != config.RefShared { + continue + } + if !upInstances[ref.Name] { + continue + } + engine := d.Engine + if engine == "" { + engine = m.Workspace.Shared[ref.Name].Template + } + name := d.Name + if name == "" { + name = project + } + cred := resource.CredentialPolicy(d.Credentials) + if cred == "" { + cred = resource.CredPredictable + } + out = append(out, resDecl{ + project: project, instance: ref.Name, engine: engine, + kind: d.Kind, name: name, cred: cred, params: d.Params, + }) + } + } + return out +} + +// resourceRegistry builds the engine→Provisioner registry the phase/commands use, +// wired with the injected Postgres connector so provisioning is daemon-free in +// tests. Only Postgres is live in this milestone. +func resourceRegistry(connect PgConnector) *resource.Registry { + var pgConn resource.PgConnector + if connect != nil { + pgConn = resource.PgConnector(connect) + } + return resource.NewRegistry(resource.Postgres{Connect: pgConn}) +} + +// resourcesPhase provisions declared resources idempotently and reports drift. +// Compensation is intentionally empty: provisioned resources are data and survive +// a failed `up`, exactly like the Postgres provision phase. +func resourcesPhase(d UpDeps, decls []resDecl) Phase { + return Phase{ + Name: "resources", + Mutating: true, + Fingerprint: func(context.Context) (string, error) { + keys := make([]string, 0, len(decls)) + for _, r := range decls { + keys = append(keys, r.project+"@"+r.instance+":"+r.engine+"/"+r.kind+"/"+r.name+"/"+string(r.cred)) + } + return Fingerprint(append([]string{"resources"}, keys...)...), nil + }, + Run: func(ctx context.Context) (any, error) { + reg := resourceRegistry(d.PgConnect) + + // Resolve each instance's published host port (idempotent — returns the + // port the shared/provision phase already allocated). + ports := map[string]int{} + for _, r := range decls { + if _, done := ports[r.instance]; done { + continue + } + ov, ok := engineOverlays[r.engine] + if !ok { + continue // no host-reachability overlay for this engine yet + } + p, err := d.Manager.FreeHostPort(ctx, generate.SharedAlias(r.instance), ov.purpose, ov.portBase) + if err != nil { + return nil, fmt.Errorf("resolve resource port for %s: %w", r.instance, err) + } + ports[r.instance] = p + } + + provisioned := []map[string]any{} + skipped := []map[string]any{} + err := lock.WithLock(ctx, d.LockPath, func() error { + for _, r := range decls { + prov, ok := reg.For(r.engine) + if !ok { + skipped = append(skipped, map[string]any{ + "project": r.project, "engine": r.engine, "kind": r.kind, "name": r.name, + "reason": "no provisioner for engine (lands in spec 27 Full scope)", + }) + continue + } + port, ok := ports[r.instance] + if !ok { + skipped = append(skipped, map[string]any{ + "project": r.project, "engine": r.engine, "kind": r.kind, "name": r.name, + "reason": "no host-reachability overlay for engine", + }) + continue + } + res := resource.Resource{ + Engine: r.engine, Kind: r.kind, Name: r.name, Owner: r.project, + Params: r.params, CredKind: r.cred, + } + // A `generated` credential gets a random value here so a consumer + // never sees a predictable password; Pusher delivery to a provider + // lands in Full scope, so the value is currently held only for the + // provisioner call (never written to a generated file). + if r.cred == resource.CredGenerated { + pw, err := secrets.RandomPassword(24) + if err != nil { + return err + } + if res.Params == nil { + res.Params = map[string]any{} + } + if _, set := res.Params["password"]; !set { + res.Params["password"] = pw + } + } + params := d.Model.Workspace.Shared[r.instance].Params + target := resource.Target{ + Instance: r.instance, Host: "127.0.0.1", Port: port, + AdminEnv: map[string]string{ + "user": paramString(params, "rootUser", "devstack"), + "password": paramString(params, "rootPassword", "devstack"), + }, + } + attrs, err := prov.Ensure(ctx, target, res) + if err != nil { + return fmt.Errorf("provision %s %s/%s on %s: %w", r.engine, r.kind, r.name, r.instance, err) + } + // Record the resource's own row, plus the postgres role that + // EnsureProject creates (preserving the implicit phase's ledger shape). + if err := d.DB.RecordProvisioned(r.project, r.kind, r.name); err != nil { + return err + } + if r.engine == "postgres" && r.kind == "database" { + if role := attrs["role"]; role != "" { + if err := d.DB.RecordProvisioned(r.project, "role", role); err != nil { + return err + } + } + } + d.DB.LogEvent("provision", r.project, r.kind+" on "+generate.SharedAlias(r.instance)) + provisioned = append(provisioned, map[string]any{ + "project": r.project, "engine": r.engine, "kind": r.kind, "name": r.name, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + drift := detectResourceDrift(d, decls) + return map[string]any{ + "provisioned": provisioned, + "skipped": skipped, + "drift": drift, + }, nil + }, + } +} + +// detectResourceDrift reports ledger resources for active projects whose +// declarative kind is no longer declared (Q-RESOURCE-DRIFT). It is REPORT-ONLY: +// nothing is dropped. Implicit kinds (role/database/redis_index) are excluded so +// the Postgres provision path is never misread as drift. +func detectResourceDrift(d UpDeps, decls []resDecl) []map[string]any { + declared := map[string]bool{} // project\x00kind\x00name + projects := map[string]bool{} + for _, r := range decls { + declared[r.project+"\x00"+r.kind+"\x00"+r.name] = true + projects[r.project] = true + } + var drift []map[string]any + for _, project := range sortedStringSlice(keysOf(projects)) { + rows, err := d.DB.ProvisionedFor(project) + if err != nil { + continue + } + for _, row := range rows { + if !declaredKind(row.Kind) { + continue + } + if declared[project+"\x00"+row.Kind+"\x00"+row.Name] { + continue + } + drift = append(drift, map[string]any{ + "project": project, "kind": row.Kind, "name": row.Name, + "note": "declared removed; not dropped — reclaim with `resource rm --purge-data` or `resource gc`", + }) + } + } + return drift +} diff --git a/internal/orchestrate/resources_test.go b/internal/orchestrate/resources_test.go new file mode 100644 index 0000000..f485af7 --- /dev/null +++ b/internal/orchestrate/resources_test.go @@ -0,0 +1,184 @@ +package orchestrate + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/profile" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/internal/workspace" + "github.com/open-source-cloud/devstack/templates" +) + +// resourcesFixture builds a one-project workspace whose devstack.yaml declares a +// postgres database resource, so the spec-27 resources phase provisions it. +func resourcesFixture(t *testing.T, resourcesBlock string) (UpDeps, *fakeRunner, *state.DB) { + t.Helper() + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nshared:\n postgres: { template: postgres, params: { version: \"16\" } }\nprojects:\n - { name: app, path: app }\n") + write("app/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web:\n template: node.vite\n uses: [workspace.shared.postgres]\n"+resourcesBlock) + + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + if err != nil { + t.Fatalf("state: %v", err) + } + t.Cleanup(func() { db.Close() }) + + mc := &docker.MockClient{ + Containers: []docker.Container{{ + ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", + Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "postgres"}, + }}, + Details: map[string]docker.ContainerDetails{ + "pg1": {ID: "pg1", State: "running", Running: true, Health: docker.HealthHealthy}, + }, + } + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath} + fr := &fakeRunner{} + d := UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, + LockPath: lockPath, Runner: fr, Env: map[string]string{}, NoHooks: true, PgConnect: okPgConnect, + } + return d, fr, db +} + +func TestResourcesPhaseProvisionsDeclaredDB(t *testing.T) { + d, _, db := resourcesFixture(t, `resources: + - { uses: workspace.shared.postgres, kind: database, name: reports } +`) + rp := &recordingPg{} + d.PgConnect = rp.connect + + recs, err := (&Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}). + Run(context.Background(), mustPhases(t, d)) + if err != nil || AnyFailed(recs) { + t.Fatalf("saga: %v\n%+v", err, recs) + } + got := map[string]string{} + for _, r := range recs { + got[r.Phase+scopeSuffix(r.Scope)] = r.Status + } + if got["resources"] != StatusOK { + t.Fatalf("resources phase = %q, want ok (all: %+v)", got["resources"], got) + } + // The declared database + its role were provisioned and recorded. + rows, _ := db.ProvisionedFor("app") + var kinds []string + for _, r := range rows { + kinds = append(kinds, r.Kind+":"+r.Name) + } + if !slices.Contains(kinds, "database:reports") { + t.Errorf("provisioned rows = %v, want database:reports", kinds) + } + // The provisioner ran the create path against the shared postgres on loopback. + joined := "" + for _, c := range rp.conns { + joined += strings.Join(c.execs, " | ") + } + if !strings.Contains(joined, "CREATE DATABASE") { + t.Errorf("declared resource DB was not created: %s", joined) + } +} + +func TestResourcesPhaseIdempotent(t *testing.T) { + d, _, db := resourcesFixture(t, `resources: + - { uses: workspace.shared.postgres, kind: database, name: reports } +`) + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + phases := mustPhases(t, d) + if recs, err := saga.Run(context.Background(), phases); err != nil || AnyFailed(recs) { + t.Fatalf("up #1: %v\n%+v", err, recs) + } + // Re-run → the resources phase skips on an unchanged fingerprint. + recs2, err := saga.Run(context.Background(), phases) + if err != nil { + t.Fatal(err) + } + for _, r := range recs2 { + if r.Phase == "resources" && r.Status != StatusSkipped { + t.Errorf("resources phase should skip on re-run, got %q", r.Status) + } + } + // Still exactly one database row (idempotent ledger). + rows, _ := db.ProvisionedFor("app") + n := 0 + for _, r := range rows { + if r.Kind == "database" && r.Name == "reports" { + n++ + } + } + if n != 1 { + t.Errorf("database:reports rows = %d, want 1", n) + } +} + +func TestResourcesPhaseReportsDriftWithoutDropping(t *testing.T) { + // A bucket row lingers in the ledger but is no longer declared → drift, not drop. + d, _, db := resourcesFixture(t, `resources: + - { uses: workspace.shared.postgres, kind: database, name: reports } +`) + _ = db.RecordProvisioned("app", "bucket", "app-orphan") + + decls := collectResourceDecls(d.Model, profile.Resolve(d.Model, nil)) + phase := resourcesPhase(d, decls) + detail, err := phase.Run(context.Background()) + if err != nil { + t.Fatalf("resources phase: %v", err) + } + m := detail.(map[string]any) + drift := m["drift"].([]map[string]any) + if len(drift) != 1 || drift[0]["name"] != "app-orphan" { + t.Fatalf("want one drift entry for app-orphan, got %v", drift) + } + // NOT dropped: the row survives (never auto-drop on config deletion). + rows, _ := db.ProvisionedFor("app") + found := false + for _, r := range rows { + if r.Kind == "bucket" && r.Name == "app-orphan" { + found = true + } + } + if !found { + t.Error("drifted resource must NOT be dropped (row disappeared)") + } +} + +func TestResourcesPhaseSkipsWhenNoProvision(t *testing.T) { + d, _, db := resourcesFixture(t, `resources: + - { uses: workspace.shared.postgres, kind: database, name: reports } +`) + d.NoProvision = true + recs, err := (&Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}). + Run(context.Background(), mustPhases(t, d)) + if err != nil || AnyFailed(recs) { + t.Fatalf("saga: %v\n%+v", err, recs) + } + for _, r := range recs { + if r.Phase == "resources" { + t.Error("resources phase present despite NoProvision") + } + } +} diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 1b3af9b..2ca0969 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -131,9 +131,12 @@ func BuildUp(d UpDeps) ([]Phase, error) { // 127.0.0.1 by the shared phase so host-side pgx can reach them. var targets []provTarget var provInstances []string + var resDecls []resDecl if !d.NoProvision { targets = provTargets(d.Model, active.Services, pgInstances(d.Model)) provInstances = provInstanceList(targets) + // Declarative spec-27 resources for active projects on up instances. + resDecls = collectResourceDecls(d.Model, active) } phases = append(phases, @@ -146,6 +149,12 @@ func BuildUp(d UpDeps) ([]Phase, error) { if len(targets) > 0 { phases = append(phases, provisionPhase(d, targets)) } + // Declarative resources (spec 27): provisioned after the implicit postgres + // provision phase (which publishes the shared instances they reach), before + // the per-project compose-up/hooks. + if len(resDecls) > 0 { + phases = append(phases, resourcesPhase(d, resDecls)) + } // Hook ordering (spec 11): workspace preUp → per-project (preUp → compose-up → // postUp) → workspace postUp. if !d.NoHooks { From aee6f9da76d64abb2cf080a5aa48ca60bdce82af Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 21:41:59 -0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(resource):=20Stage=20B=20step=206=20?= =?UTF-8?q?=E2=80=94=20resource=20list|show|create|rm|gc=20+=20workspace?= =?UTF-8?q?=20destroy=20--purge-data=20(spec=2027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/orchestrate: imperative CreateResource/DropResource/GCResources ops (lock -> overlay -> provisioner -> ledger -> event), mirroring the saga phase. - internal/cli: engine-agnostic 'resource' command group (list/show are lock-free reads; create/rm/gc drive the ops; secrets masked unless --show-secrets), and a new destructive 'workspace destroy --purge-data' flag beside the data-preserving default. Registered under root. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/destroy.go | 69 +++- internal/cli/destroy_test.go | 2 +- internal/cli/resource.go | 400 ++++++++++++++++++++++ internal/cli/resource_test.go | 96 ++++++ internal/cli/root.go | 1 + internal/orchestrate/resource_ops.go | 252 ++++++++++++++ internal/orchestrate/resource_ops_test.go | 142 ++++++++ 7 files changed, 950 insertions(+), 12 deletions(-) create mode 100644 internal/cli/resource.go create mode 100644 internal/cli/resource_test.go create mode 100644 internal/orchestrate/resource_ops.go create mode 100644 internal/orchestrate/resource_ops_test.go diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go index bf15813..cd90817 100644 --- a/internal/cli/destroy.go +++ b/internal/cli/destroy.go @@ -14,6 +14,7 @@ import ( "github.com/open-source-cloud/devstack/internal/generate" "github.com/open-source-cloud/devstack/internal/lock" "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/resource" ) // newWorkspaceCmd wires `devstack workspace ` — workspace-scoped lifecycle @@ -36,7 +37,7 @@ func newWorkspaceCmd(g *GlobalOpts) *cobra.Command { // PRESERVES data: named volumes and provisioned DBs/roles survive (shared // per-service volume removal is `uninstall`/`db gc` territory). func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command { - var yes bool + var yes, purgeData bool cmd := &cobra.Command{ Use: "destroy", Short: "Tear down THIS workspace's stacks and release its refs/ports (volumes/DBs preserved)", @@ -63,17 +64,21 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command { projects := sortedProjectNames(d.Model) if !yes { + dataLine := "Volumes and databases are PRESERVED." + if purgeData { + dataLine = "WARNING: --purge-data DROPS every provisioned database/bucket/etc (DATA DESTROYED)." + } prompt := fmt.Sprintf( "This tears down workspace %q (%d project stack(s)) and releases its refs/ports.\n"+ - "Volumes and databases are PRESERVED. Type 'yes' to continue: ", - d.Model.Workspace.Name, len(projects)) + "%s Type 'yes' to continue: ", + d.Model.Workspace.Name, len(projects), dataLine) if !confirm(cmd, prompt) { fmt.Fprintln(cmd.OutOrStdout(), "aborted") return nil } } - res := destroyWorkspace(cmd.Context(), d, projects) + res := destroyWorkspace(cmd.Context(), d, projects, purgeData) if g.JSON { if err := writeJSON(cmd, res); err != nil { return err @@ -86,11 +91,18 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command { for _, s := range res.SharedStopped { fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s) } + for _, p := range res.PurgedResources { + fmt.Fprintf(w, "[ok] dropped %s %s\n", p["kind"], p["name"]) + } for _, e := range res.Errors { fmt.Fprintf(w, "[warn] %s\n", e) } - fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (volumes/DBs preserved)\n", - d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped)) + dataNote := "volumes/DBs preserved" + if purgeData { + dataNote = fmt.Sprintf("%d resource(s) purged", len(res.PurgedResources)) + } + fmt.Fprintf(w, "destroyed workspace %q: %d stack(s) down, %d shared stopped (%s)\n", + d.Model.Workspace.Name, len(res.Projects), len(res.SharedStopped), dataNote) } if len(res.Errors) > 0 { return fmt.Errorf("destroy completed with %d error(s)", len(res.Errors)) @@ -99,28 +111,63 @@ func newWorkspaceDestroyCmd(g *GlobalOpts) *cobra.Command { }, } cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json/non-interactive)") + cmd.Flags().BoolVar(&purgeData, "purge-data", false, "also DROP every provisioned resource (databases/buckets/…) — DESTRUCTIVE") return cmd } // DestroyResult is the machine-readable outcome of `workspace destroy`. type DestroyResult struct { - Workspace string `json:"workspace"` - Projects []string `json:"projects"` // project stacks brought down - SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped - Errors []string `json:"errors,omitempty"` + Workspace string `json:"workspace"` + Projects []string `json:"projects"` // project stacks brought down + SharedStopped []string `json:"shared_stopped"` // orphaned shared services warm-stopped + PurgedResources []map[string]string `json:"purged_resources,omitempty"` // --purge-data: resources dropped + Errors []string `json:"errors,omitempty"` } // destroyWorkspace performs the teardown mechanics (no prompting) so it is // unit-testable with injected mocks. It is best-effort: a failure on one project // is recorded and the rest proceed, so a partially-broken workspace can still be // cleaned up. -func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string) DestroyResult { +func destroyWorkspace(ctx context.Context, d orchestrate.UpDeps, projects []string, purgeData bool) DestroyResult { res := DestroyResult{Workspace: d.Model.Workspace.Name} runner := d.Runner if runner == nil { runner = docker.ExecRunner{} } + // 0. --purge-data (opt-in, DESTRUCTIVE): while the shared engine is still up, + // DROP every resource this workspace provisioned, then remove its ledger rows. + // The data-preserving default skips this entirely (spec 27). + if purgeData { + for _, p := range projects { + rows, err := d.DB.ProvisionedFor(p) + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("list resources for %s: %v", p, err)) + continue + } + for _, row := range rows { + if row.Kind == "role" { + continue // dropped alongside its database + } + r := resource.Resource{Engine: engineForKindGuess(row.Kind), Kind: row.Kind, Name: row.Name, Owner: p} + if err := orchestrate.DropResource(ctx, d, r, true); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("drop %s %s: %v", row.Kind, row.Name, err)) + continue + } + res.PurgedResources = append(res.PurgedResources, map[string]string{"project": p, "kind": row.Kind, "name": row.Name}) + } + // Remove any straggler rows (e.g. redis_index) and the overlay ports. + if err := lock.WithLock(ctx, d.LockPath, func() error { + if _, err := d.DB.RemoveProvisionedForProject(p); err != nil { + return err + } + return nil + }); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("clear provisioned rows for %s: %v", p, err)) + } + } + } + // 1. compose down each project stack (containers + project default network; // named volumes survive — never -v here). for _, p := range projects { diff --git a/internal/cli/destroy_test.go b/internal/cli/destroy_test.go index 6ff6a71..9c23186 100644 --- a/internal/cli/destroy_test.go +++ b/internal/cli/destroy_test.go @@ -128,7 +128,7 @@ func TestDestroyWorkspaceTeardown(t *testing.T) { t.Fatalf("precondition: ref count = %d, want 1", n) } - res := destroyWorkspace(ctx, d, []string{"app"}) + res := destroyWorkspace(ctx, d, []string{"app"}, false) if len(res.Errors) != 0 { t.Fatalf("destroy errors: %v", res.Errors) } diff --git a/internal/cli/resource.go b/internal/cli/resource.go new file mode 100644 index 0000000..f0d5186 --- /dev/null +++ b/internal/cli/resource.go @@ -0,0 +1,400 @@ +package cli + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/orchestrate" + "github.com/open-source-cloud/devstack/internal/resource" + "github.com/open-source-cloud/devstack/internal/state" + "github.com/open-source-cloud/devstack/internal/workspace" +) + +// newResourceCmd wires `devstack resource list|show|create|rm|gc` — the +// engine-agnostic data-plane resource verbs (spec 27). list/show are lock-free +// ledger reads; create/rm/gc mirror the up-saga provision flow (lock → overlay → +// provisioner → ledger → event) via internal/orchestrate. Secrets are masked +// unless --show-secrets. +func newResourceCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "resource", + Short: "Manage per-project resources inside shared engines (databases, buckets, …)", + } + cmd.AddCommand( + newResourceListCmd(g), + newResourceShowCmd(g), + newResourceCreateCmd(g), + newResourceRmCmd(g), + newResourceGcCmd(g), + ) + return cmd +} + +// secretAttrKeys are attribute names whose value is a credential — masked in +// output unless --show-secrets (spec 27 §Credential surfacing). +var secretAttrKeys = map[string]bool{"password": true, "secret": true, "secretkey": true, "token": true} + +func maskAttrs(a resource.Attrs, show bool) map[string]string { + out := map[string]string{} + for k, v := range a { + if secretAttrKeys[k] && !show { + out[k] = "***" + continue + } + out[k] = v + } + return out +} + +func newResourceListCmd(g *GlobalOpts) *cobra.Command { + var project, engine, kind string + cmd := &cobra.Command{ + Use: "list", + Short: "List provisioned resources from the ownership ledger", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + + var rows []state.Provisioned + if project != "" { + rows, err = mgr.DB.ProvisionedFor(project) + } else { + rows, err = mgr.DB.AllProvisioned() + } + if err != nil { + return err + } + // kind filter (engine is derived, not stored; filter best-effort by kind). + var filtered []state.Provisioned + for _, r := range rows { + if kind != "" && r.Kind != kind { + continue + } + if engine != "" && !resource.SupportsKind(engine, r.Kind) { + continue + } + filtered = append(filtered, r) + } + if g.JSON { + return writeJSON(cmd, map[string]any{"resources": filtered}) + } + w := cmd.OutOrStdout() + if len(filtered) == 0 { + fmt.Fprintln(w, "no provisioned resources") + return nil + } + for _, r := range filtered { + fmt.Fprintf(w, "%-12s %-12s %-24s %s\n", r.Project, r.Kind, r.Name, r.CreatedAt) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "only this project's resources") + cmd.Flags().StringVar(&engine, "engine", "", "filter by engine (postgres/redis/minio/…)") + cmd.Flags().StringVar(&kind, "kind", "", "filter by kind (database/bucket/…)") + return cmd +} + +func newResourceShowCmd(g *GlobalOpts) *cobra.Command { + var project, engine string + var showSecrets bool + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a resource's connection attributes (secrets masked unless --show-secrets)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + + rows, err := mgr.DB.AllProvisioned() + if err != nil { + return err + } + var match *state.Provisioned + for i := range rows { + if rows[i].Name != name { + continue + } + if project != "" && rows[i].Project != project { + continue + } + match = &rows[i] + break + } + if match == nil { + return fmt.Errorf("no provisioned resource named %q", name) + } + if engine == "" { + engine = mgr.Model.Workspace.Shared[firstSharedForKind(mgr, match.Kind)].Template + } + // Derive non-secret attrs from the ledger row + workspace model (no daemon). + attrs := resource.Attrs{ + "project": match.Project, + "kind": match.Kind, + "name": match.Name, + "host": "shared-" + engineInstanceName(mgr, engine), + "password": match.Project, // predictable dev-cred (masked unless --show-secrets) + } + out := maskAttrs(attrs, showSecrets) + if g.JSON { + return writeJSON(cmd, out) + } + w := cmd.OutOrStdout() + for _, k := range sortedKeysOf(out) { + fmt.Fprintf(w, "%-10s %s\n", k, out[k]) + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "disambiguate by owner project") + cmd.Flags().StringVar(&engine, "engine", "", "engine hint for attribute derivation") + cmd.Flags().BoolVar(&showSecrets, "show-secrets", false, "print credential values (diagnostics only)") + return cmd +} + +func newResourceCreateCmd(g *GlobalOpts) *cobra.Command { + var project, credentials string + var params []string + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a resource on a running shared engine (idempotent)", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + engine, kind, name := args[0], args[1], args[2] + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + + owner := project + if owner == "" { + owner = defaultProject(d) + } + cred := resource.CredPredictable + if credentials != "" { + cred = resource.CredentialPolicy(credentials) + } + p, err := parseParams(params) + if err != nil { + return err + } + r := resource.Resource{Engine: engine, Kind: kind, Name: name, Owner: owner, Params: p, CredKind: cred} + attrs, err := orchestrate.CreateResource(cmd.Context(), d, r) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"created": map[string]string{"engine": engine, "kind": kind, "name": name, "project": owner}, "attrs": maskAttrs(attrs, false)}) + } + fmt.Fprintf(cmd.OutOrStdout(), "created %s %s %q for project %q\n", engine, kind, name, owner) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project (default: the workspace's single/first project)") + cmd.Flags().StringSliceVar(¶ms, "param", nil, "kind-specific parameter k=v (repeatable)") + cmd.Flags().StringVar(&credentials, "credentials", "", "predictable|generated (default: predictable)") + return cmd +} + +func newResourceRmCmd(g *GlobalOpts) *cobra.Command { + var project, engine string + var purge, yes bool + cmd := &cobra.Command{ + Use: "rm ", + Short: "Un-track a resource (or --purge-data to also drop it)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if purge && g.JSON && !yes { + return fmt.Errorf("refusing to --purge-data without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + + owner := project + if owner == "" { + owner = defaultProject(d) + } + // Resolve the kind from the ledger (a resource is identified by name+owner). + rows, err := d.DB.ProvisionedFor(owner) + if err != nil { + return err + } + var kind string + for _, r := range rows { + if r.Name == name && r.Kind != "role" { + kind = r.Kind + break + } + } + if kind == "" { + return fmt.Errorf("no provisioned resource named %q for project %q", name, owner) + } + if engine == "" { + engine = engineForKindGuess(kind) + } + if purge && !yes { + if !confirm(cmd, fmt.Sprintf("This DROPS %s %q (data is destroyed). Type 'yes' to continue: ", kind, name)) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + r := resource.Resource{Engine: engine, Kind: kind, Name: name, Owner: owner} + if err := orchestrate.DropResource(cmd.Context(), d, r, purge); err != nil { + return err + } + verb := "un-tracked" + if purge { + verb = "dropped" + } + if g.JSON { + return writeJSON(cmd, map[string]any{"removed": map[string]any{"name": name, "kind": kind, "project": owner, "purged": purge}}) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s %s %q (project %q)\n", verb, kind, name, owner) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "owner project") + cmd.Flags().StringVar(&engine, "engine", "", "engine (default: inferred from kind)") + cmd.Flags().BoolVar(&purge, "purge-data", false, "also DROP the resource (destructive)") + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + return cmd +} + +func newResourceGcCmd(g *GlobalOpts) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "gc", + Short: "Reclaim resources whose owner project left the workspace (destructive)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if g.JSON && !yes { + return fmt.Errorf("refusing to gc without --yes for --json/non-interactive use") + } + d, closeFn, err := buildUpDeps(cmd) + if err != nil { + return err + } + defer closeFn() + + active := map[string]bool{} + for _, p := range sortedProjectNames(d.Model) { + active[p] = true + } + // Preview the orphans so the confirmation is informed. + orphans, err := d.DB.OrphanedProvisioned(active) + if err != nil { + return err + } + if len(orphans) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no orphaned resources to reclaim") + return nil + } + if !yes { + if !confirm(cmd, fmt.Sprintf("This DROPS %d orphaned resource(s) (data destroyed). Type 'yes' to continue: ", len(orphans))) { + fmt.Fprintln(cmd.OutOrStdout(), "aborted") + return nil + } + } + res, err := orchestrate.GCResources(cmd.Context(), d, active) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, res) + } + w := cmd.OutOrStdout() + for _, r := range res.Reaped { + fmt.Fprintf(w, "reaped %s %s (project %s)\n", r["kind"], r["name"], r["project"]) + } + for _, s := range res.Skipped { + fmt.Fprintf(w, "skipped %s %s: %s\n", s["kind"], s["name"], s["reason"]) + } + return nil + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt (required for --json)") + return cmd +} + +// --- helpers --------------------------------------------------------------- + +// defaultProject returns the workspace's single project, or the first by name. +func defaultProject(d orchestrate.UpDeps) string { + names := sortedProjectNames(d.Model) + if len(names) > 0 { + return names[0] + } + return "" +} + +// engineForKindGuess maps a ledger kind back to its engine for the live set +// (postgres only this milestone). Ambiguous/unknown kinds default to postgres. +func engineForKindGuess(kind string) string { + switch kind { + case "bucket", "lifecycle", "access_key": + return "minio" + case "redis_index", "acl_user": + return "redis" + default: + return "postgres" + } +} + +func engineInstanceName(mgr *workspace.Manager, engine string) string { + if inst, ok := orchestrate.ResolveInstance(mgr.Model, engine); ok { + return inst + } + return engine +} + +func firstSharedForKind(mgr *workspace.Manager, kind string) string { + engine := engineForKindGuess(kind) + if inst, ok := orchestrate.ResolveInstance(mgr.Model, engine); ok { + return inst + } + // Fall back to any shared name so the map lookup does not panic. + for _, n := range mgr.Model.SharedNames() { + return n + } + return "" +} + +func parseParams(kv []string) (map[string]any, error) { + if len(kv) == 0 { + return nil, nil + } + out := map[string]any{} + for _, pair := range kv { + i := strings.IndexByte(pair, '=') + if i <= 0 { + return nil, fmt.Errorf("invalid --param %q (want k=v)", pair) + } + out[pair[:i]] = pair[i+1:] + } + return out, nil +} + +func sortedKeysOf(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/cli/resource_test.go b/internal/cli/resource_test.go new file mode 100644 index 0000000..0ff13d0 --- /dev/null +++ b/internal/cli/resource_test.go @@ -0,0 +1,96 @@ +package cli + +import ( + "context" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/provision" +) + +// fakeConn records the SQL a provisioner runs (Exists=false → create/drop path). +type fakeConn struct{ execs []string } + +func (c *fakeConn) Exec(_ context.Context, sql string, _ ...any) error { + c.execs = append(c.execs, sql) + return nil +} +func (c *fakeConn) Exists(context.Context, string, ...any) (bool, error) { return false, nil } + +func TestResourceCommandRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + for _, sub := range []string{"list", "show", "create", "rm", "gc"} { + c, _, err := root.Find([]string{"resource", sub}) + if err != nil || c.Name() != sub || c.RunE == nil { + t.Fatalf("resource %s not registered as a real command: %v", sub, err) + } + } +} + +func TestWorkspaceDestroyPurgeDataFlag(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"workspace", "destroy"}) + if err != nil { + t.Fatalf("find destroy: %v", err) + } + if c.Flags().Lookup("purge-data") == nil { + t.Error("workspace destroy must expose --purge-data") + } +} + +func TestDestroyWorkspacePurgeDropsResources(t *testing.T) { + d, _ := destroyFixture(t) + ctx := context.Background() + + // A recording connector so the postgres Drop DDL is observable, daemon-free. + var conns []*fakeConn + d.PgConnect = func(context.Context, string) (provision.Conn, func() error, error) { + c := &fakeConn{} + conns = append(conns, c) + return c, func() error { return nil }, nil + } + + // Seed a provisioned database + role + an un-reapable bucket for the project. + _ = d.DB.RecordProvisioned("app", "database", "app") + _ = d.DB.RecordProvisioned("app", "role", "app") + + res := destroyWorkspace(ctx, d, []string{"app"}, true) + if len(res.Errors) != 0 { + t.Fatalf("purge destroy errors: %v", res.Errors) + } + // The database was dropped and un-tracked. + if len(res.PurgedResources) == 0 { + t.Fatalf("expected purged resources, got none") + } + if len(conns) == 0 { + t.Fatal("purge must run Drop DDL against postgres") + } + joined := strings.Join(conns[0].execs, " | ") + if !strings.Contains(joined, "DROP DATABASE") { + t.Errorf("purge must DROP DATABASE: %s", joined) + } + rows, _ := d.DB.ProvisionedFor("app") + if len(rows) != 0 { + t.Errorf("purge should clear all provisioned rows, got %v", rows) + } +} + +func TestDestroyWorkspaceDefaultPreservesResources(t *testing.T) { + d, _ := destroyFixture(t) + ctx := context.Background() + d.PgConnect = func(context.Context, string) (provision.Conn, func() error, error) { + t.Fatal("data-preserving destroy must not connect to drop resources") + return nil, nil, nil + } + _ = d.DB.RecordProvisioned("app", "database", "app") + + res := destroyWorkspace(ctx, d, []string{"app"}, false) + if len(res.PurgedResources) != 0 { + t.Errorf("default destroy must not purge, got %v", res.PurgedResources) + } + // The provisioned database survives (data-preserving contract). + rows, _ := d.DB.ProvisionedFor("app") + if len(rows) != 1 { + t.Errorf("default destroy must preserve provisioned rows, got %v", rows) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f58a5c1..d1a001d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,6 +91,7 @@ func NewRootCmd(opts Options) *cobra.Command { newGenerateCmd(g), newTemplateCmd(g), newSharedCmd(g), + newResourceCmd(g), newWsCmd(g), newWorkspaceCmd(g), newUninstallCmd(g), diff --git a/internal/orchestrate/resource_ops.go b/internal/orchestrate/resource_ops.go new file mode 100644 index 0000000..f1d9823 --- /dev/null +++ b/internal/orchestrate/resource_ops.go @@ -0,0 +1,252 @@ +package orchestrate + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/resource" + "github.com/open-source-cloud/devstack/internal/secrets" + "github.com/open-source-cloud/devstack/internal/state" +) + +// This file is the imperative side of spec 27: the `resource create|rm|gc` +// commands' engine-agnostic mechanics, mirroring the resources saga phase +// (lock → overlay → provisioner → ledger → event) so there is ONE code path. The +// CLI (internal/cli) is a thin wrapper over these; they are tested here with the +// same mock docker client + injected Postgres connector the saga tests use. + +// ResourceRegistry exposes the engine→Provisioner registry (Postgres live; other +// engines land in Full scope), wired with the injected connector. +func ResourceRegistry(connect PgConnector) *resource.Registry { return resourceRegistry(connect) } + +// ResolveInstance returns the shared instance name serving engine (first shared +// service whose template == engine, matching the pgInstances convention). +func ResolveInstance(m *config.Model, engine string) (string, bool) { + for _, name := range sortedStringSlice(m.SharedNames()) { + if m.Workspace.Shared[name].Template == engine { + return name, true + } + } + return "", false +} + +// engineTarget resolves the host-reachable admin endpoint for an instance: it +// allocates/looks up the ledger port, writes+applies the per-engine 127.0.0.1 +// overlay via `compose up -d ` (idempotent, no recreate), and returns the +// Target with the instance's admin creds. Postgres is the only overlay wired. +func engineTarget(ctx context.Context, d UpDeps, engine, instance string) (resource.Target, error) { + ov, ok := engineOverlays[engine] + if !ok { + return resource.Target{}, fmt.Errorf("engine %q has no host-reachability overlay (only postgres in this milestone)", engine) + } + port, err := d.Manager.FreeHostPort(ctx, generate.SharedAlias(instance), ov.purpose, ov.portBase) + if err != nil { + return resource.Target{}, fmt.Errorf("allocate host port for %s: %w", instance, err) + } + overlay, err := writeProvisionOverlay(d.Model.Root, map[string]int{instance: port}) + if err != nil { + return resource.Target{}, err + } + outDir := filepath.Join(d.Model.Root, generate.GenDir, "shared") + runner := d.Runner + if runner == nil { + runner = docker.ExecRunner{} + } + cp := docker.Compose{ + Project: generate.SharedStackName, + File: filepath.Join(outDir, generate.ComposeFile), + Dir: outDir, Runner: runner, Overrides: []string{overlay}, + } + if err := cp.Up(ctx, instance); err != nil { + return resource.Target{}, fmt.Errorf("apply host overlay for %s: %w", instance, err) + } + params := d.Model.Workspace.Shared[instance].Params + return resource.Target{ + Instance: instance, Host: "127.0.0.1", Port: port, + AdminEnv: map[string]string{ + "user": paramString(params, "rootUser", "devstack"), + "password": paramString(params, "rootPassword", "devstack"), + }, + }, nil +} + +// CreateResource provisions one resource imperatively (idempotent) and records +// it. Returns the connection attributes (secrets included so the caller can mask). +func CreateResource(ctx context.Context, d UpDeps, r resource.Resource) (resource.Attrs, error) { + instance, ok := ResolveInstance(d.Model, r.Engine) + if !ok { + return nil, fmt.Errorf("no shared %q instance in this workspace (declare one under workspace.shared and run `devstack up`)", r.Engine) + } + reg := ResourceRegistry(d.PgConnect) + prov, ok := reg.For(r.Engine) + if !ok { + return nil, fmt.Errorf("no provisioner for engine %q (lands in spec 27 Full scope)", r.Engine) + } + if !resource.SupportsKind(r.Engine, r.Kind) { + return nil, fmt.Errorf("engine %q does not support kind %q", r.Engine, r.Kind) + } + if r.CredKind == resource.CredGenerated { + if _, set := r.Params["password"]; !set { + pw, err := secrets.RandomPassword(24) + if err != nil { + return nil, err + } + if r.Params == nil { + r.Params = map[string]any{} + } + r.Params["password"] = pw + } + } + target, err := engineTarget(ctx, d, r.Engine, instance) + if err != nil { + return nil, err + } + var attrs resource.Attrs + err = lock.WithLock(ctx, d.LockPath, func() error { + a, err := prov.Ensure(ctx, target, r) + if err != nil { + return err + } + attrs = a + if err := d.DB.RecordProvisioned(r.Owner, r.Kind, r.Name); err != nil { + return err + } + if r.Engine == "postgres" && r.Kind == "database" { + if role := a["role"]; role != "" { + if err := d.DB.RecordProvisioned(r.Owner, "role", role); err != nil { + return err + } + } + } + d.DB.LogEvent("provision", r.Owner, r.Kind+" on "+generate.SharedAlias(instance)) + return nil + }) + if err != nil { + return nil, err + } + return attrs, nil +} + +// DropResource removes a resource's ledger row, and — when purge is set — drops +// the underlying object first (destructive; the caller confirm-gates it). Without +// purge it only un-tracks the resource, leaving its bytes (the data survives). +func DropResource(ctx context.Context, d UpDeps, r resource.Resource, purge bool) error { + if purge { + instance, ok := ResolveInstance(d.Model, r.Engine) + if !ok { + return fmt.Errorf("no shared %q instance to drop %s/%s from", r.Engine, r.Kind, r.Name) + } + reg := ResourceRegistry(d.PgConnect) + prov, ok := reg.For(r.Engine) + if !ok { + return fmt.Errorf("no provisioner for engine %q (cannot --purge-data)", r.Engine) + } + target, err := engineTarget(ctx, d, r.Engine, instance) + if err != nil { + return err + } + if err := lock.WithLock(ctx, d.LockPath, func() error { + if err := prov.Drop(ctx, target, r); err != nil { + return err + } + return removeResourceRows(d.DB, r) + }); err != nil { + return err + } + d.DB.LogEvent("gc.drop", r.Owner, r.Kind+" "+r.Name+" purged from "+generate.SharedAlias(instance)) + return nil + } + // Un-track only (bytes preserved). + return lock.WithLock(ctx, d.LockPath, func() error { return removeResourceRows(d.DB, r) }) +} + +// removeResourceRows drops the resource's own ledger row plus, for a postgres +// database, the paired role row (the two EnsureProject records). +func removeResourceRows(db *state.DB, r resource.Resource) error { + if err := db.RemoveProvisioned(r.Owner, r.Kind, r.Name); err != nil { + return err + } + if r.Engine == "postgres" && r.Kind == "database" { + role := r.Name + if role == "" { + role = r.Owner + } + if err := db.RemoveProvisioned(r.Owner, "role", role); err != nil { + return err + } + } + return nil +} + +// GCResult reports the outcome of a resource gc pass. +type GCResult struct { + Reaped []map[string]string `json:"reaped"` // rows dropped + un-tracked + Skipped []map[string]string `json:"skipped"` // rows whose engine has no live provisioner/instance +} + +// GCResources reclaims orphaned resources (owner project no longer active). It +// resolves each row's engine from the provisioner catalog, drops it via the +// engine Provisioner (destructive — the caller confirm-gates), and un-tracks it. +// Rows whose engine has no live provisioner/instance are reported as skipped, +// never silently dropped from the ledger. Never recreates or bounces a container. +func GCResources(ctx context.Context, d UpDeps, active map[string]bool) (GCResult, error) { + var res GCResult + orphans, err := d.DB.OrphanedProvisioned(active) + if err != nil { + return res, err + } + reg := ResourceRegistry(d.PgConnect) + for _, o := range orphans { + engine, instance, prov, ok := engineForRow(d, reg, o.Kind) + if !ok { + res.Skipped = append(res.Skipped, map[string]string{ + "project": o.Project, "kind": o.Kind, "name": o.Name, + "reason": "no live provisioner/instance for this kind's engine", + }) + continue + } + r := resource.Resource{Engine: engine, Kind: o.Kind, Name: o.Name, Owner: o.Project} + target, err := engineTarget(ctx, d, engine, instance) + if err != nil { + return res, err + } + if err := lock.WithLock(ctx, d.LockPath, func() error { + if err := prov.Drop(ctx, target, r); err != nil { + return err + } + return d.DB.RemoveProvisioned(o.Project, o.Kind, o.Name) + }); err != nil { + return res, err + } + d.DB.LogEvent("gc.drop", o.Project, o.Kind+" "+o.Name+" reaped from "+generate.SharedAlias(instance)) + res.Reaped = append(res.Reaped, map[string]string{ + "project": o.Project, "engine": engine, "kind": o.Kind, "name": o.Name, + }) + } + return res, nil +} + +// engineForRow picks the engine whose registered provisioner lists kind AND has a +// live instance in the workspace. Postgres is the only live engine this milestone. +func engineForRow(d UpDeps, reg *resource.Registry, kind string) (engine, instance string, prov resource.Provisioner, ok bool) { + for _, e := range sortedStringSlice(reg.Engines()) { + p, has := reg.For(e) + if !has { + continue + } + for _, k := range p.Kinds() { + if k != kind { + continue + } + if inst, found := ResolveInstance(d.Model, e); found { + return e, inst, p, true + } + } + } + return "", "", nil, false +} diff --git a/internal/orchestrate/resource_ops_test.go b/internal/orchestrate/resource_ops_test.go new file mode 100644 index 0000000..b229389 --- /dev/null +++ b/internal/orchestrate/resource_ops_test.go @@ -0,0 +1,142 @@ +package orchestrate + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/resource" +) + +func TestCreateResourceImperative(t *testing.T) { + d, fr, db := upFixture(t) + rp := &recordingPg{} + d.PgConnect = rp.connect + + attrs, err := CreateResource(context.Background(), d, resource.Resource{ + Engine: "postgres", Kind: "database", Name: "reports", Owner: "app", CredKind: resource.CredPredictable, + }) + if err != nil { + t.Fatalf("CreateResource: %v", err) + } + if attrs["database"] != "reports" { + t.Errorf("attrs[database] = %q, want reports", attrs["database"]) + } + // Ledger recorded the database + its role. + rows, _ := db.ProvisionedFor("app") + var kinds []string + for _, r := range rows { + kinds = append(kinds, r.Kind+":"+r.Name) + } + if !slices.Contains(kinds, "database:reports") || !slices.Contains(kinds, "role:reports") { + t.Errorf("provisioned rows = %v, want database:reports + role:reports", kinds) + } + // The shared instance's loopback overlay was applied (compose up on shared stack). + if !fr.saw("-p "+"devstack-shared", "compose.provision.yaml") { + t.Errorf("overlay not applied via compose up: %v", fr.cmds) + } + // The create ran the guarded DDL on loopback. + joined := strings.Join(rp.conns[0].execs, " | ") + if !strings.Contains(joined, "CREATE DATABASE") { + t.Errorf("create DDL missing: %s", joined) + } +} + +func TestCreateResourceUnknownEngine(t *testing.T) { + d, _, _ := upFixture(t) + d.PgConnect = okPgConnect + _, err := CreateResource(context.Background(), d, resource.Resource{ + Engine: "cassandra", Kind: "keyspace", Name: "x", Owner: "app", + }) + if err == nil { + t.Fatal("CreateResource should fail for an engine with no shared instance") + } +} + +func TestDropResourceUntrackVsPurge(t *testing.T) { + // Un-track only: the ledger row goes, no Drop DDL runs. + d, _, db := upFixture(t) + rp := &recordingPg{} + d.PgConnect = rp.connect + _ = db.RecordProvisioned("app", "database", "reports") + _ = db.RecordProvisioned("app", "role", "reports") + + if err := DropResource(context.Background(), d, + resource.Resource{Engine: "postgres", Kind: "database", Name: "reports", Owner: "app"}, false); err != nil { + t.Fatalf("DropResource untrack: %v", err) + } + if rows, _ := db.ProvisionedFor("app"); len(rows) != 0 { + t.Errorf("un-track should remove database+role rows, got %v", rows) + } + if len(rp.conns) != 0 { + t.Error("un-track must not connect/drop (bytes preserved)") + } + + // Purge: Drop DDL runs and the rows go. + d2, _, db2 := upFixture(t) + rp2 := &recordingPg{} + d2.PgConnect = rp2.connect + _ = db2.RecordProvisioned("app", "database", "reports") + _ = db2.RecordProvisioned("app", "role", "reports") + + if err := DropResource(context.Background(), d2, + resource.Resource{Engine: "postgres", Kind: "database", Name: "reports", Owner: "app"}, true); err != nil { + t.Fatalf("DropResource purge: %v", err) + } + joined := strings.Join(rp2.conns[0].execs, " | ") + if !strings.Contains(joined, "DROP DATABASE") { + t.Errorf("purge must DROP DATABASE: %s", joined) + } + if rows, _ := db2.ProvisionedFor("app"); len(rows) != 0 { + t.Errorf("purge should remove the rows, got %v", rows) + } +} + +func TestGCResourcesReapsOrphans(t *testing.T) { + d, _, db := upFixture(t) + rp := &recordingPg{} + d.PgConnect = rp.connect + // A postgres database owned by a project no longer in the workspace. + _ = db.RecordProvisioned("gone", "database", "gone") + _ = db.RecordProvisioned("gone", "role", "gone") + // A bucket whose engine (minio) has no live provisioner → skipped, not dropped. + _ = db.RecordProvisioned("gone", "bucket", "gone-uploads") + + active := map[string]bool{"app": true} // "gone" is not active + res, err := GCResources(context.Background(), d, active) + if err != nil { + t.Fatalf("GCResources: %v", err) + } + // Postgres rows reaped; the DDL dropped the database. + if len(res.Reaped) == 0 { + t.Fatalf("expected reaped postgres rows, got %+v", res) + } + joined := strings.Join(rp.conns[0].execs, " | ") + if !strings.Contains(joined, "DROP DATABASE") { + t.Errorf("gc must DROP DATABASE for orphaned postgres db: %s", joined) + } + // The bucket (no live provisioner) was skipped and left in the ledger. + skippedBucket := false + for _, s := range res.Skipped { + if s["name"] == "gone-uploads" { + skippedBucket = true + } + } + if !skippedBucket { + t.Errorf("bucket with no provisioner should be skipped, got %+v", res.Skipped) + } + rows, _ := db.ProvisionedFor("gone") + stillBucket := false + for _, r := range rows { + if r.Kind == "bucket" { + stillBucket = true + } + if r.Kind == "database" || r.Kind == "role" { + t.Errorf("orphaned postgres row should be reaped, still present: %v", r) + } + } + if !stillBucket { + t.Error("un-reapable bucket row must NOT be silently dropped") + } +}