diff --git a/internal/datastore/aurora/aurora_generators.go b/internal/datastore/aurora/aurora_generators.go new file mode 100644 index 000000000..40b45db68 --- /dev/null +++ b/internal/datastore/aurora/aurora_generators.go @@ -0,0 +1,325 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package aurora + +import ( + "context" + "fmt" + "log/slog" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rdsdata/types" + "github.com/demula/mksuid/v2" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// CreateGenerator persists a new generator. stack_id stores the stack's +// resolved KSUID — like policy_id on an inline policy, not the label — read +// off gen.GetStackID(). Unlike CreatePolicy the column is never NULL: a +// generator is always inline to exactly one stack. +func (d *DatastoreAuroraDataAPI) CreateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx := context.Background() + + id := mksuid.New().String() + version := mksuid.New().String() + + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + query := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (:id, :version, :command_id, :operation, :label, :generator_type, :stack_id, :generator_data)` + params := []types.SqlParameter{ + {Name: aws.String("id"), Value: &types.FieldMemberStringValue{Value: id}}, + {Name: aws.String("version"), Value: &types.FieldMemberStringValue{Value: version}}, + {Name: aws.String("command_id"), Value: &types.FieldMemberStringValue{Value: commandID}}, + {Name: aws.String("operation"), Value: &types.FieldMemberStringValue{Value: "create"}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: gen.GetLabel()}}, + {Name: aws.String("generator_type"), Value: &types.FieldMemberStringValue{Value: gen.GetType()}}, + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: gen.GetStackID()}}, + {Name: aws.String("generator_data"), Value: &types.FieldMemberStringValue{Value: string(data)}}, + } + + _, err = d.executeStatement(ctx, query, params) + if err != nil { + slog.Error("Failed to create generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// UpdateGenerator persists a new version of an existing generator. The +// existing row is found by label and stack ID — a generator has no +// standalone form, so unlike UpdatePolicy there is no NULL-stack branch — +// and the new version row carries forward the same id. +func (d *DatastoreAuroraDataAPI) UpdateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx := context.Background() + + selectQuery := ` + SELECT id FROM generators + WHERE label = :label AND stack_id = :stack_id + ORDER BY version COLLATE "C" DESC + LIMIT 1 + ` + selectParams := []types.SqlParameter{ + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: gen.GetLabel()}}, + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: gen.GetStackID()}}, + } + + result, err := d.executeStatement(ctx, selectQuery, selectParams) + if err != nil { + return "", fmt.Errorf("failed to find existing generator: %w", err) + } + if len(result.Records) == 0 { + return "", fmt.Errorf("generator not found: %s", gen.GetLabel()) + } + + id, err := getStringField(result.Records[0][0]) + if err != nil { + return "", fmt.Errorf("failed to get generator id: %w", err) + } + + version := mksuid.New().String() + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (:id, :version, :command_id, :operation, :label, :generator_type, :stack_id, :generator_data)` + insertParams := []types.SqlParameter{ + {Name: aws.String("id"), Value: &types.FieldMemberStringValue{Value: id}}, + {Name: aws.String("version"), Value: &types.FieldMemberStringValue{Value: version}}, + {Name: aws.String("command_id"), Value: &types.FieldMemberStringValue{Value: commandID}}, + {Name: aws.String("operation"), Value: &types.FieldMemberStringValue{Value: "update"}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: gen.GetLabel()}}, + {Name: aws.String("generator_type"), Value: &types.FieldMemberStringValue{Value: gen.GetType()}}, + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: gen.GetStackID()}}, + {Name: aws.String("generator_data"), Value: &types.FieldMemberStringValue{Value: string(data)}}, + } + + _, err = d.executeStatement(ctx, insertQuery, insertParams) + if err != nil { + slog.Error("Failed to update generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// DeleteGenerator soft-deletes the generator with the given label on the +// given stack. The stack is resolved from its label the same way +// GetGenerator does; a stack that doesn't exist has nothing to delete. A +// label with no live match is a no-op success that returns an empty version, +// mirroring DeletePolicy. +func (d *DatastoreAuroraDataAPI) DeleteGenerator(label, stackLabel string) (string, error) { + ctx := context.Background() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return "", fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return "", nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_type, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = :stack_id AND label = :label + ) + SELECT id, generator_type + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + params := []types.SqlParameter{ + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: stack.ID}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: label}}, + } + result, err := d.executeStatement(ctx, query, params) + if err != nil { + return "", fmt.Errorf("failed to get generator for deletion: %w", err) + } + if len(result.Records) == 0 { + return "", nil + } + + record := result.Records[0] + id, err := getStringField(record[0]) + if err != nil { + return "", fmt.Errorf("failed to get generator id: %w", err) + } + generatorType, err := getStringField(record[1]) + if err != nil { + return "", fmt.Errorf("failed to get generator type: %w", err) + } + + version := mksuid.New().String() + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (:id, :version, :command_id, :operation, :label, :generator_type, :stack_id, :generator_data)` + insertParams := []types.SqlParameter{ + {Name: aws.String("id"), Value: &types.FieldMemberStringValue{Value: id}}, + {Name: aws.String("version"), Value: &types.FieldMemberStringValue{Value: version}}, + {Name: aws.String("command_id"), Value: &types.FieldMemberStringValue{Value: ""}}, + {Name: aws.String("operation"), Value: &types.FieldMemberStringValue{Value: "delete"}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: label}}, + {Name: aws.String("generator_type"), Value: &types.FieldMemberStringValue{Value: generatorType}}, + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: stack.ID}}, + {Name: aws.String("generator_data"), Value: &types.FieldMemberStringValue{Value: "{}"}}, + } + _, err = d.executeStatement(ctx, insertQuery, insertParams) + if err != nil { + return "", fmt.Errorf("failed to delete generator: %w", err) + } + + slog.Debug("Deleted generator", "label", label, "id", id, "stackLabel", stackLabel) + + return version, nil +} + +// GetGenerator retrieves the current (latest, non-deleted) generator with the +// given label on the given stack. The stack label is resolved to its +// current KSUID first, since generators.stack_id stores the stack's id, not +// its label — mirroring how a policy's inline lookups are scoped by stack +// ID. Returns nil, nil if no live stack or no live generator matches. +func (d *DatastoreAuroraDataAPI) GetGenerator(label, stackLabel string) (pkgmodel.Generator, error) { + ctx := context.Background() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = :stack_id AND label = :label + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + params := []types.SqlParameter{ + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: stack.ID}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: label}}, + } + result, err := d.executeStatement(ctx, query, params) + if err != nil { + return nil, fmt.Errorf("failed to get generator: %w", err) + } + if len(result.Records) == 0 { + return nil, nil + } + + dataStr, err := getStringField(result.Records[0][0]) + if err != nil { + return nil, fmt.Errorf("failed to get generator data: %w", err) + } + + return datastore.GeneratorFromData([]byte(dataStr)) +} + +// LoadGeneratorsByStack returns all non-deleted generators owned by a stack. +// The stack label is resolved to its current KSUID first, for the same +// reason GetGenerator does. A stack that doesn't exist owns no generators. +func (d *DatastoreAuroraDataAPI) LoadGeneratorsByStack(stackLabel string) ([]pkgmodel.Generator, error) { + ctx := context.Background() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = :stack_id + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + params := []types.SqlParameter{ + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: stack.ID}}, + } + result, err := d.executeStatement(ctx, query, params) + if err != nil { + return nil, err + } + + var generators []pkgmodel.Generator + for _, record := range result.Records { + if len(record) < 1 { + continue + } + dataStr, err := getStringField(record[0]) + if err != nil { + slog.Warn("Failed to read generator data, skipping", "error", err, "stackLabel", stackLabel) + continue + } + gen, err := datastore.GeneratorFromData([]byte(dataStr)) + if err != nil { + slog.Warn("Failed to deserialize generator, skipping", "error", err, "stackLabel", stackLabel) + continue + } + generators = append(generators, gen) + } + + return generators, nil +} + +// GeneratorIDForTesting returns the internal KSUID identity (the id column, +// stable across CreateGenerator/UpdateGenerator) of the current (max-version) +// generator row with the given label on the given stack, or "" if none +// exists. Generator has no public API that exposes this id — the Datastore +// interface returns only version strings — so the dstest suite needs a +// direct accessor to prove the id survives an update unchanged. +func (d *DatastoreAuroraDataAPI) GeneratorIDForTesting(label, stackLabel string) (string, error) { + ctx := context.Background() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return "", fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return "", nil + } + + query := ` + SELECT id FROM generators + WHERE stack_id = :stack_id AND label = :label + ORDER BY version COLLATE "C" DESC + LIMIT 1 + ` + params := []types.SqlParameter{ + {Name: aws.String("stack_id"), Value: &types.FieldMemberStringValue{Value: stack.ID}}, + {Name: aws.String("label"), Value: &types.FieldMemberStringValue{Value: label}}, + } + result, err := d.executeStatement(ctx, query, params) + if err != nil { + return "", err + } + if len(result.Records) == 0 { + return "", nil + } + + return getStringField(result.Records[0][0]) +} diff --git a/internal/datastore/aurora/aurora_test.go b/internal/datastore/aurora/aurora_test.go index 7d701586b..f00fc4f2b 100644 --- a/internal/datastore/aurora/aurora_test.go +++ b/internal/datastore/aurora/aurora_test.go @@ -76,6 +76,9 @@ func TestDatastore(t *testing.T) { NullFormaCommandSubjectForTest: func(commandID string) error { return d.NullFormaCommandSubjectForTesting(commandID) }, + GeneratorIDForTest: func(label, stackLabel string) (string, error) { + return d.GeneratorIDForTesting(label, stackLabel) + }, } }) } diff --git a/internal/datastore/datastore.go b/internal/datastore/datastore.go index 5bf2c4efa..6d808e606 100644 --- a/internal/datastore/datastore.go +++ b/internal/datastore/datastore.go @@ -486,6 +486,28 @@ type Datastore interface { // that are not in a terminal state (Success, Failed, Canceled) StackHasActiveCommands(stackLabel string) (bool, error) + // Generator operations - a generator produces a value (e.g. a random + // password) that a secret will later reference. Unlike a policy, a + // generator has no standalone form: it is always owned by exactly one + // stack, so there is no stack_generators junction table and no + // attach/detach. + + // CreateGenerator persists a new generator (returns version string) + CreateGenerator(gen pkgmodel.Generator, commandID string) (string, error) + // UpdateGenerator persists a new version of an existing generator, found + // by label and stack (returns version string) + UpdateGenerator(gen pkgmodel.Generator, commandID string) (string, error) + // DeleteGenerator soft-deletes the generator with the given label on the + // given stack (returns version string). A label with no live match is a + // no-op success that returns an empty version. + DeleteGenerator(label, stackLabel string) (string, error) + // GetGenerator retrieves the current generator with the given label on + // the given stack. Returns nil, nil if no live generator is found. + GetGenerator(label, stackLabel string) (pkgmodel.Generator, error) + // LoadGeneratorsByStack returns all non-deleted generators owned by a + // stack. + LoadGeneratorsByStack(stackLabel string) ([]pkgmodel.Generator, error) + // Close releases database connections Close() diff --git a/internal/datastore/dstest/dstest.go b/internal/datastore/dstest/dstest.go index 1c5aaa5ff..3bc9c142c 100644 --- a/internal/datastore/dstest/dstest.go +++ b/internal/datastore/dstest/dstest.go @@ -97,6 +97,15 @@ type TestDatastore struct { // Backends that don't provide it leave it nil and the relevant tests // t.Skip(). NullFormaCommandSubjectForTest func(commandID string) error + // GeneratorIDForTest returns the internal KSUID identity (the id column, + // stable across CreateGenerator/UpdateGenerator) of the current + // (max-version) generator row with the given label on the given stack, or + // "" if none exists. Generator has no public API that exposes this id — + // the Datastore interface returns only version strings — so the suite + // needs a direct accessor to prove the id survives an update unchanged. + // Backends that don't provide it leave it nil and the relevant tests + // t.Skip(). + GeneratorIDForTest func(label, stackLabel string) (string, error) } // RunAll runs the full datastore test suite against the provided factory. @@ -231,6 +240,13 @@ func RunAll(t *testing.T, newDS func(t *testing.T) TestDatastore) { RunDeleteInlinePolicyClearsExpiry(t, newDS) RunDeleteInlinePolicyThenRecreate(t, newDS) + RunCreateGeneratorThenGet(t, newDS) + RunGetGeneratorAbsentReturnsNil(t, newDS) + RunUpdateGeneratorBumpsVersionAndReadBackReflectsIt(t, newDS) + RunDeleteGeneratorThenGetReturnsNil(t, newDS) + RunLoadGeneratorsByStackReturnsOnlyThatStacksGenerators(t, newDS) + RunGeneratorKSUIDStableAcrossUpdate(t, newDS) + RunFindResourcesDependingOn(t, newDS) RunFindResourcesDependingOnMultipleRefs(t, newDS) RunFindResourcesDependingOnNoRefs(t, newDS) diff --git a/internal/datastore/dstest/suite_generators.go b/internal/datastore/dstest/suite_generators.go new file mode 100644 index 000000000..e2fa25dcd --- /dev/null +++ b/internal/datastore/dstest/suite_generators.go @@ -0,0 +1,201 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +//go:build unit + +package dstest + +import ( + "testing" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createGeneratorStack creates a stack for the generator suite tests and +// returns it with its generated ID populated. A generator is always inline +// to a stack — unlike a policy it has no standalone form — so every test +// needs one, and generators.stack_id stores the stack's KSUID, so tests need +// the ID as well as the label. +func createGeneratorStack(t *testing.T, ds datastore.Datastore, label string) *pkgmodel.Stack { + t.Helper() + stack := &pkgmodel.Stack{Label: label, Description: "generator lookup"} + _, err := ds.CreateStack(stack, "cmd-stack") + require.NoError(t, err) + require.NotEmpty(t, stack.ID) + return stack +} + +// testPasswordGenerator returns a password generator on the given stack, with +// Length as the one field the suite varies to observe an update. StackID is +// set directly from the resolved stack, the way the policy suite sets +// StackID on a TTLPolicy — the label-to-ID resolution a real apply performs +// is out of scope here. +func testPasswordGenerator(label string, stack *pkgmodel.Stack, length int) *pkgmodel.PasswordGenerator { + return &pkgmodel.PasswordGenerator{ + Label: label, + Stack: stack.Label, + StackID: stack.ID, + Length: length, + Uppercase: true, + Lowercase: true, + Digits: true, + RequireEachIncludedType: true, + } +} + +// RunCreateGeneratorThenGet verifies that a created generator is retrievable +// by label and stack, with its data intact. +func RunCreateGeneratorThenGet(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("CreateGenerator_ThenGet", func(t *testing.T) { + td := newDS(t) + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + stack := createGeneratorStack(t, ds, "generator-owner") + gen := testPasswordGenerator("db-password", stack, 24) + + version, err := ds.CreateGenerator(gen, "cmd-create") + require.NoError(t, err) + require.NotEmpty(t, version) + + got, err := ds.GetGenerator("db-password", stack.Label) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "db-password", got.GetLabel()) + assert.Equal(t, "password", got.GetType()) + assert.Equal(t, stack.Label, got.GetStack()) + pw, ok := got.(*pkgmodel.PasswordGenerator) + require.True(t, ok, "GetGenerator must return the concrete password generator type") + assert.Equal(t, 24, pw.Length) + }) +} + +// RunGetGeneratorAbsentReturnsNil verifies that looking up a generator that +// was never created returns nil, nil rather than an error. +func RunGetGeneratorAbsentReturnsNil(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("GetGenerator_AbsentReturnsNil", func(t *testing.T) { + td := newDS(t) + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + stack := createGeneratorStack(t, ds, "generator-empty") + + got, err := ds.GetGenerator("never-created", stack.Label) + require.NoError(t, err) + assert.Nil(t, got) + }) +} + +// RunUpdateGeneratorBumpsVersionAndReadBackReflectsIt verifies that updating a +// generator returns a new version distinct from creation, and that a +// subsequent GetGenerator reflects the updated data. +func RunUpdateGeneratorBumpsVersionAndReadBackReflectsIt(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("UpdateGenerator_BumpsVersionAndReadBackReflectsIt", func(t *testing.T) { + td := newDS(t) + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + stack := createGeneratorStack(t, ds, "generator-updated") + createVersion, err := ds.CreateGenerator(testPasswordGenerator("api-key", stack, 16), "cmd-create") + require.NoError(t, err) + + updateVersion, err := ds.UpdateGenerator(testPasswordGenerator("api-key", stack, 32), "cmd-update") + require.NoError(t, err) + + assert.NotEqual(t, createVersion, updateVersion, "an update must mint a new version") + + got, err := ds.GetGenerator("api-key", stack.Label) + require.NoError(t, err) + require.NotNil(t, got) + pw, ok := got.(*pkgmodel.PasswordGenerator) + require.True(t, ok) + assert.Equal(t, 32, pw.Length, "the read-back generator must reflect the update") + }) +} + +// RunDeleteGeneratorThenGetReturnsNil verifies that a deleted generator is no +// longer returned by GetGenerator. +func RunDeleteGeneratorThenGetReturnsNil(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("DeleteGenerator_ThenGetReturnsNil", func(t *testing.T) { + td := newDS(t) + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + stack := createGeneratorStack(t, ds, "generator-deleted") + _, err := ds.CreateGenerator(testPasswordGenerator("temp-secret", stack, 20), "cmd-create") + require.NoError(t, err) + + version, err := ds.DeleteGenerator("temp-secret", stack.Label) + require.NoError(t, err) + require.NotEmpty(t, version) + + got, err := ds.GetGenerator("temp-secret", stack.Label) + require.NoError(t, err) + assert.Nil(t, got) + }) +} + +// RunLoadGeneratorsByStackReturnsOnlyThatStacksGenerators verifies that +// loading by stack does not leak another stack's generators. +func RunLoadGeneratorsByStackReturnsOnlyThatStacksGenerators(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("LoadGeneratorsByStack_ReturnsOnlyThatStacksGenerators", func(t *testing.T) { + td := newDS(t) + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + owner := createGeneratorStack(t, ds, "generator-load-owner") + other := createGeneratorStack(t, ds, "generator-load-other") + + _, err := ds.CreateGenerator(testPasswordGenerator("owner-a", owner, 12), "cmd-create") + require.NoError(t, err) + _, err = ds.CreateGenerator(testPasswordGenerator("owner-b", owner, 12), "cmd-create") + require.NoError(t, err) + _, err = ds.CreateGenerator(testPasswordGenerator("other-a", other, 12), "cmd-create") + require.NoError(t, err) + + generators, err := ds.LoadGeneratorsByStack(owner.Label) + require.NoError(t, err) + + labels := make([]string, 0, len(generators)) + for _, g := range generators { + labels = append(labels, g.GetLabel()) + assert.Equal(t, owner.Label, g.GetStack()) + } + assert.ElementsMatch(t, []string{"owner-a", "owner-b"}, labels) + }) +} + +// RunGeneratorKSUIDStableAcrossUpdate verifies that a generator's internal +// KSUID identity does not change when it is updated — only the label and +// stack are looked up, and the same id is carried forward onto the new +// version row. This is load-bearing: a later slice derives generator cadence +// per id, so a rename must not read as a delete plus a fresh generator. +func RunGeneratorKSUIDStableAcrossUpdate(t *testing.T, newDS func(t *testing.T) TestDatastore) { + t.Run("Generator_KSUIDStableAcrossUpdate", func(t *testing.T) { + td := newDS(t) + if td.GeneratorIDForTest == nil { + t.Skip("backend does not provide GeneratorIDForTest") + } + ds := td.Datastore + defer td.CleanUpFn() //nolint:errcheck + + stack := createGeneratorStack(t, ds, "generator-ksuid-stable") + _, err := ds.CreateGenerator(testPasswordGenerator("stable-id", stack, 16), "cmd-create") + require.NoError(t, err) + + idBeforeUpdate, err := td.GeneratorIDForTest("stable-id", stack.Label) + require.NoError(t, err) + require.NotEmpty(t, idBeforeUpdate) + + _, err = ds.UpdateGenerator(testPasswordGenerator("stable-id", stack, 40), "cmd-update") + require.NoError(t, err) + + idAfterUpdate, err := td.GeneratorIDForTest("stable-id", stack.Label) + require.NoError(t, err) + assert.Equal(t, idBeforeUpdate, idAfterUpdate, "the generator's KSUID identity must survive an update") + }) +} diff --git a/internal/datastore/generator_data.go b/internal/datastore/generator_data.go new file mode 100644 index 000000000..b62f94d9b --- /dev/null +++ b/internal/datastore/generator_data.go @@ -0,0 +1,38 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package datastore + +import ( + "encoding/json" + "fmt" + + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// GeneratorData builds the generator_data payload for a generator. +// +// Unlike TTLPolicyData, no field needs to be stripped or reassembled: a +// generator carries no one-of and pkgmodel.ParseGenerator already dispatches +// on the same discriminated Type field the generator marshals itself with, so +// the full generator value round-trips through it directly. Centralized here +// so the four backends agree on the same byte-level format rather than each +// deciding independently. +func GeneratorData(gen pkgmodel.Generator) ([]byte, error) { + data, err := json.Marshal(gen) + if err != nil { + return nil, fmt.Errorf("failed to marshal generator data: %w", err) + } + return data, nil +} + +// GeneratorFromData rebuilds a generator from a stored generator_data +// payload. +func GeneratorFromData(data []byte) (pkgmodel.Generator, error) { + gen, err := pkgmodel.ParseGenerator(data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal generator data: %w", err) + } + return gen, nil +} diff --git a/internal/datastore/migrations_mssql/00024_generators_table.sql b/internal/datastore/migrations_mssql/00024_generators_table.sql new file mode 100644 index 000000000..f1fe97e2d --- /dev/null +++ b/internal/datastore/migrations_mssql/00024_generators_table.sql @@ -0,0 +1,40 @@ +-- © 2026 Platform Engineering Labs Inc. +-- +-- SPDX-License-Identifier: FSL-1.1-ALv2 + +-- +goose Up +-- Generators produce values (e.g. random passwords) that secrets will later +-- reference. Unlike policies, a generator has no standalone form: it is +-- always owned by exactly one stack, so stack_id is NOT NULL and there is no +-- stack_generators junction table and no attach/detach. +-- +-- Identity is the KSUID in id, not the label: a label is unique only within +-- its stack, and generator cadence will later be derived per generator id, so +-- a rename must not read as a delete plus a fresh generator. +-- +goose StatementBegin +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'generators') +BEGIN + CREATE TABLE generators ( + id nvarchar(450) COLLATE Latin1_General_BIN2 NOT NULL, + version nvarchar(450) COLLATE Latin1_General_BIN2 NOT NULL, + valid_from datetime2 DEFAULT SYSUTCDATETIME(), + command_id nvarchar(450) COLLATE Latin1_General_BIN2, + operation nvarchar(450) NOT NULL, + label nvarchar(450) NOT NULL, + generator_type nvarchar(450) NOT NULL, + stack_id nvarchar(450) COLLATE Latin1_General_BIN2 NOT NULL, + generator_data nvarchar(max) NOT NULL DEFAULT '{}', + PRIMARY KEY (id, version) + ); + CREATE INDEX idx_generators_stack_id ON generators (stack_id); + CREATE INDEX idx_generators_generator_type ON generators (generator_type); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'generators') +BEGIN + DROP TABLE generators; +END; +-- +goose StatementEnd diff --git a/internal/datastore/migrations_postgres/00025_generators_table.sql b/internal/datastore/migrations_postgres/00025_generators_table.sql new file mode 100644 index 000000000..3803869a7 --- /dev/null +++ b/internal/datastore/migrations_postgres/00025_generators_table.sql @@ -0,0 +1,33 @@ +-- © 2026 Platform Engineering Labs Inc. +-- +-- SPDX-License-Identifier: FSL-1.1-ALv2 + +-- +goose Up +-- Generators produce values (e.g. random passwords) that secrets will later +-- reference. Unlike policies, a generator has no standalone form: it is +-- always owned by exactly one stack, so stack_id is NOT NULL and there is no +-- stack_generators junction table and no attach/detach. +-- +-- Identity is the KSUID in id, not the label: a label is unique only within +-- its stack, and generator cadence will later be derived per generator id, so +-- a rename must not read as a delete plus a fresh generator. +CREATE TABLE IF NOT EXISTS generators ( + id TEXT NOT NULL, + version TEXT NOT NULL, + valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + command_id TEXT, + operation TEXT NOT NULL, + label TEXT NOT NULL, + generator_type TEXT NOT NULL, + stack_id TEXT NOT NULL, + generator_data JSONB NOT NULL DEFAULT '{}', + PRIMARY KEY (id, version) +); + +CREATE INDEX IF NOT EXISTS idx_generators_stack_id ON generators(stack_id); +CREATE INDEX IF NOT EXISTS idx_generators_generator_type ON generators(generator_type); + +-- +goose Down +DROP INDEX IF EXISTS idx_generators_generator_type; +DROP INDEX IF EXISTS idx_generators_stack_id; +DROP TABLE IF EXISTS generators; diff --git a/internal/datastore/migrations_sqlite/00024_generators_table.sql b/internal/datastore/migrations_sqlite/00024_generators_table.sql new file mode 100644 index 000000000..6caa0f26c --- /dev/null +++ b/internal/datastore/migrations_sqlite/00024_generators_table.sql @@ -0,0 +1,33 @@ +-- © 2026 Platform Engineering Labs Inc. +-- +-- SPDX-License-Identifier: FSL-1.1-ALv2 + +-- +goose Up +-- Generators produce values (e.g. random passwords) that secrets will later +-- reference. Unlike policies, a generator has no standalone form: it is +-- always owned by exactly one stack, so stack_id is NOT NULL and there is no +-- stack_generators junction table and no attach/detach. +-- +-- Identity is the KSUID in id, not the label: a label is unique only within +-- its stack, and generator cadence will later be derived per generator id, so +-- a rename must not read as a delete plus a fresh generator. +CREATE TABLE IF NOT EXISTS generators ( + id TEXT NOT NULL, + version TEXT NOT NULL, + valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + command_id TEXT, + operation TEXT NOT NULL, + label TEXT NOT NULL, + generator_type TEXT NOT NULL, + stack_id TEXT NOT NULL, + generator_data TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (id, version) +); + +CREATE INDEX IF NOT EXISTS idx_generators_stack_id ON generators(stack_id); +CREATE INDEX IF NOT EXISTS idx_generators_generator_type ON generators(generator_type); + +-- +goose Down +DROP INDEX IF EXISTS idx_generators_generator_type; +DROP INDEX IF EXISTS idx_generators_stack_id; +DROP TABLE IF EXISTS generators; diff --git a/internal/datastore/mock_datastore_test.go b/internal/datastore/mock_datastore_test.go index f76c309ac..c2d604242 100644 --- a/internal/datastore/mock_datastore_test.go +++ b/internal/datastore/mock_datastore_test.go @@ -177,7 +177,20 @@ func (m *mockDatastore) GetResourcesAtLastReconcile(_ string) ([]ResourceSnapsho return nil, nil } func (m *mockDatastore) StackHasActiveCommands(_ string) (bool, error) { return false, nil } -func (m *mockDatastore) Close() {} +func (m *mockDatastore) CreateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + return "", nil +} +func (m *mockDatastore) UpdateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + return "", nil +} +func (m *mockDatastore) DeleteGenerator(_, _ string) (string, error) { return "", nil } +func (m *mockDatastore) GetGenerator(_, _ string) (pkgmodel.Generator, error) { + return nil, nil +} +func (m *mockDatastore) LoadGeneratorsByStack(_ string) ([]pkgmodel.Generator, error) { + return nil, nil +} +func (m *mockDatastore) Close() {} func (m *mockDatastore) BulkStoreResourceUpdates(_ string, _ []resource_update.ResourceUpdate) error { return nil } diff --git a/internal/datastore/mssql/mssql_dstest_test.go b/internal/datastore/mssql/mssql_dstest_test.go index 8d560fd66..89d77779c 100644 --- a/internal/datastore/mssql/mssql_dstest_test.go +++ b/internal/datastore/mssql/mssql_dstest_test.go @@ -9,6 +9,7 @@ package mssql_test import ( "context" "database/sql" + "errors" "fmt" "testing" "time" @@ -155,6 +156,23 @@ func TestDatastore(t *testing.T) { ) return err }, + GeneratorIDForTest: func(label, stackLabel string) (string, error) { + var id string + // generators.stack_id stores the stack's KSUID, not its label, so + // the stack is resolved by label first (its own current row), the + // same way the datastore's own Get/DeleteGenerator do. + err := conn.QueryRow( + `SELECT TOP (1) g.id FROM generators g + JOIN (SELECT TOP (1) id FROM stacks WHERE label = @p1 ORDER BY version COLLATE Latin1_General_BIN2 DESC) s ON g.stack_id = s.id + WHERE g.label = @p2 + ORDER BY g.version COLLATE Latin1_General_BIN2 DESC`, + stackLabel, label, + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err + }, CleanUpFn: func() error { ds.Close() m, err := sql.Open("sqlserver", dstestMSSQLBase+"&database=master") diff --git a/internal/datastore/mssql/mssql_generators.go b/internal/datastore/mssql/mssql_generators.go new file mode 100644 index 000000000..886c0df3f --- /dev/null +++ b/internal/datastore/mssql/mssql_generators.go @@ -0,0 +1,222 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package mssql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/demula/mksuid/v2" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// CreateGenerator persists a new generator. stack_id stores the stack's +// resolved KSUID — like policy_id on an inline policy, not the label — read +// off gen.GetStackID(). Unlike CreatePolicy the column is never NULL: a +// generator is always inline to exactly one stack. +func (d *DatastoreMSSQL) CreateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx, span := mssqlTracer.Start(context.Background(), "CreateGenerator") + defer span.End() + + id := mksuid.New().String() + version := mksuid.New().String() + + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + query := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8)` + _, err = d.conn.ExecContext(ctx, query, id, version, commandID, "create", + gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to create generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// UpdateGenerator persists a new version of an existing generator. The +// existing row is found by label and stack ID — a generator has no +// standalone form, so unlike UpdatePolicy there is no NULL-stack branch — +// and the new version row carries forward the same id. +func (d *DatastoreMSSQL) UpdateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx, span := mssqlTracer.Start(context.Background(), "UpdateGenerator") + defer span.End() + + query := ` + SELECT TOP (1) id FROM generators + WHERE label = @p1 AND stack_id = @p2 + ORDER BY version COLLATE Latin1_General_BIN2 DESC` + var id string + err := d.conn.QueryRowContext(ctx, query, gen.GetLabel(), gen.GetStackID()).Scan(&id) + if err != nil { + return "", fmt.Errorf("failed to find existing generator: %w", err) + } + + version := mksuid.New().String() + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8)` + _, err = d.conn.ExecContext(ctx, insertQuery, id, version, commandID, "update", + gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to update generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// DeleteGenerator soft-deletes the generator with the given label on the +// given stack. The stack is resolved from its label the same way +// GetGenerator does; a stack that doesn't exist has nothing to delete. A +// label with no live match is a no-op success that returns an empty version, +// mirroring DeletePolicy. +func (d *DatastoreMSSQL) DeleteGenerator(label, stackLabel string) (string, error) { + ctx, span := mssqlTracer.Start(context.Background(), "DeleteGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return "", fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return "", nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_type, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE Latin1_General_BIN2 DESC) as rn + FROM generators + WHERE stack_id = @p1 AND label = @p2 + ) + SELECT id, generator_type + FROM latest_generators + WHERE rn = 1 AND operation != 'delete'` + var id, generatorType string + err = d.conn.QueryRowContext(ctx, query, stack.ID, label).Scan(&id, &generatorType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("failed to get generator for deletion: %w", err) + } + + version := mksuid.New().String() + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8)` + _, err = d.conn.ExecContext(ctx, insertQuery, id, version, "", "delete", label, generatorType, stack.ID, "{}") + if err != nil { + return "", fmt.Errorf("failed to delete generator: %w", err) + } + + slog.Debug("Deleted generator", "label", label, "id", id, "stackLabel", stackLabel) + + return version, nil +} + +// GetGenerator retrieves the current (latest, non-deleted) generator with the +// given label on the given stack. The stack label is resolved to its +// current KSUID first, since generators.stack_id stores the stack's id, not +// its label — mirroring how a policy's inline lookups are scoped by stack +// ID. Returns nil, nil if no live stack or no live generator matches. +func (d *DatastoreMSSQL) GetGenerator(label, stackLabel string) (pkgmodel.Generator, error) { + ctx, span := mssqlTracer.Start(context.Background(), "GetGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE Latin1_General_BIN2 DESC) as rn + FROM generators + WHERE stack_id = @p1 AND label = @p2 + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete'` + var dataStr string + err = d.conn.QueryRowContext(ctx, query, stack.ID, label).Scan(&dataStr) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get generator: %w", err) + } + + return datastore.GeneratorFromData([]byte(dataStr)) +} + +// LoadGeneratorsByStack returns all non-deleted generators owned by a stack. +// The stack label is resolved to its current KSUID first, for the same +// reason GetGenerator does. A stack that doesn't exist owns no generators. +func (d *DatastoreMSSQL) LoadGeneratorsByStack(stackLabel string) ([]pkgmodel.Generator, error) { + ctx, span := mssqlTracer.Start(context.Background(), "LoadGeneratorsByStack") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE Latin1_General_BIN2 DESC) as rn + FROM generators + WHERE stack_id = @p1 + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete'` + rows, err := d.conn.QueryContext(ctx, query, stack.ID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var generators []pkgmodel.Generator + for rows.Next() { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + gen, err := datastore.GeneratorFromData([]byte(dataStr)) + if err != nil { + slog.Warn("Failed to deserialize generator, skipping", "error", err, "stackLabel", stackLabel) + continue + } + generators = append(generators, gen) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return generators, nil +} diff --git a/internal/datastore/postgres/postgres_generators.go b/internal/datastore/postgres/postgres_generators.go new file mode 100644 index 000000000..39a6439cb --- /dev/null +++ b/internal/datastore/postgres/postgres_generators.go @@ -0,0 +1,224 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package postgres + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/demula/mksuid/v2" + "github.com/jackc/pgx/v5" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// CreateGenerator persists a new generator. stack_id stores the stack's +// resolved KSUID — like policy_id on an inline policy, not the label — read +// off gen.GetStackID(). Unlike CreatePolicy the column is never NULL: a +// generator is always inline to exactly one stack. +func (d DatastorePostgres) CreateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx, span := tracer.Start(context.Background(), "CreateGenerator") + defer span.End() + + id := mksuid.New().String() + version := mksuid.New().String() + + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + query := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err = d.pool.Exec(ctx, query, id, version, commandID, "create", gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to create generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// UpdateGenerator persists a new version of an existing generator. The +// existing row is found by label and stack ID — a generator has no +// standalone form, so unlike UpdatePolicy there is no NULL-stack branch — +// and the new version row carries forward the same id. +func (d DatastorePostgres) UpdateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + ctx, span := tracer.Start(context.Background(), "UpdateGenerator") + defer span.End() + + query := ` + SELECT id FROM generators + WHERE label = $1 AND stack_id = $2 + ORDER BY version COLLATE "C" DESC + LIMIT 1 + ` + var id string + err := d.pool.QueryRow(ctx, query, gen.GetLabel(), gen.GetStackID()).Scan(&id) + if err != nil { + return "", fmt.Errorf("failed to find existing generator: %w", err) + } + + version := mksuid.New().String() + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err = d.pool.Exec(ctx, insertQuery, id, version, commandID, "update", gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to update generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// DeleteGenerator soft-deletes the generator with the given label on the +// given stack. The stack is resolved from its label the same way +// GetGenerator does; a stack that doesn't exist has nothing to delete. A +// label with no live match is a no-op success that returns an empty version, +// mirroring DeletePolicy. +func (d DatastorePostgres) DeleteGenerator(label, stackLabel string) (string, error) { + ctx, span := tracer.Start(context.Background(), "DeleteGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return "", fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return "", nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_type, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = $1 AND label = $2 + ) + SELECT id, generator_type + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + var id, generatorType string + err = d.pool.QueryRow(ctx, query, stack.ID, label).Scan(&id, &generatorType) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("failed to get generator for deletion: %w", err) + } + + version := mksuid.New().String() + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err = d.pool.Exec(ctx, insertQuery, id, version, "", "delete", label, generatorType, stack.ID, "{}") + if err != nil { + return "", fmt.Errorf("failed to delete generator: %w", err) + } + + slog.Debug("Deleted generator", "label", label, "id", id, "stackLabel", stackLabel) + + return version, nil +} + +// GetGenerator retrieves the current (latest, non-deleted) generator with the +// given label on the given stack. The stack label is resolved to its +// current KSUID first, since generators.stack_id stores the stack's id, not +// its label — mirroring how a policy's inline lookups are scoped by stack +// ID. Returns nil, nil if no live stack or no live generator matches. +func (d DatastorePostgres) GetGenerator(label, stackLabel string) (pkgmodel.Generator, error) { + ctx, span := tracer.Start(context.Background(), "GetGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = $1 AND label = $2 + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + var dataStr string + err = d.pool.QueryRow(ctx, query, stack.ID, label).Scan(&dataStr) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get generator: %w", err) + } + + return datastore.GeneratorFromData([]byte(dataStr)) +} + +// LoadGeneratorsByStack returns all non-deleted generators owned by a stack. +// The stack label is resolved to its current KSUID first, for the same +// reason GetGenerator does. A stack that doesn't exist owns no generators. +func (d DatastorePostgres) LoadGeneratorsByStack(stackLabel string) ([]pkgmodel.Generator, error) { + ctx, span := tracer.Start(context.Background(), "LoadGeneratorsByStack") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version COLLATE "C" DESC) as rn + FROM generators + WHERE stack_id = $1 + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + rows, err := d.pool.Query(ctx, query, stack.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + var generators []pkgmodel.Generator + for rows.Next() { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + gen, err := datastore.GeneratorFromData([]byte(dataStr)) + if err != nil { + slog.Warn("Failed to deserialize generator, skipping", "error", err, "stackLabel", stackLabel) + continue + } + generators = append(generators, gen) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return generators, nil +} diff --git a/internal/datastore/postgres/postgres_test.go b/internal/datastore/postgres/postgres_test.go index a1650180a..a4774dc48 100644 --- a/internal/datastore/postgres/postgres_test.go +++ b/internal/datastore/postgres/postgres_test.go @@ -9,6 +9,7 @@ package postgres_test import ( "context" "encoding/json" + "errors" "fmt" "testing" "time" @@ -449,6 +450,23 @@ func TestDatastore(t *testing.T) { ) return err }, + GeneratorIDForTest: func(label, stackLabel string) (string, error) { + var id string + // generators.stack_id stores the stack's KSUID, not its label, so + // the stack is resolved by label first (its own current row), the + // same way the datastore's own Get/DeleteGenerator do. + err := d.Pool().QueryRow(context.Background(), + `SELECT g.id FROM generators g + JOIN (SELECT id FROM stacks WHERE label = $1 ORDER BY version COLLATE "C" DESC LIMIT 1) s ON g.stack_id = s.id + WHERE g.label = $2 + ORDER BY g.version COLLATE "C" DESC LIMIT 1`, + stackLabel, label, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return id, err + }, } }) } diff --git a/internal/datastore/sqlite/sqlite.go b/internal/datastore/sqlite/sqlite.go index cc2f67789..4c18219a1 100644 --- a/internal/datastore/sqlite/sqlite.go +++ b/internal/datastore/sqlite/sqlite.go @@ -2792,6 +2792,209 @@ func (d DatastoreSQLite) DeletePoliciesForStack(stackID string, commandID string return nil } +// CreateGenerator persists a new generator. stack_id stores the stack's +// resolved KSUID — like policy_id on an inline policy, not the label — read +// off gen.GetStackID(). Unlike CreatePolicy the column is never NULL: a +// generator is always inline to exactly one stack. +func (d DatastoreSQLite) CreateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + _, span := sqliteTracer.Start(context.Background(), "CreateGenerator") + defer span.End() + + id := mksuid.New().String() + version := mksuid.New().String() + + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + query := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + _, err = d.conn.Exec(query, id, version, commandID, "create", gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to create generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// UpdateGenerator persists a new version of an existing generator. The +// existing row is found by label and stack ID — a generator has no +// standalone form, so unlike UpdatePolicy there is no NULL-stack branch — +// and the new version row carries forward the same id. +func (d DatastoreSQLite) UpdateGenerator(gen pkgmodel.Generator, commandID string) (string, error) { + _, span := sqliteTracer.Start(context.Background(), "UpdateGenerator") + defer span.End() + + var id string + err := d.conn.QueryRow( + `SELECT id FROM generators WHERE label = ? AND stack_id = ? ORDER BY version DESC LIMIT 1`, + gen.GetLabel(), gen.GetStackID(), + ).Scan(&id) + if err != nil { + return "", fmt.Errorf("failed to find existing generator: %w", err) + } + + version := mksuid.New().String() + data, err := datastore.GeneratorData(gen) + if err != nil { + return "", err + } + + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + _, err = d.conn.Exec(insertQuery, id, version, commandID, "update", gen.GetLabel(), gen.GetType(), gen.GetStackID(), string(data)) + if err != nil { + slog.Error("Failed to update generator", "error", err, "label", gen.GetLabel()) + return "", err + } + + return version, nil +} + +// DeleteGenerator soft-deletes the generator with the given label on the +// given stack. The stack is resolved from its label the same way +// GetGenerator does; a stack that doesn't exist has nothing to delete. A +// label with no live match is a no-op success that returns an empty version, +// mirroring DeletePolicy. +func (d DatastoreSQLite) DeleteGenerator(label, stackLabel string) (string, error) { + _, span := sqliteTracer.Start(context.Background(), "DeleteGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return "", fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return "", nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_type, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version DESC) as rn + FROM generators + WHERE stack_id = ? AND label = ? + ) + SELECT id, generator_type + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + var id, generatorType string + err = d.conn.QueryRow(query, stack.ID, label).Scan(&id, &generatorType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("failed to get generator for deletion: %w", err) + } + + version := mksuid.New().String() + insertQuery := `INSERT INTO generators (id, version, command_id, operation, label, generator_type, stack_id, generator_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + _, err = d.conn.Exec(insertQuery, id, version, "", "delete", label, generatorType, stack.ID, "{}") + if err != nil { + return "", fmt.Errorf("failed to delete generator: %w", err) + } + + slog.Debug("Deleted generator", "label", label, "id", id, "stackLabel", stackLabel) + + return version, nil +} + +// GetGenerator retrieves the current (latest, non-deleted) generator with the +// given label on the given stack. The stack label is resolved to its +// current KSUID first, since generators.stack_id stores the stack's id, not +// its label — mirroring how a policy's inline lookups are scoped by stack +// ID. Returns nil, nil if no live stack or no live generator matches. +func (d DatastoreSQLite) GetGenerator(label, stackLabel string) (pkgmodel.Generator, error) { + _, span := sqliteTracer.Start(context.Background(), "GetGenerator") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version DESC) as rn + FROM generators + WHERE stack_id = ? AND label = ? + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + var dataStr string + err = d.conn.QueryRow(query, stack.ID, label).Scan(&dataStr) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get generator: %w", err) + } + + return datastore.GeneratorFromData([]byte(dataStr)) +} + +// LoadGeneratorsByStack returns all non-deleted generators owned by a stack. +// The stack label is resolved to its current KSUID first, for the same +// reason GetGenerator does. A stack that doesn't exist owns no generators. +func (d DatastoreSQLite) LoadGeneratorsByStack(stackLabel string) ([]pkgmodel.Generator, error) { + _, span := sqliteTracer.Start(context.Background(), "LoadGeneratorsByStack") + defer span.End() + + stack, err := d.GetStackByLabel(stackLabel) + if err != nil { + return nil, fmt.Errorf("failed to resolve stack %q: %w", stackLabel, err) + } + if stack == nil { + return nil, nil + } + + query := ` + WITH latest_generators AS ( + SELECT id, generator_data, operation, + ROW_NUMBER() OVER (PARTITION BY id ORDER BY version DESC) as rn + FROM generators + WHERE stack_id = ? + ) + SELECT generator_data + FROM latest_generators + WHERE rn = 1 AND operation != 'delete' + ` + rows, err := d.conn.Query(query, stack.ID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var generators []pkgmodel.Generator + for rows.Next() { + var dataStr string + if err := rows.Scan(&dataStr); err != nil { + return nil, err + } + gen, err := datastore.GeneratorFromData([]byte(dataStr)) + if err != nil { + slog.Warn("Failed to deserialize generator, skipping", "error", err, "stackLabel", stackLabel) + continue + } + generators = append(generators, gen) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return generators, nil +} + // deserializePolicy creates a Policy from stored data func deserializePolicy(label, policyType, policyDataStr, stackID string) (pkgmodel.Policy, error) { switch policyType { diff --git a/internal/datastore/sqlite/sqlite_test.go b/internal/datastore/sqlite/sqlite_test.go index 36543cabd..31b985633 100644 --- a/internal/datastore/sqlite/sqlite_test.go +++ b/internal/datastore/sqlite/sqlite_test.go @@ -10,6 +10,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "testing" @@ -142,6 +143,24 @@ func TestDatastore(t *testing.T) { ) return err }, + GeneratorIDForTest: func(label, stackLabel string) (string, error) { + conn := d.Conn() + var id string + // generators.stack_id stores the stack's KSUID, not its label, so + // the stack is resolved by label first (its own current row), the + // same way the datastore's own Get/DeleteGenerator do. + err := conn.QueryRow( + `SELECT g.id FROM generators g + JOIN (SELECT id FROM stacks WHERE label = ? ORDER BY version DESC LIMIT 1) s ON g.stack_id = s.id + WHERE g.label = ? + ORDER BY g.version DESC LIMIT 1`, + stackLabel, label, + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err + }, } }) } diff --git a/internal/metastructure/extract_resources_test.go b/internal/metastructure/extract_resources_test.go index 1f472bded..93f42c434 100644 --- a/internal/metastructure/extract_resources_test.go +++ b/internal/metastructure/extract_resources_test.go @@ -275,6 +275,21 @@ func (m *mockExtractDatastore) GetResourcesAtLastReconcile(_ string) ([]datastor func (m *mockExtractDatastore) StackHasActiveCommands(_ string) (bool, error) { panic("not implemented") } +func (m *mockExtractDatastore) CreateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + panic("not implemented") +} +func (m *mockExtractDatastore) UpdateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + panic("not implemented") +} +func (m *mockExtractDatastore) DeleteGenerator(_, _ string) (string, error) { + panic("not implemented") +} +func (m *mockExtractDatastore) GetGenerator(_, _ string) (pkgmodel.Generator, error) { + panic("not implemented") +} +func (m *mockExtractDatastore) LoadGeneratorsByStack(_ string) ([]pkgmodel.Generator, error) { + panic("not implemented") +} func (m *mockExtractDatastore) Close() {} func (m *mockExtractDatastore) BulkStoreResourceUpdates(_ string, _ []resource_update.ResourceUpdate) error { panic("not implemented") diff --git a/internal/metastructure/resource_summaries_test.go b/internal/metastructure/resource_summaries_test.go index 1c0584f62..6561f433b 100644 --- a/internal/metastructure/resource_summaries_test.go +++ b/internal/metastructure/resource_summaries_test.go @@ -262,6 +262,21 @@ func (m *mockSummaryDatastore) GetResourcesAtLastReconcile(_ string) ([]datastor func (m *mockSummaryDatastore) StackHasActiveCommands(_ string) (bool, error) { panic("not implemented") } +func (m *mockSummaryDatastore) CreateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + panic("not implemented") +} +func (m *mockSummaryDatastore) UpdateGenerator(_ pkgmodel.Generator, _ string) (string, error) { + panic("not implemented") +} +func (m *mockSummaryDatastore) DeleteGenerator(_, _ string) (string, error) { + panic("not implemented") +} +func (m *mockSummaryDatastore) GetGenerator(_, _ string) (pkgmodel.Generator, error) { + panic("not implemented") +} +func (m *mockSummaryDatastore) LoadGeneratorsByStack(_ string) ([]pkgmodel.Generator, error) { + panic("not implemented") +} func (m *mockSummaryDatastore) Close() {} func (m *mockSummaryDatastore) BulkStoreResourceUpdates(_ string, _ []resource_update.ResourceUpdate) error { panic("not implemented") diff --git a/internal/metastructure/resource_update/resource_update_generator_reconcile_test.go b/internal/metastructure/resource_update/resource_update_generator_reconcile_test.go index 4f006824a..95cfb6c4a 100644 --- a/internal/metastructure/resource_update/resource_update_generator_reconcile_test.go +++ b/internal/metastructure/resource_update/resource_update_generator_reconcile_test.go @@ -1116,6 +1116,74 @@ func TestGenerateResourceUpdatesForReconcile_ImplicitDelete(t *testing.T) { assert.Equal(t, "my-s3-bucket-delete", updates[0].DesiredState.Label) } +// TestGenerateResourceUpdatesForReconcile_GeneratorOnlyStackKeepsExistingResources +// verifies that reconciling a forma which declares only a generator on a +// stack that already holds a managed resource does not delete that +// resource. A generator carries no resources of its own, so the split +// Forma for its stack must never stand in for an empty desired resource +// set. +func TestGenerateResourceUpdatesForReconcile_GeneratorOnlyStackKeepsExistingResources(t *testing.T) { + ds, _ := GetDeps(t) + + resource := pkgmodel.Resource{ + Label: "my-s3-bucket", + Type: "AWS::S3::Bucket", + Stack: "infrastructure", + Target: "test-target", + Schema: pkgmodel.Schema{ + Identifier: "BucketName", + Hints: map[string]pkgmodel.FieldHint{ + "BucketName": { + CreateOnly: true, + }, + }, + Fields: []string{"BucketName"}, + }, + Properties: json.RawMessage(`{"BucketName": "my-unique-bucket-name"}`), + Managed: true, + } + + // First persist the stack with its resource. + existingStack := &pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "infrastructure"}}, + Resources: []pkgmodel.Resource{resource}, + } + _, err := ds.StoreStack(existingStack, "test-command-1") + assert.NoError(t, err) + + generator := json.RawMessage(`{ + "Type": "password", + "Label": "db-password", + "Stack": "infrastructure", + "Length": 24, + "Uppercase": true, + "Lowercase": true, + "Digits": true, + "Symbols": false, + "RequireEachIncludedType": true + }`) + + // Apply a forma that declares only the generator on the same stack - + // the resource is not repeated in the desired state. + mode := pkgmodel.FormaApplyModeReconcile + forma := &pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "infrastructure"}}, + Generators: []json.RawMessage{generator}, + } + + targetMap := map[string]*pkgmodel.Target{ + "test-target": { + Label: "test-target", + Config: json.RawMessage(`{"Region": "us-west-2"}`), + Namespace: "aws", + }, + } + + updates, err := generateResourceUpdatesForApply(forma, mode, FormaCommandSourceUser, targetMap, targetMap, ds, nil, false) + assert.NoError(t, err) + assert.Empty(t, updates) +} + func TestGenerateResourceUpdatesForReconcile_Update(t *testing.T) { ds, _ := GetDeps(t) diff --git a/internal/schema/json/json.go b/internal/schema/json/json.go index 4443fac58..1810d7945 100644 --- a/internal/schema/json/json.go +++ b/internal/schema/json/json.go @@ -74,17 +74,19 @@ func (j JSON) SerializeForma(forma *model.Forma, options *schema.SerializeOption data = simplifiedResources } else { - // Full structure with Stacks, Targets, Resources, and Policies + // Full structure with Stacks, Targets, Resources, Policies, and Generators data = struct { - Stacks []model.Stack `json:"Stacks,omitempty"` - Targets []model.Target `json:"Targets,omitempty"` - Policies []json.RawMessage `json:"Policies,omitempty"` - Resources []model.Resource `json:"Resources,omitempty"` + Stacks []model.Stack `json:"Stacks,omitempty"` + Targets []model.Target `json:"Targets,omitempty"` + Policies []json.RawMessage `json:"Policies,omitempty"` + Generators []json.RawMessage `json:"Generators,omitempty"` + Resources []model.Resource `json:"Resources,omitempty"` }{ - Stacks: forma.Stacks, - Targets: forma.Targets, - Policies: forma.Policies, - Resources: forma.Resources, + Stacks: forma.Stacks, + Targets: forma.Targets, + Policies: forma.Policies, + Generators: forma.Generators, + Resources: forma.Resources, } } diff --git a/internal/schema/pkl/generator/pklGenerator.pkl b/internal/schema/pkl/generator/pklGenerator.pkl index 3fd853c0a..eb5f60455 100644 --- a/internal/schema/pkl/generator/pklGenerator.pkl +++ b/internal/schema/pkl/generator/pklGenerator.pkl @@ -255,6 +255,64 @@ function parsePolicies(policiesData: List>): String = )) policiesString.join("\n") +// Escapes a value about to be interpolated into an emitted PKL string +// literal. Mirrors gen.pkl's escapeString (not importable here: that +// function is local to gen.pkl). +local function escapeGeneratorString(str: String): String = + str + .replaceAll("\\", "\\\\") + .replaceAll("\"", "\\\"") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t") + +/// Parses standalone generators and generates PKL output. Mirrors +/// parsePolicies: generators are always declared standalone (never nested +/// inside a stack block), but unlike policies they carry their own `stack` +/// reference, resolved here the same way parseResources resolves +/// target/stack references on resources. +function parseGenerators(generatorsData: List>, stackLabelMap: Map): String = + let (generatorsString = generatorsData.map((generatorData) -> + let (generatorType = generatorData["Type"] as String) + let (label = generatorData["Label"] as String) + let (camelCaseLabel = toCamelCase(label)) + let (stackLabel = generatorData.getOrNull("Stack") as String?) + let (stackLine = if (stackLabel != null) + "\n stack = \(stackLabelMap.getOrNull(stackLabel) ?? toCamelCase(stackLabel)).res" + else + "" + ) + if (generatorType == "password") + let (length = generatorData["Length"] as Number) + let (uppercase = generatorData["Uppercase"] as Boolean) + let (lowercase = generatorData["Lowercase"] as Boolean) + let (digits = generatorData["Digits"] as Boolean) + let (symbols = generatorData["Symbols"] as Boolean) + let (excludeCharacters = generatorData.getOrNull("ExcludeCharacters") as String? ?? "") + let (requireEachIncludedType = generatorData.getOrNull("RequireEachIncludedType") as Boolean? ?? true) + """ + + local \(camelCaseLabel) = new formae.PasswordGenerator { + label = "\(label)"\(stackLine) + length = \(length) + uppercase = \(uppercase) + lowercase = \(lowercase) + digits = \(digits) + symbols = \(symbols) + excludeCharacters = "\(escapeGeneratorString(excludeCharacters))" + requireEachIncludedType = \(requireEachIncludedType) + } + \(camelCaseLabel) + """ + else + // Unknown generator type - skip with comment + """ + + // Skipped unknown generator type: \(generatorType) (label: \(label)) + """ + )) + generatorsString.join("\n") + function parseTargets(targetsData: List>): String = let (targetsString = targetsData.map((targetData) -> let (label = targetData["label"]) @@ -393,6 +451,25 @@ function generateFormaFile(parsed: json.Value): String = List() ) + // Extract standalone generators from the parsed JSON + let (generatorsFromJson = if (parsed.getPropertyOrNull("Generators") != null) + parsed.Generators.toList().map((generator) -> Map( + "Type", generator.Type, + "Label", generator.Label, + "Stack", generator.getPropertyOrNull("Stack"), + "Length", generator.getPropertyOrNull("Length"), + "Uppercase", generator.getPropertyOrNull("Uppercase"), + "Lowercase", generator.getPropertyOrNull("Lowercase"), + "Digits", generator.getPropertyOrNull("Digits"), + "Symbols", generator.getPropertyOrNull("Symbols"), + "ExcludeCharacters", generator.getPropertyOrNull("ExcludeCharacters"), + "RequireEachIncludedType", generator.getPropertyOrNull("RequireEachIncludedType") + ) + ) + else + List() + ) + // Create mapping from original labels to camelCase labels for targets let (targetLabelMap = targetsFromJson.fold(Map(), (acc: Map, targetData) -> let (originalLabel = targetData["label"]) @@ -431,6 +508,15 @@ function generateFormaFile(parsed: json.Value): String = "" ) + // Generate generators section (only if there are generators). Placed + // after stacks (it emits `stack = .res` references) and + // before resources. + let (generatorsSection = if (generatorsFromJson.length > 0) + parseGenerators(generatorsFromJson, stackLabelMap) + "\n" + else + "" + ) + """ \(headerImports()) @@ -439,7 +525,7 @@ function generateFormaFile(parsed: json.Value): String = forma {\(policiesSection) \(parseStacks(stacksFromJson, policyLabelMap)) - + \(generatorsSection) \(parseTargets(targetsFromJson)) \(parseResources(resources, targetLabelMap, stackLabelMap)) diff --git a/internal/schema/pkl/pkl_generate_test.go b/internal/schema/pkl/pkl_generate_test.go index 1ba71f5b8..9c94d6310 100644 --- a/internal/schema/pkl/pkl_generate_test.go +++ b/internal/schema/pkl/pkl_generate_test.go @@ -7,6 +7,7 @@ package pkl import ( + "encoding/json" "os" "path/filepath" "strings" @@ -115,3 +116,65 @@ func TestGenerateSourceCode_HashedSecretCount_ZeroForNonHashed(t *testing.T) { assert.Equal(t, 0, res.HashedSecretCount, "HashedSecretCount must be 0 when no hashed opaque fields are present") } + +// TestGenerateSourceCode_Generator_RoundTrips verifies that a forma carrying +// a standalone generator (as it would when re-serialized from stored state, +// the same way standalone policies already are) round-trips through +// GenerateSourceCode into a .pkl file that declares an equivalent +// formae.PasswordGenerator referencing its stack, and that the emitted file +// itself evaluates. +func TestGenerateSourceCode_Generator_RoundTrips(t *testing.T) { + deps, pluginDir := fakeawsDeps(t) + + forma := &model.Forma{ + Stacks: []model.Stack{{Label: "default"}}, + Targets: []model.Target{fakeawsTarget()}, + Resources: []model.Resource{{ + Label: "plain-secret", + Type: "FakeAWS::SecretsManager::Secret", + Stack: "default", + Target: "aws", + Properties: []byte(`{"SecretString":{"$value":"plaintext","$visibility":"Opaque","$strategy":"Update"}}`), + }}, + Generators: []json.RawMessage{ + []byte(`{ + "Type": "password", + "Label": "db-password", + "Stack": "default", + "Length": 24, + "Uppercase": true, + "Lowercase": true, + "Digits": true, + "Symbols": false, + "ExcludeCharacters": "oO0", + "RequireEachIncludedType": true + }`), + }, + } + + dir := t.TempDir() + targetPath := filepath.Join(dir, "out.pkl") + + options := &schema.SerializeOptions{ + Schema: "pkl", + SchemaLocation: schema.SchemaLocationLocal, + LocalPluginDir: pluginDir, + Dependencies: deps, + } + + _, err := PKL{}.GenerateSourceCode(forma, targetPath, nil, options) + require.NoError(t, err) + + written, err := os.ReadFile(targetPath) + require.NoError(t, err) + generated := string(written) + + assert.Contains(t, generated, "new formae.PasswordGenerator {") + assert.Contains(t, generated, `label = "db-password"`) + assert.Contains(t, generated, "stack = default.res") + assert.Contains(t, generated, "length = 24") + assert.Contains(t, generated, `excludeCharacters = "oO0"`) + + _, err = PKL{}.Evaluate(targetPath, model.CommandApply, model.FormaApplyModeReconcile, nil) + require.NoError(t, err, "emitted PKL must itself evaluate") +} diff --git a/internal/schema/pkl/pkl_test.go b/internal/schema/pkl/pkl_test.go index 2efc3a092..725e95b69 100644 --- a/internal/schema/pkl/pkl_test.go +++ b/internal/schema/pkl/pkl_test.go @@ -269,6 +269,56 @@ func TestPkl_SecretShapeMisuse_BareMapSecretValueFailsEval(t *testing.T) { assert.ErrorContains(t, err, "SecretMapAccessor") } +// TestPkl_Generator_Evaluate verifies that a forma declaring a +// PasswordGenerator evaluates and renders a Generators listing carrying the +// fields PasswordGenerator.render() produces. +func TestPkl_Generator_Evaluate(t *testing.T) { + p := PKL{} + forma, err := p.Evaluate("./testdata/forma/generator_test.pkl", model.CommandApply, model.FormaApplyModeReconcile, nil) + require.NoError(t, err) + + jsonString := forma.ToJSON() + + assert.Equal(t, "password", gjson.Get(jsonString, "Generators.0.Type").String()) + assert.Equal(t, "db-password", gjson.Get(jsonString, "Generators.0.Label").String()) + assert.Equal(t, "generator-test-stack", gjson.Get(jsonString, "Generators.0.Stack").String()) + assert.Equal(t, int64(24), gjson.Get(jsonString, "Generators.0.Length").Int()) + assert.True(t, gjson.Get(jsonString, "Generators.0.Uppercase").Bool()) + assert.True(t, gjson.Get(jsonString, "Generators.0.Lowercase").Bool()) + assert.True(t, gjson.Get(jsonString, "Generators.0.Digits").Bool()) + assert.False(t, gjson.Get(jsonString, "Generators.0.Symbols").Bool()) + assert.Equal(t, "oO0", gjson.Get(jsonString, "Generators.0.ExcludeCharacters").String()) + assert.True(t, gjson.Get(jsonString, "Generators.0.RequireEachIncludedType").Bool()) +} + +// TestPkl_Generator_NoStackFailsEval verifies that a Generator with no stack +// set fails at PKL eval — stack is required, not defaulted. +func TestPkl_Generator_NoStackFailsEval(t *testing.T) { + p := PKL{} + _, err := p.Evaluate("./testdata/forma/generator_no_stack_test.pkl", model.CommandApply, model.FormaApplyModeReconcile, nil) + require.Error(t, err) +} + +// TestPkl_Generator_AllClassFlagsFalseFailsEval verifies that a +// PasswordGenerator with every character-class flag false fails at PKL eval, +// not at runtime — the spec has no alphabet to draw from. +func TestPkl_Generator_AllClassFlagsFalseFailsEval(t *testing.T) { + p := PKL{} + _, err := p.Evaluate("./testdata/forma/generator_all_flags_false_test.pkl", model.CommandApply, model.FormaApplyModeReconcile, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "at least one of uppercase, lowercase, digits, symbols must be true") +} + +// TestPkl_Generator_ExcludeCharactersEmptiesClassFailsEval verifies that +// excludeCharacters removing every character of an enabled class fails at +// PKL eval, not at runtime. +func TestPkl_Generator_ExcludeCharactersEmptiesClassFailsEval(t *testing.T) { + p := PKL{} + _, err := p.Evaluate("./testdata/forma/generator_exclude_empties_class_test.pkl", model.CommandApply, model.FormaApplyModeReconcile, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "excludeCharacters removes every digit") +} + func TestTranslateResourcePluginConfig(t *testing.T) { p := PKL{} config, err := p.FormaeConfig("./testdata/config/test_resource_plugin_config.pkl") diff --git a/internal/schema/pkl/schema/forma.pkl b/internal/schema/pkl/schema/forma.pkl index f5492aff1..64bfbd98b 100644 --- a/internal/schema/pkl/schema/forma.pkl +++ b/internal/schema/pkl/schema/forma.pkl @@ -14,7 +14,7 @@ properties: Any? /// fill: entry point used by the self-injecting output below; constructs a /// typed instance of the user's properties class from external `prop:` values. function fill(clazz: Class): Typed = formae.fillProps(clazz) -hidden forma: Listing +hidden forma: Listing /// defaultStack: returns the first defined Stack hidden defaultStack: formae.StackResolvable = @@ -56,6 +56,7 @@ output { Stacks = forma.toList().filterIsInstance(formae.Stack).toListing() Targets = forma.toList().filterIsInstance(formae.Target).toListing() Policies = forma.toList().filterIsInstance(formae.Policy).map((p) -> p.render()).toListing() + Generators = forma.toList().filterIsInstance(formae.Generator).map((g) -> g.render()).toListing() Resolvables = forma.toList().filterIsInstance(formae.Resolvable).toListing() Resources = new Listing { for (res in forma.toList().filterIsInstance(formae.Resource).toListing()) { diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index 27e07eec0..93c5fb3d9 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -167,6 +167,86 @@ open class AutoReconcilePolicy extends Policy { } } +/// A source of generated values. Belongs to a stack; referenced by the +/// resources whose properties take the generated value. +abstract class Generator { + label: String + stack: StackResolvable + + local self = this + + // Every StackResolvable property defaults (Resolvable's fields are all + // optional), so an unset `stack` silently default-constructs to an + // all-null instance instead of failing to evaluate. This is the actual + // enforcement that a generator names its stack; subclasses must fold it + // into their render() the same way PasswordGenerator folds in its own + // eval-time validation. + function hasStack(): Boolean = + self.stack.label != null || throw("Generator \"\(self.label)\": stack is required") + + abstract function type(): String + abstract function render(): Dynamic +} + +open class PasswordGenerator extends Generator { + length: Int(this >= 16) = 32 + uppercase: Boolean = true + lowercase: Boolean = true + digits: Boolean = true + symbols: Boolean = false + /// Characters the destination cannot accept, removed from the alphabet. + excludeCharacters: String = "" + /// Guarantee at least one character from every enabled class. + requireEachIncludedType: Boolean = true + + local self = this + + // Canonical alphabets, before excludeCharacters is applied. Eval-time + // validation below checks emptiness against these; the scheduler that + // actually draws characters must draw from the same sets, or a spec this + // accepts could still starve during unattended rejection sampling. + local uppercaseChars: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + local lowercaseChars: String = "abcdefghijklmnopqrstuvwxyz" + local digitChars: String = "0123456789" + local symbolChars: String = "!@#$%^&*()-_=+[]{}<>?/|~" + + local function remaining(alphabet: String): String = + alphabet.chars.filter((c) -> !self.excludeCharacters.contains(c)).join("") + + // Eval-time validation: a spec the scheduler could never satisfy (every + // class flag false, or excludeCharacters emptying an enabled class) fails + // here, not during unattended rejection sampling later. Referenced from + // render()'s return type so it is always forced, even though PKL would + // otherwise never evaluate an unread local. + local function validated(): Boolean = + if (!(self.uppercase || self.lowercase || self.digits || self.symbols)) + throw("PasswordGenerator \"\(self.label)\": at least one of uppercase, lowercase, digits, symbols must be true") + else if (self.uppercase && remaining(uppercaseChars).isEmpty) + throw("PasswordGenerator \"\(self.label)\": excludeCharacters removes every uppercase character") + else if (self.lowercase && remaining(lowercaseChars).isEmpty) + throw("PasswordGenerator \"\(self.label)\": excludeCharacters removes every lowercase character") + else if (self.digits && remaining(digitChars).isEmpty) + throw("PasswordGenerator \"\(self.label)\": excludeCharacters removes every digit") + else if (self.symbols && remaining(symbolChars).isEmpty) + throw("PasswordGenerator \"\(self.label)\": excludeCharacters removes every symbol character") + else + true + + function type(): String = "password" + function render(): Dynamic(validated() && self.hasStack()) = new { + Type = "password" + Label = self.label + Stack = self.stack + Length = length + Uppercase = uppercase + Lowercase = lowercase + Digits = digits + Symbols = symbols + ExcludeCharacters = excludeCharacters + RequireEachIncludedType = requireEachIncludedType + } +} + /// DEPRECATED: Use aws#Tag instead. /// This class will be removed in a future version. /// AWS resources should use `aws.Tag` from `@aws/aws.pkl`. @@ -660,6 +740,7 @@ class FormaRender { Stacks: Listing Targets: Listing Policies: Listing? + Generators: Listing? Resources: Listing(validate(this)) hidden Resolvables: Listing diff --git a/internal/schema/pkl/testdata/forma/generator_all_flags_false_test.pkl b/internal/schema/pkl/testdata/forma/generator_all_flags_false_test.pkl new file mode 100644 index 000000000..b92c306a8 --- /dev/null +++ b/internal/schema/pkl/testdata/forma/generator_all_flags_false_test.pkl @@ -0,0 +1,29 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +// Shape-misuse test: a PasswordGenerator with every character-class flag set +// to false has no alphabet to draw from at all. PKL eval fails because the +// scheduler that later reads this spec runs unattended and would otherwise +// spin forever in rejection sampling. + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +forma { + local testStack = new formae.Stack { + label = "generator-test-stack" + } + testStack + + new formae.PasswordGenerator { + label = "unsatisfiable" + stack = testStack.res + uppercase = false + lowercase = false + digits = false + symbols = false + } +} diff --git a/internal/schema/pkl/testdata/forma/generator_exclude_empties_class_test.pkl b/internal/schema/pkl/testdata/forma/generator_exclude_empties_class_test.pkl new file mode 100644 index 000000000..153f82f9c --- /dev/null +++ b/internal/schema/pkl/testdata/forma/generator_exclude_empties_class_test.pkl @@ -0,0 +1,30 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +// Shape-misuse test: excludeCharacters removes every digit while digits is +// the only enabled class, leaving nothing for the enabled class to draw +// from. PKL eval fails at the same eval-time check as the all-flags-false +// case, for the same unattended-scheduler reason. + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +forma { + local testStack = new formae.Stack { + label = "generator-test-stack" + } + testStack + + new formae.PasswordGenerator { + label = "starved-digits" + stack = testStack.res + uppercase = false + lowercase = false + digits = true + symbols = false + excludeCharacters = "0123456789" + } +} diff --git a/internal/schema/pkl/testdata/forma/generator_no_stack_test.pkl b/internal/schema/pkl/testdata/forma/generator_no_stack_test.pkl new file mode 100644 index 000000000..27da00003 --- /dev/null +++ b/internal/schema/pkl/testdata/forma/generator_no_stack_test.pkl @@ -0,0 +1,17 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +// Shape-misuse test: a Generator with no stack set has nowhere to persist. +// stack is required (non-null), so PKL eval fails when it is left unset. + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +forma { + new formae.PasswordGenerator { + label = "stackless" + } +} diff --git a/internal/schema/pkl/testdata/forma/generator_test.pkl b/internal/schema/pkl/testdata/forma/generator_test.pkl new file mode 100644 index 000000000..5d0603baa --- /dev/null +++ b/internal/schema/pkl/testdata/forma/generator_test.pkl @@ -0,0 +1,22 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +forma { + local testStack = new formae.Stack { + label = "generator-test-stack" + } + testStack + + new formae.PasswordGenerator { + label = "db-password" + stack = testStack.res + length = 24 + excludeCharacters = "oO0" + } +} diff --git a/pkg/model/forma.go b/pkg/model/forma.go index f621f41a3..0aa0f2fdc 100644 --- a/pkg/model/forma.go +++ b/pkg/model/forma.go @@ -14,7 +14,8 @@ type Forma struct { Stacks []Stack `json:"Stacks,omitempty"` Targets []Target `json:"Targets,omitempty"` Resources []Resource `json:"Resources,omitempty"` - Policies []json.RawMessage `json:"Policies,omitempty"` // Standalone policies + Policies []json.RawMessage `json:"Policies,omitempty"` // Standalone policies + Generators []json.RawMessage `json:"Generators,omitempty"` // Generators, keyed to a stack by their own Stack field } type Prop struct { diff --git a/pkg/model/generator.go b/pkg/model/generator.go new file mode 100644 index 000000000..d19450016 --- /dev/null +++ b/pkg/model/generator.go @@ -0,0 +1,96 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +import ( + "encoding/json" + "fmt" +) + +// Generator is a source of a generated value that secrets will later +// reference. It is neither a resource nor a policy: it has no target, no +// NativeID, no provider and no Read, so it is never discovered and never +// drifts. It belongs to a stack, exactly as a resource does. +type Generator interface { + GetLabel() string + GetType() string + GetStack() string + SetStack(stack string) + GetStackID() string + SetStackID(id string) +} + +// PasswordGenerator produces a random password value. Fields mirror the +// PKL PasswordGenerator.render() output. Type is not a field: it is a +// constant discriminator, injected by MarshalJSON, so there is exactly one +// place that says what type this generator is. +type PasswordGenerator struct { + Label string `json:"Label"` + Stack string `json:"Stack,omitempty"` + StackID string `json:"-"` // Set during processing, not from PKL + Length int `json:"Length"` + Uppercase bool `json:"Uppercase"` + Lowercase bool `json:"Lowercase"` + Digits bool `json:"Digits"` + Symbols bool `json:"Symbols"` + ExcludeCharacters string `json:"ExcludeCharacters,omitempty"` + RequireEachIncludedType bool `json:"RequireEachIncludedType"` +} + +func (g *PasswordGenerator) GetLabel() string { return g.Label } +func (g *PasswordGenerator) GetType() string { return "password" } +func (g *PasswordGenerator) GetStack() string { return g.Stack } +func (g *PasswordGenerator) SetStack(stack string) { g.Stack = stack } +func (g *PasswordGenerator) GetStackID() string { return g.StackID } +func (g *PasswordGenerator) SetStackID(id string) { g.StackID = id } + +// MarshalJSON injects the "Type": "password" discriminator that +// ParseGenerator dispatches on, so callers never set Type by hand and there +// is no way for the marshalled Type to disagree with GetType(). +func (g *PasswordGenerator) MarshalJSON() ([]byte, error) { + type alias PasswordGenerator + return json.Marshal(struct { + Type string `json:"Type"` + alias + }{ + Type: g.GetType(), + alias: alias(*g), + }) +} + +// ParseGenerator parses a single generator from JSON, dispatching on the +// discriminated Type field the same way ParsePolicy does. +func ParseGenerator(raw json.RawMessage) (Generator, error) { + var header struct { + Type string `json:"Type"` + } + if err := json.Unmarshal(raw, &header); err != nil { + return nil, fmt.Errorf("failed to parse generator type: %w", err) + } + + switch header.Type { + case "password": + var g PasswordGenerator + if err := json.Unmarshal(raw, &g); err != nil { + return nil, fmt.Errorf("failed to parse password generator: %w", err) + } + return &g, nil + default: + return nil, fmt.Errorf("unknown generator type: %s", header.Type) + } +} + +// ParseGenerators parses multiple generators from JSON. +func ParseGenerators(rawGenerators []json.RawMessage) ([]Generator, error) { + generators := make([]Generator, 0, len(rawGenerators)) + for i, raw := range rawGenerators { + generator, err := ParseGenerator(raw) + if err != nil { + return nil, fmt.Errorf("failed to parse generator at index %d: %w", i, err) + } + generators = append(generators, generator) + } + return generators, nil +} diff --git a/pkg/model/generator_test.go b/pkg/model/generator_test.go new file mode 100644 index 000000000..2496af98c --- /dev/null +++ b/pkg/model/generator_test.go @@ -0,0 +1,113 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGenerator_Password(t *testing.T) { + raw := json.RawMessage(`{ + "Type": "password", + "Label": "db-password", + "Stack": "default", + "Length": 24, + "Uppercase": true, + "Lowercase": true, + "Digits": true, + "Symbols": false, + "ExcludeCharacters": "oO0", + "RequireEachIncludedType": true + }`) + + generator, err := ParseGenerator(raw) + require.NoError(t, err) + + password, ok := generator.(*PasswordGenerator) + require.True(t, ok, "expected *PasswordGenerator") + + assert.Equal(t, "password", password.GetType()) + assert.Equal(t, "db-password", password.GetLabel()) + assert.Equal(t, "default", password.GetStack()) + assert.Equal(t, 24, password.Length) + assert.True(t, password.Uppercase) + assert.True(t, password.Lowercase) + assert.True(t, password.Digits) + assert.False(t, password.Symbols) + assert.Equal(t, "oO0", password.ExcludeCharacters) + assert.True(t, password.RequireEachIncludedType) +} + +// TestParseGenerator_Password_RoundTrip checks that marshaling a parsed +// PasswordGenerator back to JSON and re-parsing it produces an identical +// value, so the same shape written by the PKL schema and read by this parser +// survives a full round trip (as it does when a generator is stored, then +// reloaded to be re-applied). +func TestParseGenerator_Password_RoundTrip(t *testing.T) { + raw := json.RawMessage(`{ + "Type": "password", + "Label": "api-key-seed", + "Stack": "secrets-stack", + "Length": 40, + "Uppercase": true, + "Lowercase": true, + "Digits": true, + "Symbols": true, + "ExcludeCharacters": "", + "RequireEachIncludedType": false + }`) + + generator, err := ParseGenerator(raw) + require.NoError(t, err) + + marshaled, err := json.Marshal(generator) + require.NoError(t, err) + + roundTripped, err := ParseGenerator(marshaled) + require.NoError(t, err) + + assert.Equal(t, generator, roundTripped) +} + +// TestParseGenerator_Password_MarshalInjectsType verifies that Type is not a +// second source of truth: a PasswordGenerator built without ever setting a +// Type field still marshals "Type": "password", matching GetType(). +func TestParseGenerator_Password_MarshalInjectsType(t *testing.T) { + password := &PasswordGenerator{Label: "unset-type", Length: 16} + + marshaled, err := json.Marshal(password) + require.NoError(t, err) + + var decoded struct { + Type string `json:"Type"` + } + require.NoError(t, json.Unmarshal(marshaled, &decoded)) + assert.Equal(t, password.GetType(), decoded.Type) +} + +func TestParseGenerator_UnknownType(t *testing.T) { + raw := json.RawMessage(`{"Type": "unknown-generator", "Label": "x"}`) + + _, err := ParseGenerator(raw) + require.Error(t, err) + assert.ErrorContains(t, err, "unknown generator type") +} + +func TestParseGenerators_Multiple(t *testing.T) { + raw := []json.RawMessage{ + json.RawMessage(`{"Type": "password", "Label": "one", "Length": 16, "Uppercase": true, "Lowercase": true, "Digits": true, "Symbols": false, "RequireEachIncludedType": true}`), + json.RawMessage(`{"Type": "password", "Label": "two", "Length": 32, "Uppercase": true, "Lowercase": true, "Digits": true, "Symbols": false, "RequireEachIncludedType": true}`), + } + + generators, err := ParseGenerators(raw) + require.NoError(t, err) + require.Len(t, generators, 2) + assert.Equal(t, "one", generators[0].GetLabel()) + assert.Equal(t, "two", generators[1].GetLabel()) +}