Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ CLAUDE.md
.claude/
.handoffs/
.worktrees/
.superpowers/
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ Requires formae >= 0.89.0.
a defect worth recording, or AWS-managed flooding without a filterable
signal.

- Polymorphic `auth` on the target config: `DefaultChainAuth` (the existing
default credential provider chain — env vars, shared config, IMDS/IRSA —
optionally pinned to a shared-config `profile`) or `OidcAuth` (federated
identity: an OIDC identity token from the paired oidc-credential broker,
exchanged for credentials by assuming a role you name in the target
account). `auth` and the legacy flat `profile` field are mutually
exclusive; setting both is rejected at eval.

`OidcAuth` requires a formae agent with oidc-credential broker support
(`minFormaeVersion = "0.89.0"` in `formae-plugin.pkl`). Paired with an
older agent, or an agent with no broker paired, credential resolution
fails closed with an explicit error rather than ever falling back to
ambient credentials.

**Known limitation: the STS exchange uses a default-configured client.**
The `AssumeRoleWithWebIdentity` call that turns the identity token into
credentials is made with a region-only STS client, so
`AWS_USE_FIPS_ENDPOINT`, `AWS_ENDPOINT_URL_STS` and a custom CA bundle are
not honoured on that one exchange. Proxy settings are honoured, because
they come from the HTTP transport rather than from SDK configuration.
Every other AWS call the plugin makes uses the fully configured client and
is unaffected.

**One-time bookkeeping, not drift.** Existing targets carry no `auth`
block, and adding the field to the schema is a change formae records
against every target's stored metadata regardless of whether the target's
declared configuration actually changed. Expect a single, resource-inert
target-metadata update on the first reconcile after upgrading to this
version — no cloud resource is read, created, updated or destroyed by it.
If a stack shows exactly one such update per target immediately after the
upgrade, this is why; it is not drift and does not recur.

- `AWS::RDS::Database` and `AWS::RDS::DatabaseRole` support. A PostgreSQL
database inside an Aurora cluster, and its owning login role, are now
first-class declared resources. CloudControl models a cluster and its
Expand Down Expand Up @@ -338,6 +370,27 @@ Requires formae >= 0.89.0.
declaring the new five-resource forma. A later release will accept the older
two-part identifier so this is unnecessary.

### Deprecated

- The flat `profile` field on the target config is deprecated in favor of
`auth = new DefaultChainAuth { profile = ... }`. It continues to work
unchanged — a target set this way still authenticates via the default
credential chain pinned to that profile — and logs one deprecation warning
per plugin process rather than on every call. There is no removal in this
release; flat `profile` will be removed at a future major version, posted
ahead of time.

**Migrating is a normal target update.** Both `profile` and `auth` are
declared mutable, so rewriting a target from the flat `profile` to an
`auth` block changes the target's configuration in place and touches no
cloud resource.

This holds only on an agent at or above the release that carries these
hints. An **older** agent does not see them, classifies the dropped
top-level `Profile` key as an immutable change, and plans a target
replace, which destroys and recreates every resource on that target.
Upgrade the agent first, then migrate.

### Fixed

