Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/datastore/aurora/aurora.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
16 changes: 16 additions & 0 deletions internal/datastore/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package datastore

import (
"context"
"encoding/json"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/datastore/mock_datastore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package datastore

import (
"context"
"encoding/json"
"time"

Expand All @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions internal/datastore/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions internal/datastore/sqlite/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/metastructure/extract_resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package metastructure

import (
"context"
"encoding/json"
"testing"
"time"
Expand All @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions internal/metastructure/metastructure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions internal/metastructure/rebind/plan.go
Original file line number Diff line number Diff line change
@@ -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)
}
63 changes: 63 additions & 0 deletions internal/metastructure/rebind/rebind.go
Original file line number Diff line number Diff line change
@@ -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
})
}
65 changes: 65 additions & 0 deletions internal/metastructure/rebind/rebind_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 4 additions & 2 deletions internal/schema/pkl/generator/tests/gen.pkl-expected.pcf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ examples {
group = null
target = null
stack = null
alias = null
managed = true
x = 42
name = "test"
Expand All @@ -16,6 +17,7 @@ examples {
group = null
target = null
stack = null
alias = null
managed = true
items {
"item1"
Expand All @@ -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")
Expand Down
Loading
Loading