From ca8b4900271d428a9cf7e6956d1b6f01f3a548dd Mon Sep 17 00:00:00 2001 From: Jeroen Soeters Date: Sat, 5 Sep 2026 23:11:34 -0700 Subject: [PATCH 1/3] feat(aws): draw the database password with a generator The database master password was minted at evaluation time with random.password and pinned with setOnce. That shape cannot rotate, and eval-time randomness carries a footgun: the random.password read URI is cached per PKL evaluation, so two secrets with identical parameters silently share one value. A generator draws per generator and per binding, so the hazard disappears instead of being designed around. The secret now binds a PasswordGenerator with no rotation block: a one-time draw, like-for-like with the setOnce behavior it replaces, and the same 24-character alphanumeric shape. The generator only exists when --database=new, exactly like the secret it feeds. Generators need the formae 0.89.0 schema and a 0.89.0 agent, so the formae schema pin and the bundled agent image move up together; the aws schema pin moves to 0.1.17, which accepts a generator output where the 0.1.13 secretString type did not. --- aws/PklProject | 6 ++---- aws/bootstrap.pkl | 20 ++++++++++++++++++-- aws/vars.pkl | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/aws/PklProject b/aws/PklProject index 2ab08f1..b2e6708 100644 --- a/aws/PklProject +++ b/aws/PklProject @@ -2,12 +2,10 @@ amends "pkl:Project" dependencies { ["formae"] { - uri = "package://hub.platform.engineering/plugins/pkl/schema/pkl/formae/formae@0.88.0" + uri = "package://hub.platform.engineering/plugins/pkl/schema/pkl/formae/formae@0.89.0" } - // TODO(publish): pin to the published aws schema version that includes the IAM - // ServerCertificate Resolvable patch (so `serverCert.res.arn` resolves) ["aws"] { - uri = "package://hub.platform.engineering/plugins/aws/schema/pkl/aws/aws@0.1.13" + uri = "package://hub.platform.engineering/plugins/aws/schema/pkl/aws/aws@0.1.17" } } diff --git a/aws/bootstrap.pkl b/aws/bootstrap.pkl index 5af6b4e..56b0055 100644 --- a/aws/bootstrap.pkl +++ b/aws/bootstrap.pkl @@ -23,7 +23,6 @@ import "@aws/ec2/natgateway.pkl" import "@aws/rds/dbinstance.pkl" import "@aws/rds/dbsubnetgroup.pkl" import "@aws/secretsmanager/secret.pkl" -import "@formae/ext/random.pkl" import "@aws/ecs/ecscluster.pkl" import "@aws/ecs/taskdefinition.pkl" @@ -427,6 +426,7 @@ forma { dbSg dbSgIngress dbSubnetGroup + dbPasswordGen dbSecret db } @@ -641,11 +641,27 @@ local dbSubnetGroup = new dbsubnetgroup.DBSubnetGroup { tags = tagged("\(n)-db-subnet-group") } +// The database password is drawn by the agent, not minted at evaluation time: +// a generator draws once per binding (this one has no rotation block, so it +// never rotates on a schedule), and each generator draws independently, so two +// secrets can never silently share a value the way identically-parameterized +// eval-time randomness could. Bound via a differently-named local: writing +// `stack = ...` inside the generator body resolves against the generator's own +// property. +local dbStackRef: formae.StackResolvable = new { label = vars.stack.label } + +local dbPasswordGen: formae.PasswordGenerator = new { + label = "\(n)-db-password-gen" + stack = dbStackRef + length = 24 + symbols = false +} + local dbSecret = new secret.Secret { label = "\(n)-db-secret" name = "\(n)-db-password" description = "formae database master password (generated)" - secretString = formae.value(random.password(24, false)).opaque.setOnce + secretString = dbPasswordGen.gen.value tags = tagged("\(n)-db-secret") } diff --git a/aws/vars.pkl b/aws/vars.pkl index d53c9e4..a323dd1 100644 --- a/aws/vars.pkl +++ b/aws/vars.pkl @@ -17,7 +17,7 @@ dbName = "formae" dbUser = "formae" dbInstanceClass = "db.t4g.small" dbEngineVersion = "16.14" -formaeImage = "ghcr.io/platform-engineering-labs/formae:0.87.0" +formaeImage = "ghcr.io/platform-engineering-labs/formae:0.89.0" formaePort = 49684 stack: formae.Stack = new { From 94d1e36271864279d4540e5ec8c36cc7489992dd Mon Sep 17 00:00:00 2001 From: Jeroen Soeters Date: Sat, 5 Sep 2026 23:15:06 -0700 Subject: [PATCH 2/3] docs(aws): document the migration for installations on the setOnce password A setOnce field keeps the value it was created with, so formae refuses to route a generator through it: re-applying this version over an installation bootstrapped on the eval-time password is rejected at plan time. Document the two-apply migration (drop setOnce, then adopt the generator) and the agent restart it ends with, since the deployed agent receives FORMAE_DB_PASSWORD at task start and keeps the old value until its task is replaced. --- aws/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/aws/README.md b/aws/README.md index ed3ccc6..c998402 100644 --- a/aws/README.md +++ b/aws/README.md @@ -54,6 +54,34 @@ as your original apply. **Keep this local install and its datastore:** it holds infrastructure (VPC, database, ECS service) in state, so re-applying to upgrade depends on it. See [Updating the agent](https://docs.formae.io/en/latest/operations/install-aws-operations/#updating-the-agent-bootstrap). +### Upgrading an installation created before the generator-drawn password + +Installations bootstrapped before this version minted the database password at +evaluation time and pinned it with `setOnce`. A `setOnce` field keeps the value +it was created with, so formae refuses to route a generator through it: the +first re-apply of this version over such an installation is rejected at plan +time, naming the field. The apply changes nothing when refused. Migrate in two +applies, both with your usual flags: + +1. Edit `bootstrap.pkl` to the transitional shape — restore the old line but + without `.setOnce`, and leave the generator out of the manifest: + `secretString = formae.value(random.password(24, false)).opaque`. Apply. + This re-mints the password once and releases the `setOnce` pin; the database + follows the new value in the same apply. +2. Revert to this version's `bootstrap.pkl` as shipped and apply again. The + generator draws, and the secret and database move together; from here on the + password is generator-owned. +3. Restart the deployed agent so it reads the new password — it receives + `FORMAE_DB_PASSWORD` at task start, so it keeps using the old one until its + task is replaced: + `aws ecs update-service --cluster -cluster --service -service --force-new-deployment` + (default `` is `formae-bootstrap`). + +Run the two applies back to back and restart once at the end: the deployed +agent cannot reach its database from the moment step 1 lands until the restart, +so keep that window short. Your local install, which runs these applies, is +unaffected. Fresh installations need none of this. + ## Full guide Prerequisites (ACM certificate, Tailscale setup), every flag, sizing, and day-2 operations From d88a51f79e1a974cb08addb5c2034b55c57ce3d0 Mon Sep 17 00:00:00 2001 From: Jeroen Soeters Date: Sat, 5 Sep 2026 23:17:01 -0700 Subject: [PATCH 3/3] docs(aws): the transitional migration edit needs the random import back The shipped version removes the random module import, so the documented transitional shape must restore it or the step-1 apply fails at evaluation. The transitional file was verified to evaluate. --- aws/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/aws/README.md b/aws/README.md index c998402..887754a 100644 --- a/aws/README.md +++ b/aws/README.md @@ -63,11 +63,13 @@ first re-apply of this version over such an installation is rejected at plan time, naming the field. The apply changes nothing when refused. Migrate in two applies, both with your usual flags: -1. Edit `bootstrap.pkl` to the transitional shape — restore the old line but - without `.setOnce`, and leave the generator out of the manifest: - `secretString = formae.value(random.password(24, false)).opaque`. Apply. - This re-mints the password once and releases the `setOnce` pin; the database - follows the new value in the same apply. +1. Edit `bootstrap.pkl` to the transitional shape: add + `import "@formae/ext/random.pkl"` back to the import block, change the + secret's line to + `secretString = formae.value(random.password(24, false)).opaque` + (the old line without `.setOnce`), and remove `dbPasswordGen` from the + manifest. Apply. This re-mints the password once and releases the `setOnce` + pin; the database follows the new value in the same apply. 2. Revert to this version's `bootstrap.pkl` as shipped and apply again. The generator draws, and the secret and database move together; from here on the password is generator-owned.