- An `AWS::CertificateManager::Certificate` with `subjectAlternativeNames` no
Expand Down
48 changes: 36 additions & 12 deletions aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,30 @@ import (
// The SDK automatically provides identity methods (Name, Version, Namespace)
// and schema methods (SupportedResources, SchemaForResourceType) by reading
// formae-plugin.pkl and schema/pkl/ at startup.
type Plugin struct{}
type Plugin struct {
// oidc carries the token source the SDK installs via SetOidcTokenSource,
// plus the plugin-lifetime credentials cache it backs. Nil until the SDK
// calls SetOidcTokenSource (or on an agent too old to pair a broker), in
// which case every target config threads nil deps and Oidc auth fails
// closed rather than falling back to ambient credentials.
oidc *config.OidcDeps
}

// Compile-time check: Plugin must satisfy ResourcePlugin interface.
var _ plugin.ResourcePlugin = &Plugin{}

// Compile-time check: Plugin must satisfy OidcAware, so the SDK hands it an
// OidcTokenSource at startup.
var _ plugin.OidcAware = &Plugin{}

// SetOidcTokenSource receives the token source the SDK mints OIDC identity
// tokens through. Called once at startup; every FromTargetConfig call below
// threads the resulting deps onto the parsed Config so Oidc auth blocks can
// exchange a token for AWS credentials.
func (p *Plugin) SetOidcTokenSource(src plugin.OidcTokenSource) {
p.oidc = config.NewOidcDeps(src)
}

// EKSAutomodeResourceTypes lists AWS CloudFormation resource types that EKS Automode manages.
// These resources are tagged with "kubernetes.io/cluster/<cluster-name>" = "owned".
var EKSAutomodeResourceTypes = []string{
Expand Down Expand Up @@ -157,7 +176,7 @@ func (p *Plugin) LabelConfig() pkgmodel.LabelConfig {
}

func (p *Plugin) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig)
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if registry.HasProvisioner(request.ResourceType, resource.OperationCreate) {
provisioner := registry.Get(request.ResourceType, resource.OperationCreate, targetConfig)
return provisioner.Create(ctx, request)
Expand All @@ -172,12 +191,13 @@ func (p *Plugin) Create(ctx context.Context, request *resource.CreateRequest) (*
}

func (p *Plugin) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if registry.HasProvisioner(request.ResourceType, resource.OperationUpdate) {
provisioner := registry.Get(request.ResourceType, resource.OperationUpdate, config.FromTargetConfig(request.TargetConfig))
provisioner := registry.Get(request.ResourceType, resource.OperationUpdate, targetConfig)
return provisioner.Update(ctx, request)
}

client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig))
client, err := ccx.NewClient(targetConfig)
if err != nil {
return nil, err
}
Expand All @@ -186,14 +206,15 @@ func (p *Plugin) Update(ctx context.Context, request *resource.UpdateRequest) (*
}

func (p *Plugin) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if request.ResourceType != "" {
if registry.HasProvisioner(request.ResourceType, resource.OperationCheckStatus) {
provisioner := registry.Get(request.ResourceType, resource.OperationCheckStatus, config.FromTargetConfig(request.TargetConfig))
provisioner := registry.Get(request.ResourceType, resource.OperationCheckStatus, targetConfig)
return provisioner.Status(ctx, request)
}
}

client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig))
client, err := ccx.NewClient(targetConfig)
if err != nil {
return nil, err
}
Expand All @@ -202,12 +223,13 @@ func (p *Plugin) Status(ctx context.Context, request *resource.StatusRequest) (*
}

func (p *Plugin) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if registry.HasProvisioner(request.ResourceType, resource.OperationDelete) {
provisioner := registry.Get(request.ResourceType, resource.OperationDelete, config.FromTargetConfig(request.TargetConfig))
provisioner := registry.Get(request.ResourceType, resource.OperationDelete, targetConfig)
return provisioner.Delete(ctx, request)
}

client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig))
client, err := ccx.NewClient(targetConfig)
if err != nil {
return nil, err
}
Expand All @@ -216,12 +238,13 @@ func (p *Plugin) Delete(ctx context.Context, request *resource.DeleteRequest) (*
}

func (p *Plugin) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if registry.HasProvisioner(request.ResourceType, resource.OperationRead) {
provisioner := registry.Get(request.ResourceType, resource.OperationRead, config.FromTargetConfig(request.TargetConfig))
provisioner := registry.Get(request.ResourceType, resource.OperationRead, targetConfig)
return provisioner.Read(ctx, request)
}

client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig))
client, err := ccx.NewClient(targetConfig)
if err != nil {
return nil, err
}
Expand All @@ -230,12 +253,13 @@ func (p *Plugin) Read(ctx context.Context, request *resource.ReadRequest) (*reso
}

