diff --git a/internal/datastore/aurora/aurora.go b/internal/datastore/aurora/aurora.go index d21af31ef..ff52ec78f 100644 --- a/internal/datastore/aurora/aurora.go +++ b/internal/datastore/aurora/aurora.go @@ -101,6 +101,15 @@ func NewDatastoreAuroraDataAPI(ctx context.Context, cfg *pkgmodel.DatastoreConfi return d, nil } +// WithTx runs fn with the datastore as the transaction value. Aurora-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d *DatastoreAuroraDataAPI) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d *DatastoreAuroraDataAPI) runMigrations() error { ctx := context.Background() diff --git a/internal/datastore/datastore.go b/internal/datastore/datastore.go index 382207b29..ad9a13ec9 100644 --- a/internal/datastore/datastore.go +++ b/internal/datastore/datastore.go @@ -5,6 +5,7 @@ package datastore import ( + "context" "encoding/json" "time" @@ -16,6 +17,14 @@ import ( "github.com/platform-engineering-labs/formae/pkg/plugin" ) +// Tx represents a datastore transaction context handed to callers of +// WithTx. The marker carries no methods in this PR; subsequent RFC-0041 +// PRs (resource and stack rebind) will extend it with the specific +// write operations the rebinder needs. Keeping the interface marker-only +// for now means every Datastore implementation can satisfy it trivially +// without committing to a full transactional API up front. +type Tx interface{} + const ( CommandsTable string = "forma_commands" DefaultFormaCommandsQueryLimit = 10 @@ -111,6 +120,13 @@ type ResourceSnapshot struct { // It handles storage and retrieval of FormaCommands (requested changes), // Resources (actual cloud state), Stacks, and Targets. type Datastore interface { + // Transactional operations + + // WithTx runs fn inside a single datastore transaction. If fn returns + // an error, the transaction is rolled back. Used by the rebind phase + // (RFC-0041) to atomically apply identity-column rewrites. + WithTx(ctx context.Context, fn func(Tx) error) error + // FormaCommand operations - these represent requested changes to infrastructure // StoreFormaCommand persists a new FormaCommand with its ResourceUpdates diff --git a/internal/datastore/mock_datastore_test.go b/internal/datastore/mock_datastore_test.go index 2d3d9a250..dcbda2a3d 100644 --- a/internal/datastore/mock_datastore_test.go +++ b/internal/datastore/mock_datastore_test.go @@ -5,6 +5,7 @@ package datastore import ( + "context" "encoding/json" "time" @@ -22,6 +23,10 @@ type mockDatastore struct { name string } +func (m *mockDatastore) WithTx(_ context.Context, fn func(Tx) error) error { + return fn(m) +} + func (m *mockDatastore) StoreFormaCommand(_ *forma_command.FormaCommand, _ string) error { return nil } diff --git a/internal/datastore/postgres/postgres.go b/internal/datastore/postgres/postgres.go index 73c1cfd6c..7db23fc2e 100644 --- a/internal/datastore/postgres/postgres.go +++ b/internal/datastore/postgres/postgres.go @@ -178,6 +178,15 @@ func NewDatastorePostgres(ctx context.Context, cfg *pkgmodel.DatastoreConfig, ag return d, nil } +// WithTx runs fn with the datastore as the transaction value. Postgres-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d DatastorePostgres) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d DatastorePostgres) StoreFormaCommand(fa *forma_command.FormaCommand, commandID string) error { ctx, span := tracer.Start(context.Background(), "StoreFormaCommand") defer span.End() diff --git a/internal/datastore/sqlite/sqlite.go b/internal/datastore/sqlite/sqlite.go index 5b6f684bf..a71ce7a95 100644 --- a/internal/datastore/sqlite/sqlite.go +++ b/internal/datastore/sqlite/sqlite.go @@ -116,6 +116,15 @@ func NewDatastoreSQLite(ctx context.Context, cfg *pkgmodel.DatastoreConfig, agen return d, nil } +// WithTx runs fn with the datastore as the transaction value. SQLite-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d DatastoreSQLite) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d DatastoreSQLite) ClearCommandsTable() error { _, err := d.conn.Exec(fmt.Sprintf("DELETE FROM %s", datastore.CommandsTable)) if err != nil { diff --git a/internal/metastructure/extract_resources_test.go b/internal/metastructure/extract_resources_test.go index a138fdd7b..0f1323583 100644 --- a/internal/metastructure/extract_resources_test.go +++ b/internal/metastructure/extract_resources_test.go @@ -5,6 +5,7 @@ package metastructure import ( + "context" "encoding/json" "testing" "time" @@ -30,6 +31,10 @@ type mockExtractDatastore struct { policies map[string]pkgmodel.Policy } +func (m *mockExtractDatastore) WithTx(_ context.Context, fn func(datastore.Tx) error) error { + return fn(m) +} + func (m *mockExtractDatastore) QueryResources(_ *datastore.ResourceQuery) ([]*pkgmodel.Resource, error) { return m.resources, nil } diff --git a/internal/metastructure/metastructure.go b/internal/metastructure/metastructure.go index 27af07aae..7ec66d3b3 100644 --- a/internal/metastructure/metastructure.go +++ b/internal/metastructure/metastructure.go @@ -33,6 +33,7 @@ import ( "github.com/platform-engineering-labs/formae/internal/metastructure/messages" "github.com/platform-engineering-labs/formae/internal/metastructure/policy_update" "github.com/platform-engineering-labs/formae/internal/metastructure/querier" + "github.com/platform-engineering-labs/formae/internal/metastructure/rebind" "github.com/platform-engineering-labs/formae/internal/metastructure/resource_update" "github.com/platform-engineering-labs/formae/internal/metastructure/stack_update" "github.com/platform-engineering-labs/formae/internal/metastructure/target_update" @@ -281,6 +282,17 @@ func (m *Metastructure) ApplyForma(forma *pkgmodel.Forma, config *config.FormaCo } } + // RFC-0041: run the rebind phase ahead of update generation so the + // downstream diff observes any renamed identities. PR 1 ships this + // as a no-op when no aliases are declared; later PRs add real rename + // behaviour without touching the wiring. + if !config.Simulate { + rebinder := rebind.NewRebinder(m.Datastore) + if err := rebinder.Run(context.Background(), *forma, clientID); err != nil { + return nil, err + } + } + fa, err := FormaCommandFromForma(forma, config, pkgmodel.CommandApply, m.Datastore, clientID, resource_update.FormaCommandSourceUser) if err != nil { if requiredFieldsErr, ok := err.(apimodel.RequiredFieldMissingOnCreateError); ok { diff --git a/internal/metastructure/rebind/plan.go b/internal/metastructure/rebind/plan.go new file mode 100644 index 000000000..f8227e089 --- /dev/null +++ b/internal/metastructure/rebind/plan.go @@ -0,0 +1,54 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +// Package rebind implements the rename rebind phase described in RFC-0041. +// +// The phase is split into a read-only validation step that produces a +// RebindPlan and an execution step that applies the plan inside a single +// datastore transaction. This package owns both halves. +package rebind + +// RebindPlan is the output of the validation step. Execution applies the +// identity-column rewrites it contains to the metastructure. +// +// Each kind of write has its own concrete type so the executor cannot +// accidentally mix scopes. In particular, ResourceRebind only carries +// a new label: moving a resource between stacks or targets via a +// per-resource alias is out of scope for this RFC. Cross-stack moves +// are only possible through a StackRebind + its cascade. +type RebindPlan struct { + Stacks []StackRebind + Resources []ResourceRebind + StackCascades []ResourceStackCascade + // Targets and TargetCascades are intentionally absent. Target rename + // is deferred to a follow-up RFC (RFC-0041 §Scope of this RFC). +} + +// ResourceRebind is a resource label rename. Only `label` is mutable +// here; moving a resource across stacks or targets is out of scope +// for this RFC. +type ResourceRebind struct { + Ksuid string // stable identity, unchanged + NewLabel string +} + +// ResourceStackCascade is the per-child write produced by a stack +// rename. It only touches resources.stack; the resource label and +// target stay put. +type ResourceStackCascade struct { + Ksuid string // resource being cascaded + NewStack string // new parent stack label +} + +// StackRebind is one identity-column rewrite of a stack. +type StackRebind struct { + ID string // stable Stack.ID, unchanged + NewLabel string +} + +// IsEmpty reports whether the plan has nothing to do. +func (p *RebindPlan) IsEmpty() bool { + return p == nil || + (len(p.Stacks) == 0 && len(p.Resources) == 0 && len(p.StackCascades) == 0) +} diff --git a/internal/metastructure/rebind/rebind.go b/internal/metastructure/rebind/rebind.go new file mode 100644 index 000000000..b6a19d360 --- /dev/null +++ b/internal/metastructure/rebind/rebind.go @@ -0,0 +1,63 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package rebind + +import ( + "context" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// Rebinder runs the alias rebind phase of an apply (see RFC-0041). +// +// PR 1 wires the skeleton into the apply pipeline as a no-op when no +// aliases are declared. Resource and stack rename logic land in +// follow-up PRs (RFC-0041 §Implementation plan). +type Rebinder struct { + ds datastore.Datastore +} + +// NewRebinder constructs a Rebinder backed by the given datastore. +func NewRebinder(ds datastore.Datastore) *Rebinder { + return &Rebinder{ds: ds} +} + +// Run validates the forma's aliases, then executes the resulting plan. +// Callers must hold the apply-level commandMu lock to ensure validation +// and execution observe a consistent metastructure snapshot. +func (r *Rebinder) Run(ctx context.Context, forma pkgmodel.Forma, commandID string) error { + plan, err := r.Validate(ctx, forma) + if err != nil { + return err + } + if plan.IsEmpty() { + return nil + } + return r.Execute(ctx, plan, commandID) +} + +// Validate walks the forma, consults declared aliases, and returns a +// RebindPlan or an error describing the first conflict. +// +// PR 1: always returns an empty plan. Real validation logic lands in +// PR 2 (resource rename) and PR 7 (stack rename). +func (r *Rebinder) Validate(ctx context.Context, forma pkgmodel.Forma) (*RebindPlan, error) { + _ = ctx + _ = forma + return &RebindPlan{}, nil +} + +// Execute applies the plan inside a single datastore transaction. +// +// PR 1: empty body; only invoked when the plan is non-empty, which is +// impossible while Validate is a no-op. Reserved for PR 2. +func (r *Rebinder) Execute(ctx context.Context, plan *RebindPlan, commandID string) error { + _ = commandID + return r.ds.WithTx(ctx, func(_ datastore.Tx) error { + _ = plan + return nil + }) +} diff --git a/internal/metastructure/rebind/rebind_test.go b/internal/metastructure/rebind/rebind_test.go new file mode 100644 index 000000000..4f3ace9cc --- /dev/null +++ b/internal/metastructure/rebind/rebind_test.go @@ -0,0 +1,65 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package rebind + +import ( + "context" + "testing" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +func TestRebinder_Run_NoAliases_IsNoOp(t *testing.T) { + ds := &stubDatastore{} + r := NewRebinder(ds) + + forma := pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "prod"}}, + Targets: []pkgmodel.Target{{Label: "aws-prod"}}, + Resources: []pkgmodel.Resource{{Label: "web-server", Stack: "prod", Target: "aws-prod"}}, + } + + if err := r.Run(context.Background(), forma, "cmd-1"); err != nil { + t.Fatalf("Run with no aliases should be a no-op, got error: %v", err) + } + + if ds.withTxCalls != 0 { + t.Fatalf("expected WithTx to NOT be called when plan is empty, got %d calls", ds.withTxCalls) + } +} + +func TestRebinder_Validate_NoAliases_ReturnsEmptyPlan(t *testing.T) { + ds := &stubDatastore{} + r := NewRebinder(ds) + + forma := pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "prod"}}, + Resources: []pkgmodel.Resource{{Label: "web-server", Stack: "prod"}}, + } + + plan, err := r.Validate(context.Background(), forma) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !plan.IsEmpty() { + t.Fatalf("expected empty plan, got %+v", plan) + } +} + +// stubDatastore embeds the Datastore interface to satisfy the compiler; +// only WithTx is overridden because that is the only method exercised +// by the rebinder under test. Any other interface method invocation +// would panic on the nil embedded interface, which is the desired test +// behaviour ("you broke encapsulation"). +type stubDatastore struct { + datastore.Datastore + withTxCalls int +} + +func (s *stubDatastore) WithTx(_ context.Context, fn func(datastore.Tx) error) error { + s.withTxCalls++ + return fn(s) +} diff --git a/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf b/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf index 21cd800a1..becc0ba1c 100644 --- a/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf +++ b/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf @@ -5,6 +5,7 @@ examples { group = null target = null stack = null + alias = null managed = true x = 42 name = "test" @@ -16,6 +17,7 @@ examples { group = null target = null stack = null + alias = null managed = true items { "item1" @@ -27,10 +29,10 @@ examples { } } ["Convert to map with types - simple"] { - Map("label", "simple", "group", null, "target", null, "stack", null, "managed", true, "x", 42, "name", "test") + Map("label", "simple", "group", null, "target", null, "stack", null, "alias", null, "managed", true, "x", 42, "name", "test") } ["Convert to map with types - complex"] { - Map("label", "my-bucket", "group", null, "target", null, "stack", null, "managed", true, "type", "FakePlugin::Service::TestResource", "size", null, "tags", List(), "bucketName", "my-test-bucket") + Map("label", "my-bucket", "group", null, "target", null, "stack", null, "alias", null, "managed", true, "type", "FakePlugin::Service::TestResource", "size", null, "tags", List(), "bucketName", "my-test-bucket") } ["Generate import statements"] { List("@aws/s3.pkl") diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index 9ffe5fa80..f2576f7c4 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -19,11 +19,22 @@ open class Description { fixed Confirm: Boolean = confirm } +/// Marks a previous identity of a Resource, Stack, or Target. +/// Used by formae to rebind an existing managed row to a new +/// identity without destroy/recreate. See RFC-0041. +open class Alias { + hidden label: String(length > 0) + + // Output + fixed Label: String = label +} + open class Resource { label: String group: String? target: TargetResolvable? stack: StackResolvable? + alias: Alias? fixed managed: Boolean = true local self = this @@ -38,6 +49,7 @@ open class Resource { Group = self.group Target = self.target Stack = self.stack + Alias = self.alias Managed = self.managed Type = self.type() @@ -55,6 +67,7 @@ open class Stack { hidden label: String(length > 0) hidden description: String hidden policies: Listing? + hidden alias: Alias? local parent = this hidden res: StackResolvable = new { @@ -64,6 +77,7 @@ open class Stack { // Output fixed Label: String = label fixed Description: String = description + fixed Alias: Alias? = alias fixed Policies: Listing? = policies?.toList()?.map((p) -> if (p is PolicyResolvable) new Dynamic { `$ref` = "policy://\(p.label)" } @@ -232,6 +246,7 @@ open class Target { hidden namespace: String = config.type.toUpperCase() hidden config: Any? hidden discoverable: Boolean = true + hidden alias: Alias? local self = this hidden res: TargetResolvable = new { @@ -242,6 +257,7 @@ open class Target { fixed Label: String = label fixed Namespace: String = namespace fixed Config: Any? = config + fixed Alias: Alias? = alias fixed ConfigSchema: ConfigSchema? = if (config != null) let (hints = module.fq.configHints(reflect.Class(config.getClass()))) diff --git a/pkg/api/model/types.go b/pkg/api/model/types.go index 00deb5505..27893dc84 100644 --- a/pkg/api/model/types.go +++ b/pkg/api/model/types.go @@ -95,6 +95,7 @@ const ( OperationDelete = "delete" OperationRead = "read" OperationReplace = "replace" // delete + create + OperationRebind = "rebind" // RFC-0041 identity-column rewrite (no plugin call) ) const ( diff --git a/pkg/model/alias.go b/pkg/model/alias.go new file mode 100644 index 000000000..a30ed67f0 --- /dev/null +++ b/pkg/model/alias.go @@ -0,0 +1,12 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +// Alias marks a previous identity of a Resource, Stack, or Target. +// Used by the rebind phase to find an existing managed row by its old +// label and rewrite its identity columns to the new label. See RFC-0041. +type Alias struct { + Label string `json:"Label" pkl:"Label"` +} diff --git a/pkg/model/alias_test.go b/pkg/model/alias_test.go new file mode 100644 index 000000000..81ec4df50 --- /dev/null +++ b/pkg/model/alias_test.go @@ -0,0 +1,96 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestAlias_JSONRoundTrip(t *testing.T) { + original := Alias{Label: "old-name"} + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded Alias + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if decoded.Label != "old-name" { + t.Fatalf("expected Label=old-name, got %q", decoded.Label) + } +} + +func TestResource_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "app-server", + "Stack": "prod", + "Target": "aws-prod", + "Type": "EC2.Instance", + "Schema": {"Fields": []}, + "Properties": {}, + "Alias": {"Label": "web-server"} + }`) + + var r Resource + if err := json.Unmarshal(jsonInput, &r); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if r.Alias == nil { + t.Fatalf("expected Alias to be populated") + } + if r.Alias.Label != "web-server" { + t.Fatalf("expected Alias.Label=web-server, got %q", r.Alias.Label) + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(data, []byte(`"Alias":{"Label":"web-server"}`)) { + t.Fatalf("Alias not present in marshalled JSON: %s", data) + } +} + +func TestStack_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "production", + "Description": "prod env", + "Alias": {"Label": "prod"} + }`) + + var s Stack + if err := json.Unmarshal(jsonInput, &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if s.Alias == nil || s.Alias.Label != "prod" { + t.Fatalf("expected Alias.Label=prod, got %+v", s.Alias) + } +} + +func TestTarget_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "aws-prod-eu", + "Namespace": "AWS", + "Discoverable": true, + "Alias": {"Label": "aws-prod"} + }`) + + var tt Target + if err := json.Unmarshal(jsonInput, &tt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if tt.Alias == nil || tt.Alias.Label != "aws-prod" { + t.Fatalf("expected Alias.Label=aws-prod, got %+v", tt.Alias) + } +} diff --git a/pkg/model/resource.go b/pkg/model/resource.go index 4f924bda1..0dfe01fcc 100644 --- a/pkg/model/resource.go +++ b/pkg/model/resource.go @@ -32,6 +32,7 @@ type Resource struct { NativeID string `json:"NativeID,omitempty"` Managed bool `json:"Managed,omitempty"` // Whether the resource is managed by Formae or not Ksuid string `json:"Ksuid,omitempty"` + Alias *Alias `json:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } // TupleKey returns the lookup key for this resource in the format: type/stack/label diff --git a/pkg/model/stack.go b/pkg/model/stack.go index 772daebdc..9b7496c34 100644 --- a/pkg/model/stack.go +++ b/pkg/model/stack.go @@ -18,6 +18,7 @@ type Stack struct { Description string `json:"Description"` Policies []json.RawMessage `json:"Policies,omitempty"` // Inline policies from PKL CreatedAt time.Time `json:"CreatedAt,omitempty"` + Alias *Alias `json:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } // IsPolicyReference checks if a raw policy JSON is a reference ($ref) rather than inline diff --git a/pkg/model/target.go b/pkg/model/target.go index 5e1e49c84..af24ae854 100644 --- a/pkg/model/target.go +++ b/pkg/model/target.go @@ -30,6 +30,7 @@ type Target struct { ConfigSchema ConfigSchema `json:"ConfigSchema,omitzero" pkl:"ConfigSchema,omitempty"` Discoverable bool `json:"Discoverable" pkl:"Discoverable"` Version int `json:"Version,omitempty"` + Alias *Alias `json:"Alias,omitempty" pkl:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } func NewTargetFromString(target string) Target {