func (p *Plugin) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) {
targetConfig := config.FromTargetConfig(request.TargetConfig).WithOidcDeps(p.oidc)
if registry.HasProvisioner(request.ResourceType, resource.OperationList) {
provisioner := registry.Get(request.ResourceType, resource.OperationList, config.FromTargetConfig(request.TargetConfig))
provisioner := registry.Get(request.ResourceType, resource.OperationList, targetConfig)
return provisioner.List(ctx, request)
}

client, err := ccx.NewClient(config.FromTargetConfig(request.TargetConfig))
client, err := ccx.NewClient(targetConfig)
if err != nil {
return nil, err
}
Expand Down
71 changes: 71 additions & 0 deletions aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,82 @@
package main

import (
"context"
"encoding/json"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/platform-engineering-labs/formae/pkg/plugin"
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
)

// Compile-time check: Plugin must satisfy OidcAware, so the SDK can hand it
// an OidcTokenSource at startup.
var _ plugin.OidcAware = (*Plugin)(nil)

// oidcTokenSourceFunc adapts a function to plugin.OidcTokenSource so a test
// can script what the source answers, and observe the ctx it was called
// with.
type oidcTokenSourceFunc func(ctx context.Context, audience string) (string, error)

func (f oidcTokenSourceFunc) IdentityToken(ctx context.Context, audience string) (string, error) {
return f(ctx, audience)
}

func TestSetOidcTokenSource_PopulatesDeps(t *testing.T) {
p := &Plugin{}
assert.Nil(t, p.oidc)

src := oidcTokenSourceFunc(func(context.Context, string) (string, error) {
return "stub-token", nil
})
p.SetOidcTokenSource(src)

require.NotNil(t, p.oidc)
assert.NotNil(t, p.oidc.Source)
}

// operationCtxMarkerKey is the context.WithValue marker used to prove a ctx
// travelled, unmodified in its values, from the operation call down into the
// OidcTokenSource.
type operationCtxMarkerKey struct{}

// TestRead_OidcRoutesTheOperationCtxToTheTokenSource drives Plugin.Read (with
// a resource type carrying no registered provisioner, so it falls through to
// the CloudControl client) against a target config with an Oidc auth block.
// The stub token source returns an error, which fails the AWS SDK's
// credential resolution before it signs or sends any request, so the
// assertion never depends on network access or a real STS exchange; it only
// exercises Config->ToAwsConfig->CredentialsCache->Retrieve->IdentityToken
// far enough to observe the ctx that reached the token source.
func TestRead_OidcRoutesTheOperationCtxToTheTokenSource(t *testing.T) {
var gotCtx context.Context
src := oidcTokenSourceFunc(func(ctx context.Context, audience string) (string, error) {
gotCtx = ctx
assert.Equal(t, "sts.amazonaws.com", audience)
return "", errors.New("stub source: deliberately unminted, for ctx-routing assertions only")
})

p := &Plugin{}
p.SetOidcTokenSource(src)

ctx := context.WithValue(context.Background(), operationCtxMarkerKey{}, "operation-ctx")
request := &resource.ReadRequest{
ResourceType: "AWS::Formae::NoSuchProvisioneredType",
NativeID: "irrelevant-for-this-test",
TargetConfig: json.RawMessage(`{"Region":"us-east-1","Auth":{"Type":"Oidc","RoleArn":"arn:aws:iam::123456789012:role/formae-agent"}}`),
}

_, err := p.Read(ctx, request)

require.Error(t, err)
require.NotNil(t, gotCtx)
assert.Equal(t, "operation-ctx", gotCtx.Value(operationCtxMarkerKey{}))
}

func TestMatchesFilter(t *testing.T) {
t.Run("matches when all filter properties are present and equal", func(t *testing.T) {
properties := `{"VpcId":"vpc-123","SubnetId":"subnet-456","CidrBlock":"10.0.0.0/24"}`
Expand Down
4 changes: 4 additions & 0 deletions formae-plugin.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ summary = "AWS resource plugin (CloudControl-based)"
description = "AWS CloudControl resource plugin for Formae"
category = "cloud"
license = "FSL-1.1-ALv2"
// The dev line at planning time is 0.89.0-dev.2 (last stable 0.88.1), so
// 0.89.0 is the release expected to carry the oidc-credential broker this
// plugin's OidcAuth depends on. Confirm the real release number before
// publishing and correct this if that work lands in a later release instead.
minFormaeVersion = "0.89.0"

output {
Expand Down
12 changes: 9 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11
github.com/google/uuid v1.6.0
github.com/platform-engineering-labs/formae/pkg/model v0.1.26
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1
// Re-pin to the next official pkg/plugin tag when one is cut.
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630
github.com/stretchr/testify v1.11.1
)
Expand All @@ -42,7 +43,7 @@ require (
github.com/apple/pkl-go v0.13.2 // indirect
github.com/asdine/storm v2.1.2+incompatible // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.1.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect
Expand Down Expand Up @@ -73,6 +74,8 @@ require (
github.com/miekg/dns v1.1.72 // indirect
github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df // indirect
github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1 // indirect
// Re-pin to the next official pkg/credential tag when one is cut.
github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6 // indirect
github.com/platform-engineering-labs/orbital v0.1.36 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
Expand Down Expand Up @@ -117,6 +120,9 @@ require (
resty.dev/v3 v3.0.0-beta.6.0.20260127085140-f531c9de7027 // indirect
)

replace ergo.services/ergo => github.com/JeroenSoeters/ergo v1.999.320-pel.2
// Matches the ergo fork revision required by the pinned pkg/plugin and
// pkg/credential (both need -pel.6). A replace directive is not transitive,
// so it has to be repeated here.
replace ergo.services/ergo => github.com/JeroenSoeters/ergo v1.999.320-pel.6

replace ergo.services/actor/statemachine => github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4 h1:JdoPbqQSv9iDCfkBFH5wDZCNpYHgJJVsrD7Xg0Jp57o=
github.com/JeroenSoeters/actor/statemachine v0.0.0-20260205190926-8b1b2eaf30f4/go.mod h1:XbiEudzYCbg5YvbIMqylWXH2iWnUeKagDTfPR7uOaqg=
github.com/JeroenSoeters/ergo v1.999.320-pel.2 h1:UL0ROEToN9mmkXobs4qBHKiWodHJNkbZdx9bXRd5b8c=
github.com/JeroenSoeters/ergo v1.999.320-pel.2/go.mod h1:bLQ6PoO6Mz/8gVuzvPv3xfMfo1P9w6rZV1WnMXMeMdg=
github.com/JeroenSoeters/ergo v1.999.320-pel.6 h1:aq/v/tYTn+/QJZuMV6GDDnln2EUI+Wy3cVFhs7TB1lU=
github.com/JeroenSoeters/ergo v1.999.320-pel.6/go.mod h1:bLQ6PoO6Mz/8gVuzvPv3xfMfo1P9w6rZV1WnMXMeMdg=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Sereal/Sereal/Go/sereal v0.0.0-20250307140414-035be09f1bc8 h1:6+qQvLkethJZQLb6r+Jxh0qxa7TfeFPBHlCzF7Ml8CM=
Expand Down Expand Up @@ -144,12 +144,12 @@ github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df h1:1xWk/De6cs3h2r
github.com/naegelejd/go-acl v0.0.0-20260323030528-42e4d61407df/go.mod h1:sTJHuiEPB0WNdPYPOP9M6FQU4YTHemXgDLIQqkbBdDI=
github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1 h1:ZMTgKwSomy2cVcl/+NivSqopbWeHbmYeQ+BxoYq8bVY=
github.com/platform-engineering-labs/formae/pkg/api/model v0.1.1/go.mod h1:0ncHFCsGA6b0w1kBm6m+QwJ823qAY2vL47GvoR0BTyU=
github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6 h1:3F9pGvQDm4HHvPfVhohzjeCJZHvrVy3KCvPb1px7tLU=
github.com/platform-engineering-labs/formae/pkg/credential v0.0.0-20260821213704-ba68bacf6dd6/go.mod h1:VS9hO24cYUcGYao9nfPb41HBfE2hF4WMrIS2fZwRwzM=
github.com/platform-engineering-labs/formae/pkg/model v0.1.26 h1:80p843bmz9sLTtUFveMGWYeGcpRjvceyVxUgPvlApqs=
github.com/platform-engineering-labs/formae/pkg/model v0.1.26/go.mod h1:1dmsFwoaJZkHevBsAIZr068CWzG7de1VNz7PFWvM3Z8=
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1 h1:dg8TBQVJR8DVF28bvmzAG4Ms1Id0yIr6Kd8c3UTO3iw=
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.1/go.mod h1:ZFXMfeZljHVDWQTSeJ4dj2EG3iTjdrxQjm7DpvOYUXk=
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260810030146-5a3389594c8e h1:EvXbhuvwOq3CIyx+pin9uyenol6IvBJFhkO/rdIAKm8=
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260810030146-5a3389594c8e/go.mod h1:gq591ZeRZ4jAHpdSvCyrOvi8qyALxcXQQhd0B6GRMAo=
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102 h1:/heBYUEoIef2G50AmXZtXbDaDENoblspX2FFdy59zeo=
github.com/platform-engineering-labs/formae/pkg/plugin v0.4.2-0.20260821224650-dc5149d5a102/go.mod h1:VXdXrX5hBF3xXK0GIUowIhTBUaog0BmjU8TOXF2yUfA=
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630 h1:ZyGSai5xPIi+vxAS04JlVr04Fecw3K9Zj2bsR4OBkdE=
github.com/platform-engineering-labs/formae/pkg/plugin-conformance-tests v0.2.7-0.20260811042554-70c525251630/go.mod h1:gq591ZeRZ4jAHpdSvCyrOvi8qyALxcXQQhd0B6GRMAo=
github.com/platform-engineering-labs/orbital v0.1.36 h1:nPMLxDbwDrjlhJoMQXAE86HtVhQHC2A6BJlWYmNM75c=
Expand Down
Loading
Loading