# Apply modes Source: https://docs.formae.io/documentation/concepts/apply-modes What reconcile and patch modes each let formae do, and what they refuse to do. Every `formae apply` requires a mode. The mode determines what formae is allowed to do, and more importantly, what it won't do. ## Reconcile ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl ``` Reconcile is the [GitOps](/documentation/concepts/ways-to-work) mode: your codebase declares the desired state of your infrastructure, and reconcile makes reality match it. Resources in your forma are created or updated, and resources that exist in the cloud but **not** in your forma are destroyed. After a reconcile, your infrastructure is exactly what your code says it should be. This is the everyday mode whenever your code is the source of truth and you want your infrastructure brought back in line with it, from a first deployment to routine changes. ## Patch ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode patch change.pkl ``` Patch is **append-only**. It only creates or updates the resources named in your forma, and never destroys anything. Resources you don't mention are left untouched, and within a resource, collection properties (tags, security groups, and the like) are only added to, never trimmed. Reconcile can destroy; patch cannot. Patch exists for two situations: * **Working without a declared codebase.** When you don't have code that describes the desired state of your infrastructure, or don't have it to hand, you work by extract-and-patch: extract the resource or two you want to change, edit them, and apply them back with patch. * **Emergency fixes.** When you're firefighting an incident and want the smallest possible blast radius, patch one or two resources to resolve it rather than reconciling a whole stack. ### A patch is an out-of-band change A patch changes your infrastructure without going through a full reconcile, so formae treats it exactly like a change made outside formae, the same way it treats an [out-of-band change](/documentation/concepts/synchronization) that sync pulls in. After a patch, your codebase no longer describes reality. The next [soft reconcile](#hard-vs-soft-reconcile) on that stack detects the drift the patch introduced and **fails**, forcing you to deal with it: absorb the change back into your code, or undo it. This is deliberate. A 2am patch fixes the incident now; the drift it created surfaces the next morning, so someone reconciles the code back to reality instead of silently losing the change. ## Collection handling When a resource has a collection property (tags, security groups, or any list of values), the apply mode determines how that collection is updated. **Reconcile mode** treats your forma as the complete truth: the collection after apply matches exactly what you specified, and elements not in your forma are removed. **Patch mode** is append-only: elements in your forma are guaranteed to exist after apply, but existing elements are never removed. **Example:** a resource has tags `[A, B, C]` and your forma specifies `[B, D]`. | Mode | Result | What happened | | --------- | -------------- | ------------------------------- | | Reconcile | `[B, D]` | Tags A and C are removed | | Patch | `[A, B, C, D]` | Tag D is added, nothing removed | This makes patch mode safer for shared resources where multiple teams manage different parts of the same collection. ## Hard vs soft reconcile When someone changes a resource outside of formae (through the cloud console, another tool, or a script), that's an out-of-band change. Reconcile mode handles them in two ways. ### Soft reconcile (default) If formae detects out-of-band changes, it rejects the apply to protect you: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl # Fails: external changes detected ``` This gives you a chance to review what changed: extract the current state, compare it with your code, and decide what to keep. ### Hard reconcile If you've reviewed the changes and want to overwrite them, add `--force`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --force main.pkl ``` Hard reconcile overwrites all out-of-band changes and brings the infrastructure exactly in line with your forma. Use it when your code is the authority and external changes should be discarded. Soft reconcile is a safety net. Reach for `--force` only when you know what you're overwriting. ## Resource replacement Most property changes update a resource in place. Some properties are immutable, though: changing them triggers a destroy followed by a create. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} myVpc = new vpc.Vpc { cidrBlock = "10.1.0.0/16" // Immutable, triggers replacement } ``` formae handles this automatically, but be aware: replacement means downtime for that resource and anything that depends on it. Simulation catches this before it happens: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --simulate main.pkl ``` Check the simulation output for "replace" operations before applying. ## See also * [Policy](/documentation/concepts/policy): auto-reconcile policies apply reconcile mode on a schedule. * [Synchronization](/documentation/concepts/synchronization): how formae detects the out-of-band changes that soft reconcile protects against. # Architecture Source: https://docs.formae.io/documentation/concepts/architecture How the CLI, agent, metastructure, and plugins fit together.
The client holds the CLI and MCP side by side as siblings. Both read your infrastructure as code in Pkl, and both call the API inside your infrastructure. The agent contains the API and the metastructure, which holds the datastore. The agent drives three plugins, each of which manages resources such as S3 and IAM, VMs and databases, or routers.
Two design choices set formae apart from traditional infrastructure tooling: * **Active components** automate work that usually needs manual intervention, reducing the operational burden on you. * **Automatic state management** tracks every infrastructure change regardless of where it came from, and keeps state current so an up-to-date infrastructure-as-code representation is available on request at any time. Together they improve maintainability, change tracking, and operational efficiency across the whole infrastructure lifecycle. ## CLI and API The CLI and API are the front end of the system: a consistent interface for people and applications to drive the platform. The API currently lives inside the agent and will be extracted into its own component later. The CLI runs wherever you need it, on your local machine or on a node in your CI/CD system. ## Metastructure The **metastructure** is formae's internal representation that combines your infrastructure configuration with the operational logic to manage it. It enables: * **Active monitoring** of infrastructure changes in real time. * **Asynchronous application** of changes that converge to your desired state. * **Complete version history**, so you can take any infrastructure state (past or present), modify it, and apply it to any environment. * **Automatic tracking** of changes made both inside and outside formae. Traditional IaC tools store only static configuration. The metastructure holds both the *what* (your infrastructure) and the *how* (the operations to manage it), which is what lets formae continuously reconcile your actual infrastructure with your code. This design moves complexity into the agent and keeps the plugins simple. ## Agent The agent is the actively running backend of the platform, responsible for executing core operations. formae currently supports single-agent operation, with multi-agent distribution planned for the future. The agent: * Is the central execution engine for platform operations. * Maintains state and handles resource management. * Processes requests from the CLI through the API. * Manages the metastructure implementation. You can deploy the agent in several ways: * **Cloud deployment** runs the agent in a cloud account as a service. This is the standard approach for production. * **Local operation** runs the agent entirely on a local computer, whether a developer's machine or a CI/CD node. * **Container or Kubernetes** runs the agent in a container, or in a Kubernetes pod with a few extra prerequisites such as storage. The agent manages the lifecycle of the technology plugins shipped with formae and any you implement yourself. Plugins always run as separate processes and are never embedded. **A single target must be managed by exactly one agent.** Two agents managing overlapping targets conflict: each has its own datastore, which leads to conflicting discovery, duplicate resource tracking, and unpredictable behaviour. The correct pattern is one agent per target, with multiple team members pointing their CLIs at the same agent so there is a single source of truth. When you design targets across agents, make sure no piece of infrastructure is managed by more than one agent. ## Datastore The agent uses a datastore for persistence. The implementation varies with your deployment: * **SQLite** (default): an embedded database on the agent's local disk, no setup required. * **Postgres**: an external PostgreSQL server, typically managed or highly available. * **Aurora Data API**: reaches Aurora through the AWS RDS Data API instead of a direct connection, so no VPC access is needed. * **Microsoft SQL Server**: an external SQL Server instance, with SQL or workload identity authentication. # Discovery Source: https://docs.formae.io/documentation/concepts/discovery How formae finds resources you haven't told it about yet, and tracks them until you bring them under management. formae continuously scans your cloud targets for resources it doesn't manage yet, so you can adopt it gradually instead of migrating everything at once. Discovery is enabled by default. As soon as you apply a [discoverable target](/documentation/concepts/target#discovery), formae starts scanning it every 10 minutes. ## What discovery does Every resource discovery finds that formae doesn't already manage is recorded as **unmanaged**. So formae can coexist with other tools without forcing a rapid migration, an unmanaged resource is read-only: you can see it, but formae won't change it until you bring it under management. Find what discovery has turned up in your inventory: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:false" ``` To bring discovered resources under management (extract them into a forma, give them a stack, and apply), see [Bring existing resources under management](/documentation/guides/bring-resources-under-management). ## Controlling what gets scanned Targets are discoverable by default. Set `discoverable = false` on any target you want excluded from discovery. That field lives on the target, so see [Target → Discovery](/documentation/concepts/target#discovery) for the config, and [Configuration](/documentation/reference/configuration#discovery) for discovery settings like the scan interval. ## How discovered resources are labeled Every discovered resource needs a [label](/documentation/concepts/label). formae derives it from the resource's own properties, using the plugin's default rule (the AWS plugin reads the `Name` tag), and falls back to the provider identifier when nothing matches. See [Label → Labels for discovered resources](/documentation/concepts/label#labels-for-discovered-resources) for the full resolution order, how to override it, and how duplicate labels are disambiguated. ## Coexisting with other tools A resource formae discovers can still be managed by another tool at the same time. [Synchronization](/documentation/concepts/synchronization) keeps formae's view of these resources up to date, so drift shows up rather than silently diverging. ## See also * [Synchronization](/documentation/concepts/synchronization): how formae keeps resources it already knows about current. * [Target](/documentation/concepts/target): where discovery scans run, and how to mark a target discoverable. * [Label](/documentation/concepts/label): full labeling and collision rules. * [Resources](/documentation/concepts/resources): managed versus unmanaged resources. # Forma Source: https://docs.formae.io/documentation/concepts/forma A forma is an infrastructure declaration: the unit formae applies, extracts, or destroys. A forma (plural: formae) is an infrastructure declaration. When you apply a forma, formae processes it to create, update, or delete resources. A forma defines the exact scope of change, whether that's an entire environment or a single update. You can manage your deployment at any level of granularity, without being forced to touch unrelated resources. Formae support three operations: * [`apply`](/documentation/reference/cli) a forma to provision or modify infrastructure * [`extract`](/documentation/reference/cli) a forma from existing resources, capturing their current state so you can reuse it elsewhere * [`destroy`](/documentation/reference/cli) a forma to decommission the resources it declares A forma can be temporary (used once) or committed to version control for long-term management. ## Example ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/s3/bucket.pkl" forma { new formae.Stack { label = "my-app" description = "My application stack" } new formae.Target { label = "my-aws-target" config = new aws.Config { region = "us-east-1" } } new bucket.Bucket { label = "app-bucket" bucketName = "my-unique-bucket-name" } } ``` Every forma includes at least one [stack](/documentation/concepts/stack) to organize resources and at least one [target](/documentation/concepts/target) to specify where they're applied. This example uses `amends "@formae/forma.pkl"`. `formae project init` now generates `extends "@formae/forma.pkl"` instead, which additionally lets you declare typed CLI [properties](/documentation/concepts/properties). Both forms are supported. A forma might not complete successfully: failures can occur between the agent and the target systems. Incomplete execution leaves infrastructure in a consistent state. If the failure is recoverable or caused by bad input, reapply the forma. The agent retries when it can. ## See also * [Write your first forma](/documentation/get-started/write-your-first-forma): structure, properties, and resource references, hands-on * [Stack](/documentation/concepts/stack): how resources are grouped for lifecycle management * [Target](/documentation/concepts/target): where a forma's resources are applied # Label Source: https://docs.formae.io/documentation/concepts/label The formae-side identifier you use to reference a stack or resource. A `label` is how you identify and reference a stack or resource in formae. Instead of different terms like "name" or "id" for different technologies, formae uses `label` consistently throughout the platform. Resource labels must be unique within a stack, so you can always reference a specific resource without ambiguity. ## Examples **A resource label in Pkl:** ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new bucket.Bucket { label = "my-s3-bucket" bucketName = "my-unique-bucket-name" } ``` **A stack label in Pkl:** ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "my-stack" description = "Stack for my resources" } ``` **Using labels in commands:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} # Destroy resources in a specific stack by label formae destroy --query="stack:production-api" # View resources with a specific label formae inventory resources --query="label:my-s3-bucket" ``` Use descriptive labels that clearly identify the purpose of a stack or resource. Labels are how you'll reference and query your infrastructure. ## Labels for discovered resources When formae discovers a resource that isn't already managed, it assigns a label so you can refer to it by a readable name rather than the provider's raw identifier. The label is taken from one of the resource's own properties, so a discovered resource arrives with a name that already means something to you. Which property? Each plugin decides, and ships a sensible default. The rule is expressed as a small [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html) query in the plugin's [`labelConfig`](/documentation/reference/configuration#labelconfig). The syntax can look dense at first, but it is really just "pull this one field out of the resource". The AWS plugin's default reads the `Name` tag: ``` $.Tags[?(@.Key=='Name')].Value ``` Read that as: from the resource's `Tags`, take the one whose `Key` is `Name`, and use its `Value`. So an EC2 instance tagged `Name=web-server` is discovered with the label `web-server`. You can override the rule per resource type in your `formae.conf.pkl`. If the query returns nothing (the resource has no `Name` tag, or your override matched no fields), formae falls back to the resource's provider identifier as the label. An EC2 instance with no `Name` tag would be discovered with a label like `i-0abc1234`. ### Collisions If two discovered resources would yield the same label, formae appends a numeric suffix to disambiguate. Three Aurora replica instances all tagged `Name=database` are discovered as `database`, `database-1`, `database-2`, in the order they are discovered. The first instance keeps the unsuffixed label; later ones get the next free number. Renaming a resource later does not free its old label for future discoveries: the suffix counter is derived from labels currently in the inventory, not a separate counter. Renaming `database` to `aurora-writer` while `database-1` and `database-2` still exist means the next discovery that would land on `database` becomes `database-3`, not `database`. ## Renaming a resource Because a label is a formae-side identifier that is never sent to the cloud provider, you can rename a resource with no cloud operation at all: only the inventory row changes. This makes labels safe to refactor, and it is how you give a readable name to a resource that discovery labeled with a raw provider identifier. For the steps, see [Rename a resource](/documentation/guides/rename-a-resource). ## See also * [Resources](/documentation/concepts/resources): managed versus unmanaged resources, and how discovered ones are brought under management. * [Configuration](/documentation/reference/configuration#labelconfig): overriding a plugin's default label extraction. * [Resolvable](/documentation/concepts/resolvable): referencing a resource by its label via `.res`. # Plugins Source: https://docs.formae.io/documentation/concepts/plugin How formae extends itself to new infrastructure through separate, independently versioned plugin processes. formae uses a plugin architecture to extend its capabilities. Plugins add support for infrastructure formae doesn't manage out of the box, without changing formae's core. ## How plugins work Plugins run as separate processes that communicate with the formae agent. When the agent starts, it discovers installed plugins and spawns them. Each plugin announces its capabilities (supported resource types, rate limits), and the agent routes resource operations to the right plugin based on resource type. This architecture gives you: * **Isolation**: a plugin crash doesn't take down the agent or other plugins. * **Independent updates**: update a plugin without updating formae itself. * **Extensibility**: anyone can build a plugin for infrastructure formae doesn't yet support. * **Independent licensing**: plugins are licensed separately from formae. ## Plugin types A plugin is one of two types, declared in its manifest: * **Resource plugins** manage cloud provider resources, for example `aws`, `azure`, `gcp`. They run only on the agent host, since that's where orchestration happens. A resource plugin bundles its own Pkl schema (`schema/pkl/`) alongside its binary, so installing the plugin is what makes its resource types available in your forma files. * **Auth plugins**, for example `auth-basic`, construct and verify the authentication header used on every CLI-to-agent request. Because both sides of that exchange need to agree, an auth plugin must be installed at the same version on **both** the agent host and every CLI host. Earlier versions of formae described a third "network" plugin role (Tailscale mesh networking as a plugin). Tailscale is now configured directly on the agent as a `network` config block, not installed as a plugin. See [Security & networking](/documentation/reference/security-and-networking) for setup. ## Plugin location Plugins install into `/opt/pel`, organized by namespace and version: ``` /opt/pel/ ├── AWS/ │ └── v0.1.5/ │ ├── aws # Plugin binary │ ├── formae-plugin.pkl │ └── schema/pkl/ └── MYCLOUD/ └── v1.0.0/ ├── mycloud ├── formae-plugin.pkl └── schema/pkl/ ``` `/opt/pel` is root-owned and the agent runs as the `pel` user, so `formae plugin install`, `uninstall`, and `update` typically need `sudo`. When the agent starts, it discovers and loads every plugin under this tree. `formae plugin install` runs locally, on the host you invoke it from. For an agent deployed via a cloud installer (ECS, ACI, Cloud Run, Helm/K8s) there's no host to install onto directly, bake the plugin into a derived agent image instead. ## Example: the AWS plugin The `aws` plugin lets formae manage AWS resources. Once installed, you can use AWS resource types in your forma: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@aws/s3/bucket.pkl" new bucket.Bucket { label = "my-bucket" bucketName = "my-unique-bucket-name" } ``` ## Listing plugins ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin list ``` ``` Resource plugins: ✓ aws 0.1.5 (agent + cli) ✓ azure 0.1.2 (agent + cli) Auth plugins: ✓ auth-basic 0.1.0 (agent + cli) ``` Each row shows whether the plugin lives on the agent, the CLI, or both, and flags a version mismatch if an auth plugin has drifted between the two. See the [`plugin` CLI reference](/documentation/reference/cli) for `search`, `info`, `install`, `uninstall`, and `update`. ## Building your own plugin If you need to manage resources in a system formae doesn't support yet, build a plugin. A plugin implements CRUD operations (Create, Read, Update, Delete) and discovery for its resource types; formae handles orchestration, ordering, and rate limiting around it. See Extending formae for how to get started. ## See also * [Target](/documentation/concepts/target): where a plugin's resources get deployed. * [Apply modes](/documentation/concepts/apply-modes): how the agent uses a plugin's Create/Update/Delete operations during an apply. # Auto-reconcile policy Source: https://docs.formae.io/documentation/concepts/policies/auto-reconcile Automatically re-apply a stack's declared state at a fixed interval, undoing drift. An auto-reconcile policy re-applies a stack's declared state on a schedule, without you running `apply` yourself. It's how you keep a stack pinned to its declared state continuously, instead of only at the moment you happen to deploy it. ## How it works After each successful reconcile, formae records the resulting state. At the configured interval, it compares the stack's current state against that recorded state. If it finds any deviation, it automatically runs a [hard reconcile](/documentation/concepts/apply-modes#hard-vs-soft-reconcile): declared state wins, and any drift is overwritten. The interval timer restarts after each reconcile completes, so reconciliations stay evenly spaced regardless of how long any single one takes. ## Configuration | Property | Type | Description | | ---------- | -------- | -------------------------------------------------- | | `interval` | Duration | How often to reconcile, for example `5.min`, `1.h` | ## Relationship to synchronization [Synchronization](/documentation/concepts/synchronization) is what detects drift: changes made through the cloud console, another tool, or a formae [patch](/documentation/concepts/apply-modes). Without auto-reconcile, that drift just sits there until your next `apply`, and [soft reconcile](/documentation/concepts/apply-modes#hard-vs-soft-reconcile) will stop you to review it. Auto-reconcile skips that pause: it applies a hard reconcile automatically on every interval, so drift never survives longer than one cycle. Auto-reconcile suits production stacks where configuration consistency matters more than allowing manual experimentation. For a dev environment where you want room to poke at things through the console, leave it off. ## Examples **Inline auto-reconcile policy:** ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/ec2/securitygroup.pkl" local prodTarget = new formae.Target { label = "prod-target" config = new aws.Config { region = "us-east-1" } } forma { prodTarget new formae.Stack { label = "production-networking" description = "Production network security - auto-enforced" policies = new Listing { new formae.AutoReconcilePolicy { interval = 5.min // Re-enforce every 5 minutes } } } new securitygroup.SecurityGroup { label = "api-sg" stack = "production-networking" target = prodTarget.res groupDescription = "API security group - managed by formae" securityGroupIngress = new Listing { new { ipProtocol = "tcp" fromPort = 443 toPort = 443 cidrIp = "0.0.0.0/0" } } } } ``` **Reusable auto-reconcile policy**, shared across every production stack: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/s3/bucket.pkl" local prodReconcile = new formae.AutoReconcilePolicy { label = "prod-reconcile-5m" interval = 5.min } local prodTarget = new formae.Target { label = "prod-target" config = new aws.Config { region = "us-east-1" } } forma { prodReconcile prodTarget new formae.Stack { label = "prod-storage" description = "Production storage infrastructure" policies = new Listing { prodReconcile.res } } new bucket.Bucket { label = "prod-data" stack = "prod-storage" target = prodTarget.res bucketName = "company-prod-data" } new formae.Stack { label = "prod-logging" description = "Production logging infrastructure" policies = new Listing { prodReconcile.res } } new bucket.Bucket { label = "prod-logs" stack = "prod-logging" target = prodTarget.res bucketName = "company-prod-logs" } } ``` **Combining with a TTL policy**, for a staging stack that stays enforced while it lives, then expires: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "staging-environment" description = "Staging env - auto-reconciled, expires in 7 days" policies = new Listing { new formae.AutoReconcilePolicy { interval = 1.h } new formae.TTLPolicy { ttl = 7.d onDependents = "cascade" } } } ``` ## Use cases * **Security compliance**: security groups and IAM policies always match declared configuration; unauthorized changes get reverted automatically. * **Production stability**: desired state holds in critical infrastructure without manual intervention. * **Multi-team environments**: when several teams can reach the same infrastructure, auto-reconcile reverts changes made outside version-controlled IaC. ## Monitoring ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks ``` Shows which stacks have an auto-reconcile policy and its interval. Reconcile runs triggered by the policy show up in your regular command history: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status --query='stack:production-networking' ``` ## Considerations **Interval selection.** Balance responsiveness against cloud provider rate limits; very short intervals (under a minute) risk being throttled. **Emergency changes.** If you need a change to persist, either update your infrastructure code before the next cycle, or temporarily remove the policy. **Interaction with patches.** Auto-reconcile reverts patch-mode changes too. If you apply an urgent [patch](/documentation/concepts/apply-modes), the next auto-reconcile cycle undoes it unless you also update the declared state. ## See also * [Policy](/documentation/concepts/policy): inline vs. reusable policies, and how to query them. * [TTL policy](/documentation/concepts/policies/ttl): expire a stack instead of enforcing it. The two can be combined on the same stack. * [Synchronization](/documentation/concepts/synchronization): how formae detects the drift auto-reconcile undoes. * [Apply modes](/documentation/concepts/apply-modes): soft vs. hard reconcile, and what auto-reconcile automates. # TTL policy Source: https://docs.formae.io/documentation/concepts/policies/ttl Automatically destroy a stack and its resources after a set duration. A TTL (time-to-live) policy destroys a stack and everything in it once its deadline passes. It's the mechanism for ephemeral infrastructure: dev workspaces, test environments, demos, anything that should clean itself up without someone remembering to run `destroy`. ## How it works The deadline is expressed one of two ways: a **duration** (`ttl`), measured from when the stack is created, or an **absolute instant** (`expiresAt`). Either way it counts down in the background: no `apply` is needed to trigger the eventual destroy. Re-applying the stack does not move the deadline; a `ttl` always counts from the stack's creation, and an `expiresAt` names the instant directly. When the deadline passes, formae destroys the stack and all its resources. ## Configuration A TTL policy carries exactly one of `ttl` or `expiresAt`. Declaring both, or neither, fails validation when the forma is evaluated. | Property | Type | Description | | -------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `ttl` | Duration | How long the stack should live, measured from its creation, for example `1.h`, `24.h`, `7.d` | | `expiresAt` | String | The instant to destroy the stack at, as an RFC 3339 UTC timestamp, for example `"2026-09-01T00:00:00Z"`. Sub-second precision is dropped | | `onDependents` | `"abort"` \| `"cascade"` | What to do if resources outside the stack depend on it. Defaults to `"abort"` | ### Relative or absolute? * **`ttl`** fits infrastructure whose lifetime is known at creation: a sandbox that lives for the workday, a test environment that should survive its CI run and no longer. Because it measures from stack creation, attaching a `ttl` to a stack that already exists counts the time the stack has already lived: a `ttl = 1.h` on a three-day-old stack is already expired and the stack is destroyed on the next expiry check. * **`expiresAt`** fits deadlines known as a date: the end of a trial, a scheduled teardown, a booking window. It reads the same to every observer, never shifts on re-apply, and is the right form when another system computes the deadline. To destroy an existing stack some time from now, compute the instant and set `expiresAt` rather than attaching a `ttl`. To move a deadline, re-apply the stack with a new `expiresAt` (or a new `ttl`; the change applies in place, still anchored to creation). To remove one, re-apply the stack in reconcile mode without the policy. ### onDependents When the TTL expires, formae checks whether any resource outside the stack depends on a resource inside it: * **`abort`** (default): cancel the destruction. The stack stays intact until the dependency is removed. * **`cascade`**: delete the dependent resources too, following the dependency chain into other stacks. `cascade` can delete resources in stacks you didn't intend to touch, anywhere the dependency chain leads. Use `abort` unless you're certain nothing outside the stack should survive it. ## Examples **Inline TTL policy:** ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/logs/loggroup.pkl" local devTarget = new formae.Target { label = "dev-target" config = new aws.Config { region = "us-east-1" } } forma { devTarget new formae.Stack { label = "feature-xyz" description = "Temporary dev environment for feature XYZ" policies = new Listing { new formae.TTLPolicy { ttl = 8.h // Destroy after 8 hours onDependents = "abort" // Don't destroy if other resources depend on it } } } new loggroup.LogGroup { label = "feature-logs" stack = "feature-xyz" target = devTarget.res logGroupName = "/dev/feature-xyz/logs" retentionInDays = 1 } } ``` **Absolute deadline**, for a stack that must be gone at a known instant: ```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "pilot-workshop" description = "Workshop environment for the September pilot" policies = new Listing { new formae.TTLPolicy { expiresAt = "2026-09-01T00:00:00Z" // Destroy at this instant onDependents = "abort" } } } ``` **Reusable TTL policy**, shared by every developer's sandbox stack: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/logs/loggroup.pkl" local ephemeralPolicy = new formae.TTLPolicy { label = "ephemeral-4h" ttl = 4.h onDependents = "cascade" } local devTarget = new formae.Target { label = "dev-target" config = new aws.Config { region = "us-east-1" } } forma { ephemeralPolicy devTarget new formae.Stack { label = "alice-dev" description = "Alice's development environment" policies = new Listing { ephemeralPolicy.res } } new loggroup.LogGroup { label = "alice-logs" stack = "alice-dev" target = devTarget.res logGroupName = "/dev/alice/logs" } new formae.Stack { label = "bob-dev" description = "Bob's development environment" policies = new Listing { ephemeralPolicy.res } } new loggroup.LogGroup { label = "bob-logs" stack = "bob-dev" target = devTarget.res logGroupName = "/dev/bob/logs" } } ``` ## Use cases * **Development environments** that clean themselves up at the end of the workday. * **CI/CD test infrastructure** that disappears once the test run finishes with it. * **Demo environments** that auto-destroy after the demo window. * **Cost control**: forgotten resources stop accumulating cost on their own. ## Monitoring ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks ``` Shows every stack's attached policies, including TTL and its expiry. ## See also * [Policy](/documentation/concepts/policy): inline vs. reusable policies, and how to query them. * [Auto-reconcile policy](/documentation/concepts/policies/auto-reconcile): enforce declared state instead of expiring it. The two can be combined on the same stack. # Policy Source: https://docs.formae.io/documentation/concepts/policy A policy is a lifecycle rule you attach to a stack: automatic cleanup after a duration, or automatic enforcement of declared state. A policy is a configurable behavior you attach to a stack. Where a forma declares what resources should exist, a policy declares how formae should manage the stack's lifecycle over time, without you having to run another `apply`. formae has two built-in policy types: * **[TTL policy](/documentation/concepts/policies/ttl)**: destroys a stack and its resources after a set duration. * **[Auto-reconcile policy](/documentation/concepts/policies/auto-reconcile)**: re-applies the stack's declared state at a fixed interval, undoing out-of-band and incremental changes. ## Inline vs. reusable policies You can define a policy two ways. **Inline policies** live directly inside a stack definition. They belong to that stack alone and are deleted along with it. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "dev-environment" policies = new Listing { new formae.TTLPolicy { ttl = 4.h onDependents = "cascade" } } } ``` **Reusable policies** are standalone objects, defined once and referenced from any number of stacks with `.res`. Changes to the policy propagate to every stack that references it. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local ephemeralPolicy = new formae.TTLPolicy { label = "ephemeral-1h" ttl = 1.h onDependents = "abort" } forma { ephemeralPolicy new formae.Stack { label = "dev-stack-1" policies = new Listing { ephemeralPolicy.res } } new formae.Stack { label = "dev-stack-2" policies = new Listing { ephemeralPolicy.res } } } ``` | Aspect | Inline | Reusable | | --------- | ------------------------------------------------------- | --------------------------------------- | | Label | None | Required | | Lifecycle | Follows the stack's declaration; deleted with the stack | Independent, must be explicitly deleted | | Sharing | Single stack only | Any number of stacks | | Use case | One-off behavior | Consistent policy across stacks | Reach for a reusable policy as soon as you find yourself copying the same `ttl` or `interval` value into more than one stack. A single source of truth means one edit updates every stack that uses it. ## Removing a policy In reconcile mode, the applied forma is the source of truth for a stack's policies, the same way it is for the stack's resources: * **Inline policy**: re-apply the stack without the policy in its `policies` listing, and the policy is deleted. * **Reusable policy**: re-apply the stack without the `.res` reference, and the policy is detached from that stack. The standalone policy object itself lives on, attached to whatever other stacks still reference it, until it is destroyed. This also means an inline policy added outside the forma (for example through an operator tool) does not survive the next reconcile of a stack the forma declares: declare it in the forma to keep it. Patch mode never touches policies you don't mention. ## Querying policies ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory policies ``` Lists reusable policies only, since inline policies have no independent identity outside their stack. To see which policy, inline or reusable, is attached to a given stack, check the stack itself: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks ``` ## See also * [TTL policy](/documentation/concepts/policies/ttl): automatic cleanup after a duration. * [Auto-reconcile policy](/documentation/concepts/policies/auto-reconcile): automatic enforcement of declared state. * [Stack](/documentation/concepts/stack): the unit a policy attaches to. # Properties Source: https://docs.formae.io/documentation/concepts/properties Parameters that turn a forma into a reusable, type-safe template exposed as CLI flags. A forma can be parameterized with properties. Properties make a forma reusable across environments and let you hide infrastructure detail behind a small, typed interface, useful when a platform team owns the forma and other teams (or a CI/CD job) only need to supply a few values. ## Declaring properties A forma that `extends "@formae/forma.pkl"` declares its properties as a plain typed class: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} extends "@formae/forma.pkl" import "@formae/formae.pkl" properties: Props class Props { /// The team that owns this service team: String /// Database size, as a t-shirt size size: String = "xs" } ``` Each member of the class becomes a property: * **The member name is the CLI flag.** `team` becomes `--team`, `size` becomes `--size`. Override this with [`@formae.Flag`](#override-the-flag-name) when the two should differ. * **A member with a default is optional; a member without one is required.** Here `size` defaults to `"xs"`, and `team` must be supplied. * **The member's type validates the input.** A value that does not fit the declared type (or its constraints, below) is rejected before anything is applied. Declaring properties as a typed class requires **`extends "@formae/forma.pkl"`**, not `amends`. `extends` opens the forma module so property values can be injected into it; `amends` keeps working for the [legacy block form](#legacy-properties-block) below. ### Reading properties Read a property directly by its member name, with the right static type: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local _vpc = new vpc_resources.VpcResources { team = properties.team } local _database = new database_resources.DatabaseResources { team = properties.team size = properties.size vpc = _vpc.vpc } ``` `properties.team` *is* the `String`; there is no `.value` to unwrap. Because the shape is typed, editors resolve `properties.team` instead of flagging it as an unresolved reference, so the property reads have full completion and type checking. ### Override the flag name By default the member name is the flag, so a camelCase member like `certArn` would be exposed as `--certArn`. Annotate the member with `@formae.Flag` to decouple the two, keeping the typed member name while choosing the flag a consumer passes: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} class Props { /// ARN of the ACM certificate @formae.Flag { name = "cert-arn" } certArn: String = "" } ``` The CLI flag is now `--cert-arn` (and `-p cert-arn=...`), while the forma still reads the member as `properties.certArn`, with full editor resolution: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new listener.Listener { certificateArn = properties.certArn } ``` `--help` and the property manifest show the overridden flag (`cert-arn`); the member name (`certArn`) is unchanged. ## Constraining the properties Constrain a member with a Pkl type so a bad input fails before anything is applied rather than mid-deploy: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} class Props { /// Owning team team: "team-a" | "team-b" /// Database size size: String(matches(Regex(#"(?i)^(XS|S)$"#))) = "xs" /// Port to listen on port: Int(this > 0) = 8080 } ``` `team` is a closed enumeration: only `"team-a"` or `"team-b"` type-check. `size` takes a pattern constraint, useful when you want to validate shape rather than enumerate every legal value. `port` constrains a number: `--port -5` fails the `Int(this > 0)` constraint at evaluation time. Either way, an input that doesn't satisfy the constraint stops the apply before any cloud call. ## Setting the properties A consumer, whether a developer or a CI/CD job, only needs to supply properties when running `apply`. They don't need to understand the forma itself. To see the properties a forma exposes, pass it to `--help`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --help database.pkl ``` ``` ... Properties: --size property: size [default: "xs"] --team property: team [required] ``` Then supply them as flags: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --team team-a --size s database.pkl ``` See [Write your first forma](/documentation/get-started/write-your-first-forma) for a worked example of turning a hardcoded value into a property end to end. ## Legacy properties block A forma that `amends "@formae/forma.pkl"` declares properties as a `properties {}` block of `formae.Prop` objects, read through `.value`. This form still works and needs no migration: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" properties { team = new formae.Prop { flag = "team" } size = new formae.Prop { flag = "size" default = "xs" } } local _database = new database_resources.DatabaseResources { team = properties.team.value size = properties.size.value } ``` Each property's `flag` becomes a `--flag` option, and a property without a `default` is required, exactly as in the typed form. The difference is ergonomics: the block form reads values with `.value` and is not statically typed, so editors cannot resolve `properties.team` and constraints live in separate `typealias`/`function` definitions rather than on the member itself. Prefer the typed `extends` form for new formae. ## See also * [Write your first forma](/documentation/get-started/write-your-first-forma): a hands-on walkthrough that adds a property to a real bucket. * [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure): expose a parameterized forma as a developer-facing interface. * [Apply modes](/documentation/concepts/apply-modes): reconcile vs. patch, and how properties interact with each. # Resolvable Source: https://docs.formae.io/documentation/concepts/resolvable How you reference another resource's properties or a secret without knowing its value at write time. A resolvable is how you read a value off a resource before that value exists. You write `resource.res.property`, and formae fills in the real value at apply time, once the resource that produces it has actually been created. Resolvables exist for two things: 1. **Referencing properties of other resources** (a subnet needs a VPC ID, a database needs subnet IDs). 2. **Referencing secrets** without ever exposing their value. The examples below use `local` to bind a resource to a variable so it can be referenced later via `.res`. A `local` still has to be mentioned inside the `forma` block to actually be created. See [Write your first forma](/documentation/get-started/write-your-first-forma) for the full pattern. ## Why resolvables exist Infrastructure resources depend on each other, and the values they depend on often don't exist until the dependency is created (an AWS-generated ID, an ARN, a randomly generated password). Resolvables let you write that dependency declaratively, without manually ordering resources or copying values by hand. formae: * Detects when a referenced property is available. * Waits for it if the resource that produces it is still being created. * Injects the resolved value at the right point in the apply. This is what lets you write `vpc.res.vpcId` in a subnet's definition even though the VPC doesn't exist yet: formae works out that the subnet depends on the VPC, creates the VPC first, and substitutes the real ID. ## Referencing resource properties ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local vpc = new vpc.VPC { label = "main-vpc" cidrBlock = "10.0.0.0/16" } vpc local subnet1 = new subnet.Subnet { label = "subnet-1" vpcId = vpc.res.vpcId // Reference the VPC's ID using .res cidrBlock = "10.0.1.0/24" availabilityZone = "us-west-2a" } subnet1 new dbsubnetgroup.DBSubnetGroup { label = "db-subnet-group" dbSubnetGroupDescription = "Subnet group for RDS" subnetIds { subnet1.res.subnetId // Reference the subnet's ID } } ``` ## Referencing secrets A secret is created like any other resource, then referenced through `.res` the same way. Wrapping the value with `formae.value(...).opaque` keeps it out of logs, output, and CLI results while still letting other resources consume it. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Create a secret with a randomly generated password local dbSecret = new secret.Secret { label = "db-password" name = "my-db-password" description = "Database password secret" secretString = formae.value(random.password(12, false)).opaque.setOnce } dbSecret // Reference the secret in a database instance new dbinstance.DBInstance { label = "my-database" allocatedStorage = 20 dbInstanceClass = "db.t3.micro" engine = "postgres" masterUsername = "admin" masterUserPassword = dbSecret.res.secretString // Reference the secret using .res } ``` * `formae.value()` wraps the password. * `.opaque` marks it as a secret that is never displayed. * `.setOnce` generates it once and reuses that value on every subsequent apply. * `dbSecret.res.secretString` references the secret's value in the database configuration. See [Values](/documentation/concepts/values) for more on `.opaque` and `.setOnce`. ## Embedding resolvables in strings `.res` gives you a resolvable as a field's entire value. Sometimes you need to splice a resolved value into the *middle* of a larger string, for example, a value that only exists after another resource is created, but has to appear inside a code block or config template. `formae.embed(...)` does this: write the surrounding text as a string and interpolate any resolvable with `\(...)`. formae resolves each reference at apply time and substitutes the real value into the text. Both resources still apply in a single pass, the referenced resource is ordered first automatically. A CloudFront Function whose JavaScript needs the generated ID of a Key Value Store is the canonical case. Without embedding you would apply the store, copy its ID by hand, and re-apply the function. With it, one apply does both: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@aws/cloudfront/cffunction.pkl" as cffn import "@aws/cloudfront/keyvaluestore.pkl" as kvsmod // A Key Value Store's Id is generated by AWS at create time, so it is not // known until after the store exists. local store = new kvsmod.KeyValueStore { label = "feature-flags" name = "feature-flags" } store new cffn.Function { label = "rewrite" name = "rewrite" autoPublish = true functionConfig = new cffn.FunctionConfig { runtime = "cloudfront-js-2.0" comment = "Reads from the key value store" keyValueStoreAssociations = new Listing { new cffn.KeyValueStoreAssociation { keyValueStoreARN = store.res.arn } } } // The store's Id is embedded directly in the function source. functionCode = formae.embed(""" import cf from 'cloudfront'; const kvsId = '\(store.res.id)'; async function handler(event) { const kv = cf.kvs(kvsId); return event.request; } """) } ``` At apply time, `\(store.res.id)` becomes the real Key Value Store ID (for example `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d`), and the function is created with that value already in its source. A few things to know: * **A field has to opt in to accept embeds.** The plugin's schema decides whether a field can be embedded. If a field rejects `formae.embed(...)`, reference the whole value with `.res` instead. * **`formae extract` round-trips embeds.** Extracting a managed resource regenerates the `formae.embed("…\(…)…")` call rather than the resolved value, so re-applying is a no-op and the reference is never flattened to a literal. * **You can embed more than one reference** in the same string, and mix them with `formae.value(...)` secrets exactly as in any other field. ## Resolvables in targets Resolvables aren't limited to resource properties, they can also appear in [target configurations](/documentation/concepts/target#target-resolvables). This enables cross-plugin patterns where one plugin provides infrastructure and another plugin's target resolves its connection details from it: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Compose stack exposes endpoints as a Mapping local lgtmStack = new compose.Stack { label = "lgtm" projectName = "formae-observability" composeFile = "..." } // Grafana target resolves a specific endpoint using at() new formae.Target { label = "grafana" namespace = "GRAFANA" config = new grafana.Config { url = lgtmStack.res.endpoints.at("lgtm:3000") } } ``` The `at()` method indexes into the `endpoints` Mapping by key: formae resolves it to the actual URL (for example `http://localhost:3000`) at apply time. See [Target resolvables](/documentation/concepts/target#target-resolvables) for the full pattern. ## Collection resolvables When a resource property resolves to a collection (Mapping or Listing), use `at()` to reference individual items: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Map key access, Docker Compose endpoints url = composeStack.res.endpoints.at("grafana:3000") // List index + field access, OVH database endpoints uri = dbService.res.endpoints.at(0).uri ``` See the Collection Resolvables section of the plugin SDK docs for details on defining these in your schemas. ## See also * [Values](/documentation/concepts/values): the `.opaque` and `.setOnce` modifiers used with secrets. * [Target](/documentation/concepts/target): target resolvables and cross-plugin connection resolution. * [Properties](/documentation/concepts/properties): the CLI-facing counterpart to resolvables. # Resources Source: https://docs.formae.io/documentation/concepts/resources The infrastructure formae manages, and the difference between managed and unmanaged resources. A resource is an infrastructure component: a bucket, a compute instance, a database, or any other cloud service. Resources are what formae ultimately creates, updates, and deletes on your behalf. Every resource formae knows about is either **managed** or **unmanaged**. The distinction is simply whether formae controls the resource or only observes it. ## Managed resources A resource is **managed** once formae controls it. When you [apply](/documentation/reference/cli) a forma, formae reconciles each declared resource so its actual state matches what the forma says it should be. A resource becomes managed in one of two ways: * You declare it in a forma and apply it. * An existing resource is [discovered](/documentation/concepts/discovery) and you bring it under management. Managed resources can reference one another with `.res`, which declares a dependency: formae waits for the referenced value to exist before applying the resource that needs it, so you never order resources by hand. See [Resolvable](/documentation/concepts/resolvable) for how this works. ## Unmanaged resources An **unmanaged** resource exists in your cloud but was not created through formae. formae tracks it so you have visibility, but does not modify it. Unmanaged resources are **read-only**: formae will not update or delete them until you choose to manage them. Unmanaged resources are surfaced by [Discovery](/documentation/concepts/discovery), which periodically scans your cloud accounts for resources that are not yet in formae's inventory. You can then decide which ones to bring under management. ## Why the distinction exists Keeping unmanaged resources visible without touching them lets formae fit into an environment you already run: * Adopt formae gradually in an existing account. * Operate alongside Terraform, Pulumi, or other tools. * Keep visibility across everything, including resources you have not chosen to manage. * Transition resources to managed selectively, on your own timeline. Discovery identifies resources; you decide which ones to manage. ## See also * [Discovery](/documentation/concepts/discovery): how formae finds resources that were not created through it. * [Resolvable](/documentation/concepts/resolvable): the `.res` mechanism behind resource references. * [Stack](/documentation/concepts/stack): how resources are grouped for lifecycle management. # Stack Source: https://docs.formae.io/documentation/concepts/stack formae's unit of lifecycle: the resources you create, reconcile, and destroy together. A stack is a collection of resources you choose to manage together. It is formae's unit of lifecycle: reconciling a stack is what lets formae remove resources that are no longer declared, and destroying a stack tears down everything in it. A stack is *not* a boundary for references. Resources in one stack can reference resources in another through [resolvables](/documentation/concepts/resolvable). The stack is about lifecycle, not about isolating dependencies. You never create a stack up front. When you apply a forma, formae looks for a stack by label and creates it if it doesn't exist yet: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "production-api" description = "Production API infrastructure" } ``` Every resource in that forma is assigned to the stack automatically. A stack cleans up after itself. When its last resource is destroyed, formae deletes the empty stack for you; you never remove one by hand. ## One stack per resource Each resource belongs to exactly one stack. A single owner is what keeps lifecycle and state tracking unambiguous, and it stops two stacks from fighting over the same resource. Decide which stack a resource lives in when you design your infrastructure. ## Designing stack boundaries Because the stack is the unit of lifecycle, its boundaries decide what gets deployed, reconciled, and destroyed together. Common ways to draw them: * **By environment**: `dev`, `staging`, `production` * **By application or service**: `user-service`, `payments`, `analytics` * **By layer**: `networking`, `databases`, `compute` Smaller stacks give you finer-grained control; larger stacks keep related resources together. Policies attach at the **stack** level. A [TTL](/documentation/concepts/policies/ttl) or [auto-reconcile](/documentation/concepts/policies/auto-reconcile) policy governs the whole stack, so a resource inherits whatever policy its stack carries. Factor this into where you draw boundaries: if two resources need different lifecycle policies, they belong in different stacks. (Resource-level policies are coming, which will relax this.) ## Version control formae tracks resources and their lifecycle at the stack level. You can extract any managed stack in your preferred format for version control, backup, or reuse in another environment. ## See also * [Forma](/documentation/concepts/forma): the declaration that creates and updates stacks. * [Target](/documentation/concepts/target): where a stack's resources are applied. * [Apply modes](/documentation/concepts/apply-modes): how reconcile uses stack boundaries to decide what to remove. * [Policy](/documentation/concepts/policy): TTL and auto-reconcile policies, which are scoped to a stack. # Synchronization Source: https://docs.formae.io/documentation/concepts/synchronization How formae keeps its recorded state current when a resource changes outside of formae. formae periodically checks the resources it already knows about against the cloud, so its state never goes stale, no matter who else touches your infrastructure. ## How synchronization works Synchronization is enabled by default (every 5 minutes). On each run, formae reads the current state of every resource it knows about, managed and unmanaged alike, and compares it against what's recorded. If something changed outside formae, through the cloud console, another IaC tool, or a script, formae updates its own state to match immediately. This does not touch your forma code or your cloud resources; formae just stops being stale. What happens on your next `apply` depends on the mode: [soft reconcile](/documentation/concepts/apply-modes#hard-vs-soft-reconcile) (the default) rejects the apply so you can review the drift, and `--force` (hard reconcile) overwrites it with your declared state. See [Apply modes](/documentation/concepts/apply-modes#hard-vs-soft-reconcile) for the full mechanism. Synchronization is what lets you: * Use formae alongside other tools without conflicts * Make emergency changes through the cloud console when you need to * Collaborate with teammates using different workflows * Always have an accurate view of your infrastructure, regardless of how it was last touched ## Drift on cloud-defaulted properties Some properties are filled in by the cloud when your forma does not declare them: a bucket's default encryption, a key's rotation setting, an engine version the service picks for you. Leaving such a property out of your forma can be a deliberate choice to rely on that default, so formae defends the default it observed when it created or last updated the resource the same way it defends a value you declared. What happens to an out-of-band change on such a property depends on whether formae's own write produced the value that moved: * **The value comes from formae's write.** When formae creates or updates a resource, it records what the cloud returned, including the defaults filled into properties your forma omits. A later out-of-band change to one of those values is ordinary drift: a soft reconcile (and a simulate) is rejected showing the change, and a forced reconcile reverts the property to the recorded value. To accept the new value instead, declare the property in your forma. * **The value appeared on its own.** Properties the cloud or another system populates after formae's write, such as targets another service registers into a load balancer at runtime, are not treated as drift. They stay visible in `formae drift`, but they never reject an apply and `--force` never reverts them. ## Configuration Synchronization is on by default. Tune the interval, or turn it off, in your [agent configuration](/documentation/reference/configuration#synchronization): ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} agent { synchronization { enabled = true interval = 5.min // How often to check for changes } } ``` ## Use cases * **Emergency fixes.** Make a quick change through the cloud console during an incident. formae picks it up on the next sync, so your recorded state stays current even if your forma code hasn't changed yet. * **Multi-tool environments.** If your team uses more than one tool to manage infrastructure, synchronization keeps formae's view accurate regardless of what made the change. * **Gradual adoption.** Keep using your existing tools while formae tracks everything in the background, building a complete, current view of your infrastructure as you migrate. Synchronization works hand in hand with [discovery](/documentation/concepts/discovery). Discovery finds resources formae doesn't know about yet; synchronization keeps everything formae already knows about current. ## See also * [Discovery](/documentation/concepts/discovery): how formae finds resources it doesn't manage yet. * [Apply modes](/documentation/concepts/apply-modes): soft vs. hard reconcile, and how out-of-band changes affect an apply. # Target Source: https://docs.formae.io/documentation/concepts/target Where formae creates, manages, and discovers resources: a cloud account, region, or environment. A target defines *where* resources live: the cloud account, region, or environment formae creates and manages them in, and where it discovers existing ones. Every forma needs at least one target. **One agent per target.** A target should only ever be managed by a single formae agent. Running multiple agents against the same target produces conflicting state and unpredictable behaviour. See [Architecture](/documentation/concepts/architecture). ## One target or many If a forma defines exactly one target, every resource in it belongs to that target automatically. You don't set a target on each resource: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} forma { new formae.Target { label = "aws-uw2" config = new aws.Config { region = "us-west-2" } } // No target set: this bucket uses the only target in the forma. new bucket.Bucket { label = "assets" bucketName = "my-assets" } } ``` If a forma defines more than one target, the assignment is no longer implied. Each resource must declare which target it belongs to: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} forma { local uw2 = new formae.Target { label = "aws-uw2" config = new aws.Config { region = "us-west-2" } } uw2 local ue1 = new formae.Target { label = "aws-ue1" config = new aws.Config { region = "us-east-1" } } ue1 new bucket.Bucket { label = "assets" bucketName = "my-assets" target = uw2.res } new bucket.Bucket { label = "certs" bucketName = "my-certs" target = ue1.res } } ``` ## Dynamic target config Like a resource, a target's config fields don't have to be hardcoded. They can be driven by a [property](/documentation/concepts/properties), for example a region you set on the command line: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Target { label = "aws" config = new aws.Config { region = properties.region // set with --region on the CLI } } ``` ...or by a [resolvable](/documentation/concepts/resolvable#resolvables-in-targets), which reads a value from another resource at apply time: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Target { label = "grafana" config = new grafana.Config { url = lgtmStack.res.endpoints.at("lgtm:3000") // resolved from a Compose stack } } ``` See [Resolvables in targets](/documentation/concepts/resolvable#resolvables-in-targets) for the full cross-plugin pattern. ## Discovery Discovery scans your targets for resources formae doesn't manage yet. The `discoverable` field controls whether a target is scanned; targets are discoverable by default. Set `discoverable = false` on any target you want excluded: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Target { label = "staging-uw2" discoverable = false config = new aws.Config { region = "us-west-2" } } ``` For the workflow of registering a target and pulling in what already runs on it, see [Bring resources under management](/documentation/guides/bring-resources-under-management). Applying a target-only forma (just targets, no resources) with `--mode reconcile` is safe. Reconcile only affects managed resources within a stack, and a target-only forma has no stack, so there is nothing to remove. ## Unreachable targets If the agent cannot reach a target for a sustained period, formae eventually cleans up that target's discovered (unmanaged) resources from inventory so they don't linger as stale entries. This is an inventory cleanup only: formae never deletes anything in the cloud, never touches resources under formae management, and never acts on a target with intermittent connectivity. Re-applying the target brings its inventory back. By default a target is cleaned up after it has been continuously unreachable for 24 hours. Set the `reap` field to change that window: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Target { label = "batch-eu" config = new aws.Config { region = "eu-west-1" } reap = new formae.ReapAfter { maxUnreachable = 72.h } } ``` To keep a target no matter how long it stays unreachable, never cleaning it up: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} reap = new formae.NeverReap {} ``` ## Replacing a target Most target config changes update the target in place. Some config is immutable, though: changing it (switching region, changing a connection endpoint) triggers a **target replace**. formae deletes the target and every managed resource on it, then recreates them against the new config. Whether a resource survives that automatically depends on the resource: * **Portable** resources can be recreated on the new target. * **Non-portable** resources cannot. An EC2 instance, for example, may use a region-bound AMI that doesn't exist in the new region. If a target replace would hit non-portable managed resources, formae **rejects the operation** rather than break them: ``` Cannot replace target 'aws-uw2' The following resources are bound to the current target configuration and cannot be automatically moved to the new target: - my-stack/AWS::EC2::Instance/web To proceed, manually remove these resources first with 'formae destroy', then reapply to recreate them with the new target configuration. ``` Run `formae apply --simulate` first: it shows which config fields changed and whether each change updates in place or forces a replace. Which config fields update in place versus force a replace is decided by the plugin, not by you. As a user, it's enough to know that some target config updates in place and some forces a replace, and that `--simulate` tells you which. Plugin authors: see [Plugin development](/plugin-development). ## Deleting a target Destroy a target-only forma (just targets, no resources) to remove the declared targets: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy target.pkl ``` Deleting a target also **cascade-deletes every managed resource on it** from the cloud. Because that blast radius can be large, the CLI guards it: with `--yes`, a destroy that would cascade is aborted unless you also pass `--on-dependents=cascade`. Run interactively (without `--yes`) and formae shows what will be cascaded and asks you to confirm. Deleting a target removes its **unmanaged** resources from formae's inventory, but never from your cloud. formae only destroys resources it manages, so discovered resources you never brought under management stay exactly where they are; formae simply stops tracking them once their target is gone. ### Targets during a full destroy When you destroy a forma that has both targets and resources, formae keeps plain targets (a cloud region target, say), because they exist independently of the resources. Only a target whose config *depends on* a resource being destroyed, through a resolvable reference, is deleted automatically. To remove a plain target, destroy it separately with its own target-only forma. ## Sharing targets Keep shared targets in one file and import them wherever you need them, so target config stays consistent across your formae. This is how our own infrastructure is organised: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // vars.pkl awsProd: formae.Target = new formae.Target { label = "aws-prod-uw2" config = new aws.Config { region = "us-west-2" } } ``` ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "./vars.pkl" forma { vars.awsProd // resources on the shared target } ``` ## See also * [Resolvable](/documentation/concepts/resolvable): resolving target config from another resource. * [Discovery](/documentation/concepts/discovery): how targets enable discovery. * [Stack](/documentation/concepts/stack): how resources are grouped for lifecycle. * [Architecture](/documentation/concepts/architecture): one agent per target. # Values Source: https://docs.formae.io/documentation/concepts/values The .opaque and .setOnce modifiers that give a plain value secret-handling or stable-once-generated behavior. `formae.value()` wraps a regular value so you can attach extra behavior to it. Two modifiers are available: `.opaque` for secrets and `.setOnce` for values that should stay stable across applies. ## Opaque values `.opaque` marks a value as a secret that is never displayed to the user. Opaque values can still be referenced through `.res` and passed between resources, but they're never shown in logs, output, or CLI results. Use it for passwords, API keys, and other sensitive fields. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local dbSecret = new secret.Secret { label = "db-password" secretString = formae.value("my-secret-password").opaque } dbSecret ``` Other resources can still reference the value through `.res`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new dbinstance.DBInstance { label = "my-database" masterUserPassword = dbSecret.res.secretString // References the opaque value } ``` ## setOnce `.setOnce` generates or captures a value the first time it's applied, then keeps it constant on every later apply, even if the expression that produced it would otherwise evaluate to something different. This matters for anything generated randomly: without `.setOnce`, a random password would get a new value on every apply. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local dbSecret = new secret.Secret { label = "db-password" secretString = formae.value(random.id(16).toString()).setOnce } dbSecret ``` `random.id(16).toString()` generates a random 16-character identifier; `.setOnce` ensures the same value is reused on every subsequent apply of this forma. ## Combining opaque and setOnce The two modifiers chain, which is the common case for generated secrets: stable across applies and never shown. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} apiKey = formae.value(random.id(32).toString()).opaque.setOnce ``` This produces a secret API key that is generated once, never displayed, and never changes. ## See also * [Resolvable](/documentation/concepts/resolvable): how `.res` references, including opaque values, are consumed by other resources. # Ways to work with formae Source: https://docs.formae.io/documentation/concepts/ways-to-work Three operating models, from Git as the strict source of truth to formae as the system of record. formae supports three ways to work, depending on how much you want Git versus formae to be the source of truth for your infrastructure. They differ on two things: how strictly Git controls what is deployed, and whether [discovery](/documentation/concepts/discovery) and [synchronization](/documentation/concepts/synchronization) are enabled. All three use the same [apply modes](/documentation/concepts/apply-modes) (reconcile and patch) underneath. ## Classic GitOps Git is the single source of truth. Anything changed outside formae is rejected or overwritten on the next reconcile, so what is in your repository is exactly what runs in the cloud. Best for teams with strict change control and automated CI/CD pipelines, where every change should go through a pull request. ## Always up-to-date GitOps Extends Classic by turning on [discovery](/documentation/concepts/discovery) and [synchronization](/documentation/concepts/synchronization). Your code stays authoritative, but formae continuously tracks out-of-band changes so you can review them and decide whether to absorb them into your code or overwrite them. Best for mixed environments and gradual adoption, where not everything goes through formae yet and you want visibility into drift rather than hard rejection. ## No-Git GitOps formae itself is the system of record. You work with temporary forma files, extracting and applying at any granularity you like. Git becomes optional, and an up-to-date representation of your infrastructure can be restored from formae at any time. Best for exploratory or short-lived work where maintaining a repository would be overhead. ## How they relate The three models are about *where the source of truth lives*, not *how changes are applied*. Every model still uses the same two [apply modes](/documentation/concepts/apply-modes): * **Reconcile** makes the cloud match your forma exactly, removing anything not declared. * **Patch** applies only the changes you specify, leaving everything else alone. Moving from Classic to Always-up-to-date is a matter of enabling discovery and synchronization; moving to No-Git is a matter of how you treat your forma files, not a different apply mechanism. # Quick start Source: https://docs.formae.io/documentation/get-started/quickstart Install formae and deploy your first infrastructure to AWS, Azure, or GCP in about ten minutes. This guide takes you from zero to running infrastructure: install formae, start the agent, deploy a ready-made example to AWS, Azure, or GCP, verify it, and tear it back down. Prefer to work through an AI assistant? Follow the [Quick start with an AI assistant](/documentation/get-started/quickstart-ai-assistant) and describe what you want instead of writing Pkl by hand. Install the CLI and agent with one command: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" ``` Add the binary to your `PATH`: ```bash zsh theme={"languages":{"custom":["/languages/pkl.json"]}} echo 'export PATH=/opt/pel/bin:$PATH' >> ~/.zshrc source ~/.zshrc ``` ```bash bash theme={"languages":{"custom":["/languages/pkl.json"]}} echo 'export PATH=/opt/pel/bin:$PATH' >> ~/.bashrc source ~/.bashrc ``` ```fish fish theme={"languages":{"custom":["/languages/pkl.json"]}} fish_add_path /opt/pel/bin ``` Verify the install: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae --version ``` The agent runs the infrastructure operations and keeps state in sync with your cloud. Start it in its own terminal and leave it running: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent start ``` It prints its banner, confirms it has started, and registers the bundled providers:
formae v0.88.0
2026-07-20T16:52:21-07:00 INF Starting agent id=37MG2qUmBGgcOdFOg2kaHU2xk6P
2026-07-20T16:52:21-07:00 INF Agent started
2026-07-20T16:52:21-07:00 INF Plugin registered: namespace=AZURE node=formae-azure-plugin\@localhost rateLimit=10 resources=43 \[pid=\<8E428C64.0.1009>] \[name='PluginCoordinator'] \[behavior=plugin\_coordinator.PluginCoordinator]
2026-07-20T16:52:21-07:00 INF Plugin registered: namespace=GCP node=formae-gcp-plugin\@localhost rateLimit=10 resources=65 \[pid=\<8E428C64.0.1009>] \[name='PluginCoordinator'] \[behavior=plugin\_coordinator.PluginCoordinator]
2026-07-20T16:52:23-07:00 INF Plugin registered: namespace=AWS node=formae-aws-plugin\@localhost rateLimit=2 resources=239 \[pid=\<8E428C64.0.1009>] \[name='PluginCoordinator'] \[behavior=plugin\_coordinator.PluginCoordinator]
2026-07-20T16:52:28-07:00 INF Plugin registered: namespace=K8S node=formae-k8s-plugin\@localhost rateLimit=10 resources=575 \[pid=\<8E428C64.0.1009>] \[name='PluginCoordinator'] \[behavior=plugin\_coordinator.PluginCoordinator]
Leave it running in this terminal, and open a **second terminal** for the remaining steps.
formae ships runnable examples for each cloud. Copy one into a working directory, along with the `PklProject` files that declare its Pkl dependencies (the formae SDK and cloud schemas). Choose your cloud in the next step for the exact example. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} ls /opt/pel/formae/examples/ ``` **Prerequisites:** AWS credentials in your environment, for example via `aws configure` or `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. This example provisions a complete ECS stack: VPC and subnets, security groups, an ECS cluster, an Application Load Balancer, and a running service. Copy the example and its Pkl project files: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} mkdir -p ~/projects && cd ~/projects cp -R /opt/pel/formae/examples/aws/ecs-hello-world ./ cp /opt/pel/formae/examples/aws/PklProject* ./ecs-hello-world/ cd ecs-hello-world ``` Apply the example. formae shows you the plan and waits for your confirmation before it changes anything, then opens a live view of the run. The example exposes `--region` as a property, so you can pick a region without editing any code: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --region eu-central-1 ecs_hello_world.pkl ``` **Prerequisites:** Azure CLI signed in (`az login`) and an SSH public key. This example provisions an SSH-accessible Ubuntu 22.04 VM with its networking: resource group, virtual network, subnet, security group, public IP, and network interface. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export SSH_PUBLIC_KEY=$(cat ~/.ssh/id_rsa.pub) mkdir -p ~/projects && cd ~/projects cp -R /opt/pel/formae/examples/azure/virtual-machine ./ cp /opt/pel/formae/examples/azure/PklProject* ./virtual-machine/ cd virtual-machine ``` Set your `subscriptionId` in `vars.pkl`, then apply. formae shows the plan and asks you to confirm before making changes, then opens a live view: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl ``` **Prerequisites:** GCP credentials exported as `GCP_CREDENTIALS_FILE` (path to a service account key) or `GCP_CREDENTIALS_JSON` (the key itself). This example provisions a global external HTTP(S) load balancer. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export GCP_CREDENTIALS_FILE=~/.config/gcloud/my-service-account.json mkdir -p ~/projects && cd ~/projects cp -R /opt/pel/formae/examples/gcp/gcp-loadbalancer ./ cp /opt/pel/formae/examples/gcp/PklProject* ./gcp-loadbalancer/ cd gcp-loadbalancer ``` Open `gcp_loadbalancer.pkl` and set `Project` near the top of the file to your GCP project ID: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local Project = "your-project-id" ``` Apply the change. formae shows the plan and asks you to confirm before making changes, then opens a live view: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile gcp_loadbalancer.pkl ``` `apply` runs for real, so formae first shows you the plan and asks you to confirm. Press `y` to continue, or `q` to abort. This is the AWS `ecs-hello-world` plan:
  formae apply · reconcile                                                                       ecs\_hello\_world.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 22 create

  ▌ Targets
  Operation ▲   Label
  + create      default-aws-target

  ▌ Stacks
  Operation ▲   Label
  + create      ecs-hello-world

  ▌ Resources
  Operation ▲   Label                                                       Type
  + create      ecs-vpc                                                     AWS::EC2::VPC
  + create      ecs-igw                                                     AWS::EC2::InternetGateway
  + create      ecs-igw-attachment                                          AWS::EC2::VPCGatewayAttachment
  + create      ecs-public-subnet-1                                         AWS::EC2::Subnet
  + create      ecs-public-subnet-2                                         AWS::EC2::Subnet
  + create      ecs-public-rt                                               AWS::EC2::RouteTable
  + create      ecs-public-route                                            AWS::EC2::Route
  + create      ecs-public-subnet-1-assoc                                   AWS::EC2::SubnetRouteTableAssociation
  + create      ecs-public-subnet-2-assoc                                   AWS::EC2::SubnetRouteTableAssociation
  + create      ecs-alb-sg                                                  AWS::EC2::SecurityGroup
      ↓ show 10 more (10 remaining)

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1 stack(s), create
1 target(s) and create 20 resource(s).  Do you want to continue? (y/N)  ?: help
Once you confirm, formae opens a live view of the run. Press `q` to leave the view at any time; the operation keeps running on the agent. To re-attach to it, run: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status command ``` When the run finishes, every resource is applied:
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
✓    3Gmw76N43M4ysS54phFL3naR57p apply     reconcile   completed 22/22                  22   0    0    0    04:19
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Targets
        Label ▲                                                                                   Operation   Time
       default-aws-target                                                                        create      00:00

  ▌ Stacks
        Label ▲                                                                                   Operation   Time
       ecs-hello-world                                                                           create      00:00

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       ecs-alb                                               AWS::ElasticLoadBalancingV2::Load…  create      02:45
       ecs-alb-sg                                            AWS::EC2::SecurityGroup             create      00:22
       ecs-alb-sg-ingress                                    AWS::EC2::SecurityGroupIngress      create      00:22
       ecs-hello-cluster2                                    AWS::ECS::Cluster                   create      00:23
       ecs-hello-task-def                                    AWS::ECS::TaskDefinition            create      00:24
       ecs-hello-world-cluster                               AWS::ECS::Cluster                   create      00:21
       ecs-igw                                               AWS::EC2::InternetGateway           create      00:23
       ecs-igw-attachment                                    AWS::EC2::VPCGatewayAttachment      create      00:22
       ecs-listener                                          AWS::ElasticLoadBalancingV2::List…  create      00:23
       ecs-public-route                                      AWS::EC2::Route                     create      00:02
       ecs-public-rt                                         AWS::EC2::RouteTable                create      00:21
       ecs-public-subnet-1                                   AWS::EC2::Subnet                    create      00:22
       ecs-public-subnet-1-assoc                             AWS::EC2::SubnetRouteTableAssocia…  create      00:24
       ecs-public-subnet-2                                   AWS::EC2::Subnet                    create      00:21
       ecs-public-subnet-2-assoc                             AWS::EC2::SubnetRouteTableAssocia…  create      00:23
       ecs-task-exec-role                                    AWS::IAM::Role                      create      00:22
       ecs-task-sg                                           AWS::EC2::SecurityGroup             create      00:21
       ecs-task-sg-ingress                                   AWS::EC2::SecurityGroupIngress      create      00:22
       ecs-tg                                                AWS::ElasticLoadBalancingV2::Targ…  create      00:21
       ecs-vpc                                               AWS::EC2::VPC                       create      00:23

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  →←: column  s: toggle sort  space: expand  d: details  q: quit                                           ?: help
`--mode` is required on `apply`. Learn the difference between reconcile and patch in [Apply modes](/documentation/concepts/apply-modes).
Confirm formae created the resources. Filter the inventory by the stack you just deployed: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query "stack:ecs-hello-world" ``` For the AWS example, that lists the stack it just deployed:
  formae inventory
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 ╭─────────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─╯             ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Label ▲                           Stack                  Type                                   NativeID                                      
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
ecs-alb                           ecs-hello-world        AWS::ElasticLoadBalancingV2::LoadBalan…arn:aws:elasticloadbalancing:eu-central-1:897…
ecs-alb-sg                        ecs-hello-world        AWS::EC2::SecurityGroup                sg-0ce41bacf1d67b147                          
ecs-alb-sg-ingress                ecs-hello-world        AWS::EC2::SecurityGroupIngress         sgr-0fbb692f2f8d5636d                         
ecs-hello-cluster2                ecs-hello-world        AWS::ECS::Cluster                      ecs-hello-world-cluster2                      
ecs-hello-task-def                ecs-hello-world        AWS::ECS::TaskDefinition               arn:aws:ecs:eu-central-1:897722706215:task-de…
ecs-hello-world-cluster           ecs-hello-world        AWS::ECS::Cluster                      ecs-hello-world-cluster                       
ecs-igw                           ecs-hello-world        AWS::EC2::InternetGateway              igw-047bab59003537498                         
ecs-igw-attachment                ecs-hello-world        AWS::EC2::VPCGatewayAttachment         IGW|vpc-02e1b0b73a5730d65                     
ecs-listener                      ecs-hello-world        AWS::ElasticLoadBalancingV2::Listener  arn:aws:elasticloadbalancing:eu-central-1:897…
ecs-public-route                  ecs-hello-world        AWS::EC2::Route                        rtb-0bab9fbbf4c0010e5|0.0.0.0/0|GatewayId=igw…
ecs-public-rt                     ecs-hello-world        AWS::EC2::RouteTable                   rtb-0bab9fbbf4c0010e5                         
ecs-public-subnet-1               ecs-hello-world        AWS::EC2::Subnet                       subnet-0b38a309934e06276                      
ecs-public-subnet-1-assoc         ecs-hello-world        AWS::EC2::SubnetRouteTableAssociation  rtbassoc-013308e76c96ade7c                    
ecs-public-subnet-2               ecs-hello-world        AWS::EC2::Subnet                       subnet-08999cc2f4fb40e87                      
ecs-public-subnet-2-assoc         ecs-hello-world        AWS::EC2::SubnetRouteTableAssociation  rtbassoc-0d4512b6e49506eba                    
ecs-task-exec-role                ecs-hello-world        AWS::IAM::Role                         6cfPoszejP6KRJElRijPynvA8-SMXNaMlk1MfC        
ecs-task-sg                       ecs-hello-world        AWS::EC2::SecurityGroup                sg-075d33812aa776116                          
ecs-task-sg-ingress               ecs-hello-world        AWS::EC2::SecurityGroupIngress         sgr-068746b2de1bad0c0                         
ecs-tg                            ecs-hello-world        AWS::ElasticLoadBalancingV2::TargetGro…arn:aws:elasticloadbalancing:eu-central-1:897…
Showing 20 of 20 resources (filtered)
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  / stack:ecs-hello-world                                                                                                              /: edit query
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                                                         ?: help
Each example uses its own stack name. You can also check your cloud's web console to see the created resources.
To avoid ongoing charges, destroy what you created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy ecs_hello_world.pkl ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy main.pkl ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy gcp_loadbalancer.pkl ``` `destroy` shows a plan and asks you to confirm too; press `y` to proceed. The completed teardown looks like this:
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
✓    3Gmwiy52PNdn5rJiu3nWhLDPxJf destroy   -           completed 20/20                  20   0    0    0    02:35
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       ecs-alb                                               AWS::ElasticLoadBalancingV2::Load…  delete      01:02
       ecs-alb-sg                                            AWS::EC2::SecurityGroup             delete      00:21
       ecs-alb-sg-ingress                                    AWS::EC2::SecurityGroupIngress      delete      00:21
       ecs-hello-cluster2                                    AWS::ECS::Cluster                   delete      00:21
       ecs-hello-task-def                                    AWS::ECS::TaskDefinition            delete      00:21
       ecs-hello-world-cluster                               AWS::ECS::Cluster                   delete      00:21
       ecs-igw                                               AWS::EC2::InternetGateway           delete      00:21
       ecs-igw-attachment                                    AWS::EC2::VPCGatewayAttachment      delete      01:02
       ecs-listener                                          AWS::ElasticLoadBalancingV2::List…  delete      00:22
       ecs-public-route                                      AWS::EC2::Route                     delete      00:01
       ecs-public-rt                                         AWS::EC2::RouteTable                delete      00:21
       ecs-public-subnet-1                                   AWS::EC2::Subnet                    delete      00:22
       ecs-public-subnet-1-assoc                             AWS::EC2::SubnetRouteTableAssocia…  delete      00:21
       ecs-public-subnet-2                                   AWS::EC2::Subnet                    delete      00:21
       ecs-public-subnet-2-assoc                             AWS::EC2::SubnetRouteTableAssocia…  delete      00:21
       ecs-task-exec-role                                    AWS::IAM::Role                      delete      00:21
       ecs-task-sg                                           AWS::EC2::SecurityGroup             delete      00:21
       ecs-task-sg-ingress                                   AWS::EC2::SecurityGroupIngress      delete      00:21
       ecs-tg                                                AWS::ElasticLoadBalancingV2::Targ…  delete      00:21
       ecs-vpc                                               AWS::EC2::VPC                       delete      00:22

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  →←: column  s: toggle sort  space: expand  d: details  esc: back  q: quit                                ?: help
`destroy` takes no `--mode`. It removes every resource in the forma, so there is nothing for reconcile or patch to differ on.
## What's next? Build a forma from scratch and learn how properties and resource references work. Deploy by describing what you want to Claude Code, Codex, or another MCP client. Understand labels, resources, stacks, targets, and formas. Set up your editor for Pkl authoring. See how the agent, CLI, and plugins fit together. # Quick start with an AI assistant Source: https://docs.formae.io/documentation/get-started/quickstart-ai-assistant Deploy your first infrastructure by describing what you want to an AI coding assistant. This is the conversational path into formae. Instead of writing Pkl and running CLI commands yourself, you describe what you want and your assistant does the work: picking plugins, writing the forma, simulating, and applying. You will install formae, connect your assistant, deploy something real, and tear it down. It works the same on AWS, Azure, or GCP, because you tell the assistant which cloud you are on. Prefer to drive the CLI yourself? Follow the [Quick start](/documentation/get-started/quickstart) instead. Both end in the same place. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" ``` Add the binary to your `PATH`: ```bash zsh theme={"languages":{"custom":["/languages/pkl.json"]}} echo 'export PATH=/opt/pel/bin:$PATH' >> ~/.zshrc source ~/.zshrc ``` ```bash bash theme={"languages":{"custom":["/languages/pkl.json"]}} echo 'export PATH=/opt/pel/bin:$PATH' >> ~/.bashrc source ~/.bashrc ``` ```fish fish theme={"languages":{"custom":["/languages/pkl.json"]}} fish_add_path /opt/pel/bin ``` Verify with `formae --version`. Your assistant talks to the agent, so it needs to be running. Start it in its own terminal and leave it there: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent start ``` Install the formae MCP server and skills in your client. Claude Code is a one-liner; other clients need the server registered manually. ```text theme={"languages":{"custom":["/languages/pkl.json"]}} /plugin marketplace add platform-engineering-labs/formae-marketplace /plugin install formae@formae-marketplace ``` For Codex, OpenCode, Cursor, and other MCP clients, follow the [installation instructions](/documentation/guides/ai-coding-assistants#install). **Check the connection.** Ask your assistant: > Is my formae agent healthy? It should report live agent status. If it errors, the MCP server is not registered: see the setup guide above. formae uses your existing cloud credentials. Set up whichever cloud you are deploying to: `aws configure`, or export `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. `az login` Export `GCP_CREDENTIALS_FILE` (path to a service account key) or `GCP_CREDENTIALS_JSON` (the key itself). Now just ask. Name your cloud and what you want to deploy: > I want to deploy an S3 bucket to AWS in us-east-1 with formae. Your assistant uses the `formae-author` skill to work out which plugins you need, pull version-matched examples, write the forma file, and design the stack. It simulates the change and shows you what will be created before anything happens. Review the simulation, then tell it to go ahead: > Looks good, apply it. Your assistant always simulates before applying and asks before anything destructive. That behaviour is built into the skills, not something you have to remember to ask for. Ask what you now have: > What resources do I have under management? It calls `list_resources` against the agent and shows you what formae is tracking. You can cross-check in your cloud console. > Destroy the stack you just created. Your assistant confirms first, then tears it down. ## What you just did You never opened a Pkl file, but you now have a real forma in your project that you own and can edit. The assistant is not a black box: it wrote IaC code you can read, commit, and apply yourself with `formae apply`. ## What's next Build a forma by hand to understand what your assistant generated. All 19 skills, all 31 tools, and setup for every MCP client. Stacks, targets, resources, and formas. Ask your assistant to absorb out-of-band changes back into your code. # Write your first forma Source: https://docs.formae.io/documentation/get-started/write-your-first-forma Build a forma from scratch: a stack, a target, resources, references, and properties. In the [Quick start](/documentation/get-started/quickstart) you deployed an example someone else wrote. Now you will write one yourself, from an empty directory to running infrastructure. You will create an S3 bucket, put a file in it that references the bucket, and then make the whole thing configurable from the command line. This tutorial uses AWS S3 because a bucket is the simplest resource to reason about. Every structure you learn here (stack, target, resources, references, properties) is identical on Azure, GCP, and every other cloud. Only the resource types change. ## Before you start * formae installed and on your `PATH` * The agent running in another terminal (`formae agent start`) * AWS credentials in your environment If any of that is missing, work through the [Quick start](/documentation/get-started/quickstart) first. A forma project is a directory with the Pkl dependencies it needs. Scaffold one with the AWS schema included: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} mkdir -p ~/projects && cd ~/projects formae project init --include aws first-forma cd first-forma ``` This creates three files: | File | What it does | | ---------------------- | ---------------------------------------------------------------------- | | `PklProject` | Declares your package dependencies (the formae SDK and the AWS schema) | | `PklProject.deps.json` | The resolved dependency lock file | | `main.pkl` | A starter forma for you to replace | Open `main.pkl` and replace its contents with this: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} extends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/s3/bucket.pkl" forma { new formae.Stack { label = "first-forma" description = "My first formae stack" } new formae.Target { label = "aws-target" config = new aws.Config { region = "us-east-1" } } new bucket.Bucket { label = "my-first-bucket" bucketName = "formae-first-forma-CHANGE-ME" } } ``` S3 bucket names are globally unique across all of AWS. Replace `CHANGE-ME` with something nobody else will have used. You will fix this properly with a property in a later step. Four things are happening here: * **`extends "@formae/forma.pkl"`** tells Pkl this file is a forma. This is what `formae project init` generates; it also lets you add typed [properties](/documentation/concepts/properties) later. (The older `amends "@formae/forma.pkl"` form still works.) * **The `forma` block** holds everything formae will manage. * **`formae.Stack`** groups your resources. Destroying the stack destroys everything in it, so a stack is what you create and tear down as a whole, not just a folder for organizing files. * **`formae.Target`** says *where* resources go: which cloud account and region. Every resource takes a **`label`**, which is how you refer to it inside formae. It is not the name of the thing in your cloud. `bucketName` is the real S3 name. Never guess at what a forma will do. Run a dry run with `--simulate`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --simulate main.pkl ``` This shows exactly what would be created, changed, or destroyed without touching your cloud. If a Pkl expression is wrong, you find out here rather than halfway through a real apply. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl ``` `--mode reconcile` means "make my cloud match this file exactly". On a terminal, `apply` shows a live progress view until it finishes; press `q` to detach at any time and it keeps running on the agent. Confirm formae is managing the bucket: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources ``` Real infrastructure is connected: a subnet needs its VPC, an object needs its bucket. formae handles this with `.res`. Add an object to your bucket. Note the two changes: the bucket becomes a `local` you can refer to, and the object reads the bucket's name through `.res`. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} extends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/s3/bucket.pkl" import "@aws/s3/object.pkl" local myBucket = new bucket.Bucket { label = "my-first-bucket" bucketName = "formae-first-forma-CHANGE-ME" } forma { new formae.Stack { label = "first-forma" description = "My first formae stack" } new formae.Target { label = "aws-target" config = new aws.Config { region = "us-east-1" } } myBucket new object.Object { label = "readme-object" bucket = myBucket.res.bucketName key = "hello.txt" content = "Written by my first forma." contentType = "text/plain" } } ``` `myBucket.res.bucketName` is a **resolvable**: at write time the bucket does not exist yet, so there is no name to hand the object. formae works out that the object depends on the bucket, creates the bucket first, then fills in the real value. You never declare dependencies or ordering yourself. Declaring `local myBucket` is not enough to deploy it. You must also mention `myBucket` inside the `forma` block, as above. A `local` that is never referenced in `forma` is simply not rendered, and formae will not create it. Dry-run, then apply: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --simulate main.pkl formae apply --mode reconcile main.pkl ``` The bucket is untouched (nothing about it changed) and the object is created. Hardcoding the bucket name means editing code for every environment. Properties turn values into command-line flags. Declare a typed `Props` class (this is why the file `extends` rather than `amends`), and use its members in the bucket: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} properties: Props class Props { /// Globally-unique S3 bucket name name: String = "formae-first-forma-CHANGE-ME" /// Deployment environment environment: String = "dev" } local myBucket = new bucket.Bucket { label = "my-first-bucket" bucketName = properties.name tags { new { key = "Environment"; value = properties.environment } } } ``` Two rules to remember: * The member name is the flag: `name` becomes `--name`, `environment` becomes `--environment`. * Read a property directly by its member name. `properties.name` *is* the string, no `.value` to unwrap, and your editor resolves it. Now pass the properties on the command line, and the same forma serves any environment. This also gives the bucket a real name instead of the `CHANGE-ME` default: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --simulate --name my-unique-bucket-name --environment staging main.pkl formae apply --mode reconcile --watch --name my-unique-bucket-name --environment staging main.pkl ``` To see every property a forma exposes: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --help main.pkl ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy main.pkl ``` Because everything lives in one stack, this removes the object and the bucket together, in the right order. ## What you learned * A forma is a **stack** (lifecycle group), a **target** (where), and **resources** (what). * **`label`** identifies a resource to formae; the cloud name is a separate field. * **`.res`** references another resource and gives you dependency ordering for free. * A **`local`** must still be mentioned inside `forma` to be created. * **Properties** are members of a typed `Props` class: each becomes a CLI flag, read directly by name (`properties.name`). * **`apply --simulate`** shows you the truth before you change anything. ## What's next Split formae into reusable classes and functions as your infrastructure grows. When to use reconcile and when to use patch. Stacks, targets, resources, resolvables, and how formae stays in sync. The Pkl syntax you will actually use in formae. # AI assistants Source: https://docs.formae.io/documentation/guides/ai-coding-assistants Connect formae to Claude Code, Codex, OpenCode, Cursor, or any MCP client, and manage infrastructure through conversation. formae ships an MCP server and a set of skills that teach AI coding assistants how to manage infrastructure. Once connected, you can deploy, query, and change cloud resources through natural conversation. Release notes for the MCP server live in its [CHANGELOG](https://github.com/platform-engineering-labs/formae-mcp/blob/main/CHANGELOG.md) in the `formae-mcp` repository, not in these docs. ## What you get * **31 MCP tools** that give your assistant direct access to the formae agent API: querying resources, deploying infrastructure, updating your IaC codebase from reality, searching the plugin hub, and more. * **19 skills** that teach your assistant proven workflows, from authoring new infrastructure end to end (infer the right plugins, pull version-matched examples, design stacks, simulate, apply) to absorbing out-of-band changes back into your IaC codebase. Both pieces matter. The tools are the API access; the skills are the workflows that use them safely (simulate before applying, confirm before destroying). A client with skills but no MCP server registered will fail on every tool call. ## Prerequisites * `git` and `curl` * A running formae agent (`formae agent start`) and a formae profile pointing at it You do not need a Go toolchain. The MCP server and a matched `formae` binary are downloaded as prebuilt artifacts into `~/.formae-ai/opt` on first launch — nothing is compiled on your machine. ## Install Claude Code has first-class support through the plugin marketplace. The MCP server is registered for you, and on first use it downloads a prebuilt server and a matched `formae` binary into `~/.formae-ai/opt`. Register the marketplace: ```text theme={"languages":{"custom":["/languages/pkl.json"]}} /plugin marketplace add platform-engineering-labs/formae-marketplace ``` Install the plugin: ```text theme={"languages":{"custom":["/languages/pkl.json"]}} /plugin install formae@formae-marketplace ``` Run `/reload-plugins` (Claude Code v2.1.116+) to apply without restarting. On older versions, restart Claude Code. **Verify:** ask Claude to run `/formae-status`. You should get live agent status back, not an error. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-mcp.git \ ~/.claude/plugins/formae-mcp claude --plugin-dir ~/.claude/plugins/formae-mcp ``` Codex (a recent CLI, verified with 0.148.0) installs plugins from a marketplace the same way Claude Code does. Register the marketplace, then install the plugin: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} codex plugin marketplace add platform-engineering-labs/formae-marketplace codex plugin add formae@formae-marketplace ``` No clone, no symlink, no editing `config.toml`. The skills and the MCP server both come with the plugin. The first session after install downloads the prebuilt server and a matched `formae` binary into `~/.formae-ai/opt`; later sessions start instantly. **Verify:** `codex plugin list` should show `formae` installed. Then start a session and ask it to check formae status, or search the plugin hub, and confirm a real tool call returns data, not just that the skill loaded. For older Codex versions without plugin marketplace support, see the manual install (clone + symlink + `config.toml`) documented in [`.codex/INSTALL.md`](https://github.com/platform-engineering-labs/formae-mcp/blob/main/.codex/INSTALL.md). Clone the repo (it carries the skills and the launcher): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-mcp.git \ ~/.config/opencode/formae mkdir -p ~/.config/opencode/skills ln -s ~/.config/opencode/formae/skills ~/.config/opencode/skills/formae ``` Register the server in `opencode.json`, pointing the command at the launcher script (use an absolute path) — it downloads the prebuilt server and a matched `formae` binary on first run: ```json theme={"languages":{"custom":["/languages/pkl.json"]}} { "$schema": "https://opencode.ai/config.json", "mcp": { "formae": { "type": "local", "command": ["/home/you/.config/opencode/formae/scripts/start-mcp.sh"], "enabled": true } } } ``` Restart OpenCode. **Verify:** ask it to check formae status. A working setup returns live agent data. Full details: [`.opencode/INSTALL.md`](https://github.com/platform-engineering-labs/formae-mcp/blob/main/.opencode/INSTALL.md). Clone the repo for the launcher (Cursor consumes only the tools, so the skills symlink is optional): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-mcp.git \ ~/.cursor/formae ``` Register the server in `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global), pointing the command at the launcher (use an absolute path) — it downloads the prebuilt server and a matched `formae` binary on first run: ```json theme={"languages":{"custom":["/languages/pkl.json"]}} { "mcpServers": { "formae": { "command": "/home/you/.cursor/formae/scripts/start-mcp.sh" } } } ``` Reload Cursor, then check that the formae server shows as connected in **Settings → MCP**. Cursor consumes the MCP tools. The formae skills are not loaded, so you drive the workflows yourself rather than invoking `/formae-*` skills. Any MCP client can use the formae tools. Clone the repo for the launcher: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-mcp.git \ ~/formae-mcp ``` Then register it using your client's standard MCP configuration, pointing the command at the launcher (use an absolute path) — it downloads the prebuilt server and a matched `formae` binary on first run. Most clients use this shape: ```json theme={"languages":{"custom":["/languages/pkl.json"]}} { "mcpServers": { "formae": { "command": "/home/you/formae-mcp/scripts/start-mcp.sh" } } } ``` The server talks to the formae agent at `http://localhost:49684` by default. See [Configuration](#configuration) to point it elsewhere. ## Configuration By default the MCP server connects to the formae agent at `http://localhost:49684`. To point it somewhere else, or to work with more than one environment, use **profiles** (requires formae >= 0.87.0). The server reads the agent endpoint from your **active** profile, or from the profile named by a tool's `profile` argument. Profiles live at `~/.config/formae/profiles/.pkl` and are managed with `formae profile`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" cli { api { url = "http://my-agent-host" port = 8080 } } ``` ### Targeting a specific environment If you manage several formae environments as named profiles, every tool that talks to an agent accepts an optional `profile` argument. That targets one environment for a single call without changing which profile is active. Your assistant prefers this per-command targeting over switching the active profile, because the active profile is shared with your `formae` command line and any other assistant sessions. Ask it to "check drift in staging" or "apply to prod" and it targets that environment per command. Switching your default happens only when you explicitly ask. ## Example workflows > "I want to deploy a vLLM server on my Kubernetes cluster with formae" Starting from just a description, the assistant works out whether to create a new project or build in one you already have, adds the plugins you need, pulls real examples for them, helps you group resources into stacks, and walks you through simulating before applying. You do not need to know the forma file layout up front. > "Apply my VPC forma file at `infra/vpc.pkl`" The assistant simulates the deployment first, shows you what will be created, asks for confirmation, then applies. > "Has anything changed in my production stack since the last reconcile?" The assistant queries the agent, cross-references your IaC code, and presents only the true delta: changes not yet reflected in your codebase. > "Absorb out-of-band changes into my IaC code" The assistant extracts current state, edits your Pkl files to match, and verifies with a simulation that the code is back in sync. > "Build a Cloudflare plugin for formae" The assistant researches the Cloudflare API, suggests resource types organized in implementation waves, scaffolds the plugin, and works through each CRUD operation with tests, following the [plugin SDK tutorial](/plugin-development). ## Available skills Skills are workflows your assistant follows when you ask for an infrastructure task. They enforce safe practices like simulating before applying and confirming before destructive operations. | Skill | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `/formae-author` | Front door for authoring new infrastructure: triages intent, infers plugins, dispatches to the focused skills below | | `/formae-project-init` | Scaffold a new forma project with the right layout and config | | `/formae-deps` | Add or remove plugin schema dependencies in a project | | `/formae-stack-design` | Design how resources are grouped into stacks | | `/formae-apply` | Deploy infrastructure (simulate, confirm, apply) | | `/formae-patch` | Targeted change without a full reconcile | | `/formae-rename` | Rename a resource's label via `alias` without recreating the cloud object | | `/formae-destroy` | Tear down resources, stacks, or environments | | `/formae-fix-code-drift` | Detect and absorb out-of-band changes into your IaC codebase | | `/formae-policy` | Set, remove, or inspect TTL and auto-reconcile policies on a stack | | `/formae-discover` | Find unmanaged resources in cloud accounts | | `/formae-import` | Bring unmanaged resources under formae management | | `/formae-status` | Check running commands, deployment progress, and failures | | `/formae-stacks` | View infrastructure stacks and resource counts | | `/formae-resources` | Query resources by type, stack, label, or management status | | `/formae-targets` | List cloud targets and configured regions | | `/formae-plugin-new` | Scaffold a new resource plugin from scratch | | `/formae-plugin-add-resource` | Add a resource type to an existing plugin | | `/formae-config` | Manage formae config profiles (requires formae >= 0.87.0) | ## Available MCP tools ### Read-only | Tool | Description | | ----------------------------------- | --------------------------------------------------------- | | `list_resources` | Query resources with optional filters | | `list_stacks` | Retrieve all stacks | | `list_targets` | Query configured cloud targets | | `get_command_status` | Get status of a specific command | | `list_commands` | List commands with optional filters | | `get_agent_stats` | Retrieve agent statistics | | `check_health` | Health check for the agent | | `list_changes_since_last_reconcile` | List changes since last reconcile | | `extract_resources` | Extract resources as Pkl code | | `list_policies` | List reusable policies and the stacks they're attached to | | `search_hub_plugins` | Search the live plugin hub by keyword or resource type | | `get_hub_plugin` | Get details for a specific hub plugin | | `list_plugin_examples` | List version-matched examples for a hub plugin | | `get_plugin_example` | Fetch a specific example from a hub plugin | ### Mutation | Tool | Description | | ----------------------- | ------------------------------------------------------------------- | | `apply_forma` | Deploy or update infrastructure | | `destroy_forma` | Remove infrastructure by file or query | | `cancel_commands` | Cancel running commands | | `force_sync` | Trigger immediate resource synchronization | | `force_discover` | Trigger immediate resource discovery | | `force_check_ttl` | Trigger an immediate TTL expiry sweep across stacks | | `force_reconcile_stack` | Force a one-shot reconcile on a stack with an auto-reconcile policy | | `create_inline_policy` | Plan a TTL or auto-reconcile policy edit on a stack | ### Profiles Requires formae >= 0.87.0. Manage named formae environments (endpoint + targets) from your assistant. | Tool | Description | | ----------------- | --------------------------------------------------------------------------------- | | `list_profiles` | List configuration profiles and which one is active | | `current_profile` | Show the active profile | | `use_profile` | Switch the active profile (global; only on explicit "change my default" requests) | | `save_profile` | Snapshot the active profile under a new name | | `create_profile` | Create a new profile from the starter template | | `delete_profile` | Delete a profile (cannot be the active one) | | `diff_profiles` | Compare two profiles, or one against the active | | `read_profile` | Return a profile's Pkl contents | | `write_profile` | Replace a profile's Pkl (overwrite-only; refuses the active profile) | # Bring existing resources under management Source: https://docs.formae.io/documentation/guides/bring-resources-under-management Turn resources that already exist in your cloud into formae-managed code, without recreating anything. You have resources created through the console, a CLI, or another tool, and you want formae to manage them: change them, tag them, evolve them through code. formae [discovers](/documentation/concepts/discovery) them, then lets you extract them as Pkl and bring them under management without touching or recreating anything. **Before you start:** discovery has to have run on a discoverable target. If you haven't set one up yet, do [Create a target](/documentation/guides/create-a-target) first. The examples below adopt AWS internet gateways; use the equivalent types for your own resources. Discovery records the resources formae doesn't manage yet. List what it found in your inventory: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:false" ``` The query matches resources by attribute, so add filters to home in on exactly what you want. Here, only the internet gateways: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:false type:AWS::EC2::InternetGateway" ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 ╭─────────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─╯             ╰────────────────────────────────────────────────────────────────────────────────────────────────────

Label ▲                   Stack            Type                          NativeID                           
────────────────────────────────────────────────────────────────────────────────────────────────────────────
formae-auto-recon-inline-1⚠ unmanaged      AWS::EC2::InternetGateway     igw-037caa5c87583128e              
formae-auto-recon-standal…⚠ unmanaged      AWS::EC2::InternetGateway     igw-0555aa2884ab0d7a8              
formae-lgtm-igw           ⚠ unmanaged      AWS::EC2::InternetGateway     igw-0ec4e39eb6bf0a348              
igw-00fda294a8aa7d0ce     ⚠ unmanaged      AWS::EC2::InternetGateway     igw-00fda294a8aa7d0ce              
igw-03ff9cdf7e484fcbe-2   ⚠ unmanaged      AWS::EC2::InternetGateway     igw-03ff9cdf7e484fcbe              
igw-06c867a1939f44327     ⚠ unmanaged      AWS::EC2::InternetGateway     igw-06c867a1939f44327              
igw-0e65d7a67d98849cf     ⚠ unmanaged      AWS::EC2::InternetGateway     igw-0e65d7a67d98849cf              
lifeline-1558-igw-2       ⚠ unmanaged      AWS::EC2::InternetGateway     igw-03c849fb5154f7715              
lifeline-abc1-igw-1       ⚠ unmanaged      AWS::EC2::InternetGateway     igw-0b9e65ff163e1b5ff              

Showing 9 of 9 resources (filtered)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  / managed:false type:AWS::EC2::InternetGateway                                                     /: edit query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
You can filter by attributes like `type`, `label`, and `target`, and use `*` wildcards. See the [CLI reference](/documentation/reference/cli) for the full query syntax.
Pull the resources you want into a forma: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract --query="managed:false type:AWS::EC2::InternetGateway" networking.pkl ```
Initialized new Pkl project at .
Initialized pkl project at '.'
Extracted 9 resources to networking.pkl

  formae-auto-recon-inline-1 (AWS::EC2::InternetGateway) on stack $unmanaged
  formae-auto-recon-standalone-1 (AWS::EC2::InternetGateway) on stack $unmanaged
  formae-lgtm-igw (AWS::EC2::InternetGateway) on stack $unmanaged
  igw-00fda294a8aa7d0ce (AWS::EC2::InternetGateway) on stack $unmanaged
  igw-03ff9cdf7e484fcbe-2 (AWS::EC2::InternetGateway) on stack $unmanaged
  igw-06c867a1939f44327 (AWS::EC2::InternetGateway) on stack $unmanaged
  igw-0e65d7a67d98849cf (AWS::EC2::InternetGateway) on stack $unmanaged
  lifeline-1558-igw-2 (AWS::EC2::InternetGateway) on stack $unmanaged
  lifeline-abc1-igw-1 (AWS::EC2::InternetGateway) on stack \$unmanaged
This writes `networking.pkl` with the resources you selected. formae creates a `PklProject` only when there is none anywhere in the directory hierarchy where you extract. Run `extract` in a fresh, empty directory and it scaffolds a starter project (a `PklProject` pinning the formae and provider schema packages the resources need, plus a `main.pkl`) and prints an "Initialized new Pkl project" line. Run it inside a directory already covered by a `PklProject` and formae writes just `networking.pkl`, reusing the dependencies already declared there. Usually you extract straight into your existing formae project. When you do, make sure the new file is reachable: either import `networking.pkl` somewhere in your `main.pkl` include hierarchy, or copy the extracted resources into a file that is already included. A forma file that nothing imports is written to disk but never applied. Extracted files need formae `0.88.0` or greater (they use `extends "@formae/forma.pkl"`). If your project's `PklProject` pins an older version, extract still writes the file but prints a notice to bump the formae dependency and run `pkl project resolve`. See [Schema version compatibility](/documentation/reference/cli/extract#schema-version-compatibility).
Discovered resources sit on formae's built-in `unmanaged` stack, so `extract` comments the stack label out and leaves you a prompt. Uncomment it and give the resources a stack of your own (the description comes pre-filled by `extract`; keep it or change it): ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local myStack = new formae.Stack { // Please provide a stack to bring the resources in this Forma under management label = "imported-networking" description = "Resources imported with formae extract" } ``` Applying moves them off the `unmanaged` stack and onto `imported-networking`, where formae manages them. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile networking.pkl ``` formae recognizes these as the resources discovery already found, so nothing is recreated. It puts each one under management, moving them off the `unmanaged` stack and onto `imported-networking`. Confirm: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="stack:imported-networking" ```
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, you drive the same flow as a conversation. Ask what discovery turned up, name what you want to adopt, and let the assistant's `formae-import` skill extract, simulate, and apply:
Conversation
You
Do we have any unmanaged resources?
Assistant
Discovery has six unmanaged internet gateways on the unmanaged stack: legacy-igw-inline, legacy-igw-standalone, igw-0a1b2c3d4e5f6a7b8, igw-0f9e8d7c6b5a4f3e2, webapp-1558-igw-1, and webapp-abc1-igw. Want me to bring them under management?
You
Bring the internet gateways under management.
Assistant
I'll extract the six into a new imported-networking stack and simulate first. The simulation creates the stack and moves 6 resources from unmanaged to imported-networking, putting them under management with nothing recreated. Apply it?
You
Yes.
Assistant
Applied. The six internet gateways are now managed on imported-networking, and nothing was recreated.
The assistant always simulates and confirms before applying, so you see exactly what will happen first. Narrow the request however you like ("just the internet gateways", "the ones whose name starts with `webapp-`") or name the target stack yourself.
## See also * [Discovery](/documentation/concepts/discovery): how formae finds unmanaged resources. * [Resources](/documentation/concepts/resources): managed versus unmanaged. * [Apply modes](/documentation/concepts/apply-modes): what reconcile does when you apply. # Build self-service infrastructure Source: https://docs.formae.io/documentation/guides/build-self-service-infrastructure Expose a clean, parameterized interface so developers deploy production-ready infrastructure with a few flags while your code enforces the standards. As a platform team you want developers to get production-ready infrastructure without touching the code that defines it. formae lets you do this with [properties](/documentation/concepts/properties): you write one parameterized forma, expose only the inputs developers should choose, and every property becomes a CLI flag automatically. Developers run `formae apply` with a few flags, and your code turns those choices into compliant infrastructure. This guide walks through building such an offering: define the interface, map choices to real infrastructure, bake in your standards, and hand it to developers. ## Design the developer interface Properties are the contract between you and your developers. Declare only the inputs they need to vary as members of a typed `Props` class. Each member's name becomes a `--flag` CLI option automatically. A member with a `default` is optional; a member without one is required. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // team-database.pkl extends "@formae/forma.pkl" import "@formae/formae.pkl" properties: Props class Props { /// Owning team team: String /// Deployment environment env: String = "dev" /// Database size, as a t-shirt size size: String = "small" } ``` Here `--team` is required, while `--env` and `--size` fall back to their defaults. Developers never open the Pkl file; they interact entirely through these flags. Declaring properties as a typed class requires `extends "@formae/forma.pkl"` (not `amends`). See [Properties](/documentation/concepts/properties) for the typed form and the legacy `properties {}` block. Use defaults generously. The fewer required flags, the easier the offering is to adopt. Make `--team` required because it has no sensible default, but let `--size` default to the common case. ## Map choices to infrastructure decisions This is where your platform expertise lives: translating a small vocabulary like `small`/`medium`/`large` into concrete instance classes, storage sizes, and policies. Read the property values and derive the real configuration. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Map t-shirt sizes to instance config local instanceClass = if (properties.size == "large") "db.r6g.xlarge" else if (properties.size == "medium") "db.r6g.large" else "db.t4g.micro" local storageGb = if (properties.size == "large") 500 else if (properties.size == "medium") 100 else 20 local teamName = properties.team local envName = properties.env ``` You can also constrain a `Props` member directly so a bad input fails before any cloud API call: a closed enumeration (`size: "small" | "medium" | "large"`) or a regex pattern rejects invalid values at evaluation time rather than mid-deploy. See [Properties](/documentation/concepts/properties) for the constraint patterns. ## Bake your standards into the resources Encode your organization's requirements directly in the forma. Developers get compliant infrastructure without having to know the rules, because the rules are in the code, not in a wiki. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} forma { new formae.Stack { label = "\(teamName)-\(envName)" } new formae.Target { label = "\(teamName)-target" config = new aws.Config { region = "us-east-1" } } new dbinstance.DBInstance { label = "\(teamName)-database" engine = "postgres" engineVersion = "15.4" dbInstanceClass = instanceClass allocatedStorage = storageGb.toString() storageEncrypted = true // Always encrypted multiAZ = envName != "dev" // Multi-AZ outside dev deletionProtection = envName == "production" masterUsername = teamName masterUserPassword = formae.value(random.password(24, true)).opaque.setOnce tags { new { key = "Team"; value = teamName } new { key = "Environment"; value = envName } new { key = "ManagedBy"; value = "formae" } } } } ``` The generated password uses `.opaque.setOnce`: `.opaque` keeps it out of logs and output, and `.setOnce` fixes it on the first apply so it stays stable across later applies. See [Values](/documentation/concepts/values) for both modifiers. These examples use AWS resources, but the same pattern applies to any technology that has a formae [plugin](/documentation/concepts/plugin). Properties and standards are provider-agnostic. ## Encapsulate larger offerings with modules For anything bigger than a single resource, group the pieces into a Pkl class so the forma stays clean and the complexity lives behind an interface. A class takes the same property values as inputs and returns a listing of resources. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // infrastructure/team_environment.pkl import "@aws/ec2/securitygroup.pkl" import "@aws/rds/dbinstance.pkl" import "@aws/s3/bucket.pkl" class TeamEnvironment { team: String env: String size: String hidden sg: securitygroup.SecurityGroup = new { label = "\(team)-\(env)-sg" groupDescription = "\(team) \(env) security group" // ... ingress/egress rules baked in } hidden database: dbinstance.DBInstance = new { label = "\(team)-\(env)-db" // ... size mapping, encryption, tags } hidden artifacts: bucket.Bucket = new { label = "\(team)-\(env)-artifacts" bucketName = "\(team)-\(env)-artifacts" // ... versioning, lifecycle rules } resources: Listing = new { sg database artifacts } } ``` The forma then reduces to wiring the class to the properties and spreading its resources: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "./infrastructure/team_environment.pkl" local environment = new team_environment.TeamEnvironment { team = properties.team env = properties.env size = properties.size } forma { new formae.Stack { label = "\(properties.team)-\(properties.env)" } new formae.Target { label = "default" config = new aws.Config { region = "us-east-1" } } ...environment.resources } ``` ## Hand it to developers The properties you declared are already the whole interface. Developers discover them by passing the forma to `--help`, then deploy with the flags. Passing the forma file to `--help` lists the property flags, their defaults, and which are required: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --help team-database.pkl ``` The output includes a `Properties:` section, for example:
Properties:
      --env                   property: env \[default: "dev"]
      --size                  property: size \[default: "small"]
      --team                  property: team \[required]
A developer supplies the flags and applies. Reconcile mode brings the team's stack into being exactly as the forma (and their choices) describe it. formae shows the plan and asks for confirmation before making any changes, then streams the run in a live view: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --team payments --env staging team-database.pkl ```
  formae apply · reconcile                                                                       team-database.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 3 create

  ▌ Targets
  Operation ▲   Label
  + create      payments-target

  ▌ Stacks
  Operation ▲   Label
  + create      payments-staging

  ▌ Resources
  Operation ▲   Label                                                       Type
  + create      payments-database                                           AWS::RDS::DBInstance

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1 stack(s), create
1 target(s) and create 1 resource(s).  Do you want to continue? (y/N)  ?: help
Pass `--yes` to skip the confirmation prompt in a CI/CD job.
Treat property flags as a public API. Renaming a `Props` member renames its `--flag` and breaks every developer script and CI job that passes it. Keep flags stable: add new members rather than renaming, and deprecate old ones gradually. ## See also * [Properties](/documentation/concepts/properties): the full reference for typed, constrained property interfaces. * [Apply modes](/documentation/concepts/apply-modes): reconcile versus patch, and how each treats a stack. * [Values](/documentation/concepts/values): the `.opaque` and `.setOnce` modifiers for secrets and stable generated values. * [Write your first forma](/documentation/get-started/write-your-first-forma): a worked example of turning a hardcoded value into a property end to end. # Cancel a running command Source: https://docs.formae.io/documentation/guides/cancel-a-running-command Stop an in-progress apply or destroy without leaving orphaned resources behind. A command you triggered by mistake, or one that is stuck, is its own kind of incident. You can cancel it. ## Cancel it ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae cancel ``` With no query, `formae cancel` targets the most recent command. To target specific in-progress commands, pass a query: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae cancel --query="command:apply status:InProgress" ``` ## What cancel does By default, cancel is careful about work that is already in flight: * Only commands in the `InProgress` state can be canceled. * Resources already executing run through to completion, so formae leaves no half-applied or orphaned resources behind. Operations that have not started yet are dropped. * Work already completed is not rolled back. Cancel stops further changes; it does not undo what already happened. This is the live view the moment you cancel. Some resources are already done (the check marks), a couple are still running to completion (the spinner), and the rest are canceled. The progress bar turns red to show the command is being canceled:
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
⣾    3GovHX0bguXAqlm8H98bXBebF5q apply     reconcile   ███████████████████████░░░░ 4/15 4    9    2    0    00:14
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Targets
        Label ▲                                                                                   Operation   Time
       aws-target                                                                                create      00:00

  ▌ Stacks
        Label ▲                                                                                   Operation   Time
       lifeline                                                                                  create      00:00

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       lifeline-alb-http-ingress                             AWS::EC2::SecurityGroupIngress      create      
       lifeline-alb-sg                                       AWS::EC2::SecurityGroup             create      
       lifeline-igw                                          AWS::EC2::InternetGateway           create      00:05
       lifeline-igw-attachment                               AWS::EC2::VPCGatewayAttachment      create      
       lifeline-public-route                                 AWS::EC2::Route                     create      
   ⣾    lifeline-public-rt                                    AWS::EC2::RouteTable                create      00:14
   ⣾    lifeline-public-subnet-1                              AWS::EC2::Subnet                    create      00:14
       lifeline-public-subnet-1-assoc                        AWS::EC2::SubnetRouteTableAssocia…  create      
       lifeline-public-subnet-2                              AWS::EC2::Subnet                    create      
       lifeline-public-subnet-2-assoc                        AWS::EC2::SubnetRouteTableAssocia…  create      
       lifeline-task-http-ingress                            AWS::EC2::SecurityGroupIngress      create      
       lifeline-task-sg                                      AWS::EC2::SecurityGroup             create      
       lifeline-vpc                                          AWS::EC2::VPC                       create      00:03

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Once the in-flight resources finish, the command settles into a terminal `canceled` state. The resources that completed remain under management: inspect them with `formae inventory`, or remove them with `formae destroy`.
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
⊘    3GovHX0bguXAqlm8H98bXBebF5q apply     reconcile   canceled 6/15                    6    9    0    0    00:22
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Targets
        Label ▲                                                                                   Operation   Time
       aws-target                                                                                create      00:00

  ▌ Stacks
        Label ▲                                                                                   Operation   Time
       lifeline                                                                                  create      00:00

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       lifeline-alb-http-ingress                             AWS::EC2::SecurityGroupIngress      create      
       lifeline-alb-sg                                       AWS::EC2::SecurityGroup             create      
       lifeline-igw                                          AWS::EC2::InternetGateway           create      00:05
       lifeline-igw-attachment                               AWS::EC2::VPCGatewayAttachment      create      
       lifeline-public-route                                 AWS::EC2::Route                     create      
       lifeline-public-rt                                    AWS::EC2::RouteTable                create      00:18
       lifeline-public-subnet-1                              AWS::EC2::Subnet                    create      00:20
       lifeline-public-subnet-1-assoc                        AWS::EC2::SubnetRouteTableAssocia…  create      
       lifeline-public-subnet-2                              AWS::EC2::Subnet                    create      
       lifeline-public-subnet-2-assoc                        AWS::EC2::SubnetRouteTableAssocia…  create      
       lifeline-task-http-ingress                            AWS::EC2::SecurityGroupIngress      create      
       lifeline-task-sg                                      AWS::EC2::SecurityGroup             create      
       lifeline-vpc                                          AWS::EC2::VPC                       create      00:03

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
## See also * [Troubleshoot a failed command](/documentation/guides/troubleshoot-a-failed-command): find out which resource broke and why. * [Incidents and recovery](/documentation/guides/incidents-and-recovery): fix a live incident with a patch and settle up afterwards. * [CLI reference](/documentation/reference/cli): full flags for `apply`, `cancel`, `status`, `inventory`, and `destroy`. # Run formae in CI/CD Source: https://docs.formae.io/documentation/guides/cicd Drive formae non-interactively from a pipeline: validate changes in a pull request, apply on merge, pass values between stages, and manage the pipeline itself as a forma. formae is built to run unattended. The same `formae apply` you run from a laptop works in a CI/CD job once you turn off the interactive prompts and read structured output instead of tables. This guide covers the non-interactive contract, the simulate-in-a-pull-request then apply-on-merge flow, passing values between stages, worked GitHub Actions and GitLab CI pipelines, reading discovered resources, and fanning the same pipeline out across many repositories. The [Git as the source of truth](/documentation/concepts/ways-to-work) operating model is the natural fit here: your forma files live in Git, the pipeline is the only thing that applies them, and every change lands through a reviewed merge. ## Run formae non-interactively A pipeline runner needs the formae CLI and a way to reach the [agent](/documentation/guides/install-agent). The agent is long-lived and shared: you do not start one per job. Many pipelines and humans connect to the same agent at once. Install the CLI in the runner the same way you would locally: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" ``` The runner has to reach the agent. The `default` [profile](/documentation/guides/manage-profiles) points at `http://localhost:49684`, which is only right when the agent runs on the same host. For a remote agent, add a profile whose `cli.api.url` is the agent host and select it per command with `--profile`, or point the CLI at the agent through your CI secrets. Three flags turn `apply` into something a pipeline can drive: | Flag | Purpose | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--mode reconcile` or `--mode patch` | Required on every apply. Reconcile makes the stack match the forma exactly; patch applies only the changes you name. See [Apply modes](/documentation/concepts/apply-modes). | | `--yes` | Run without the interactive confirmation prompt. | | `--output-consumer machine --output-schema json` | Emit JSON instead of human tables so later steps can parse the result. | ### Gate the pipeline on the outcome `apply` submits the command to the agent and the agent executes it asynchronously. In machine mode, `apply` prints the command ID as soon as the command is accepted: ```json theme={"languages":{"custom":["/languages/pkl.json"]}} { "CommandId": "2a1f..." } ``` To make the job pass or fail on the actual result, capture that ID and poll `formae status command` until the command reaches a terminal state: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} CMD_ID=$(formae apply --mode reconcile --yes \ --output-consumer machine --output-schema json infra/main.pkl \ | jq -r '.CommandId') while :; do STATE=$(formae status command --query="id:$CMD_ID" \ --output-consumer machine --output-schema json \ | jq -r '.Commands[0].State') case "$STATE" in Success) echo "apply succeeded"; break ;; Failed|Canceled) echo "apply $STATE"; exit 1 ;; *) sleep 5 ;; esac done ``` A CI runner has no interactive terminal, so `apply` is fire-and-forget there: it submits the command and returns as soon as the agent accepts it. Use the `formae status command` poll above wherever the pipeline result has to gate a merge or a deploy - it streams status and fails the job when the apply fails. ## Simulate in the pull request, apply on merge The everyday pipeline has two triggers. On a pull request, validate the forma and preview the changes without touching anything. On merge to the main branch, apply them. `formae eval` catches syntax and type errors. Running `apply` with `--simulate` previews the plan without making any cloud API calls, so reviewers see exactly what the merge would do. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae eval infra/main.pkl formae apply --mode reconcile --simulate infra/main.pkl ``` Once the change lands on the main branch, apply it. Reconcile mode brings the stack into the exact shape the forma describes. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --yes infra/main.pkl ``` Here is the same flow as a complete pipeline for each system. ```yaml GitHub Actions theme={"languages":{"custom":["/languages/pkl.json"]}} name: infrastructure on: pull_request: paths: ["infra/**"] push: branches: [main] paths: ["infra/**"] jobs: validate: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install formae run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" - name: Validate run: formae eval infra/main.pkl - name: Preview changes run: formae apply --mode reconcile --simulate infra/main.pkl deploy: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install formae run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" - name: Apply run: formae apply --mode reconcile --yes infra/main.pkl ``` ```yaml GitLab CI theme={"languages":{"custom":["/languages/pkl.json"]}} stages: [validate, deploy] .install: &install - /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" validate: stage: validate rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" script: - *install - formae eval infra/main.pkl - formae apply --mode reconcile --simulate infra/main.pkl deploy: stage: deploy rules: - if: $CI_COMMIT_BRANCH == "main" script: - *install - formae apply --mode reconcile --yes infra/main.pkl ``` The `deploy` jobs above are fire-and-forget: `apply` returns as soon as the command is accepted, so the job can go green before the deploy finishes. For a hard pass/fail gate on the main branch, follow the apply with the [`formae status command` check](#gate-the-pipeline-on-the-outcome) so the job waits for the terminal state and fails on `Failed` or `Canceled`. ## Parameterize per environment You rarely want a separate forma per environment. Declare [properties](/documentation/concepts/properties) for the parts that vary and formae turns each one into a CLI flag automatically. One forma then deploys to staging or production by passing different flags. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --yes --env staging infra/main.pkl formae apply --mode reconcile --yes --env production infra/main.pkl ``` Here `--env` exists because the forma declares an `env` property. To see which flags a forma exposes, pass it to `--help`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --help infra/main.pkl ``` The output lists a `Properties:` section with each flag, its default, and whether it is required. For the full pattern, including how to constrain inputs so a bad value fails before any cloud call, see [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure). ## Pass values between stages A common shape is provision, then deploy, then verify: one job creates infrastructure, the next needs a computed value from it (a hostname, an ARN, a generated name), and the last smoke-tests the result. The machine output of `formae inventory` is the bridge between stages. Computed values live under `ReadOnlyProperties`; values you declared in the forma live under `Properties`. Query the resource you just created and pull the field you need: ```yaml GitHub Actions theme={"languages":{"custom":["/languages/pkl.json"]}} provision: runs-on: ubuntu-latest outputs: db_host: ${{ steps.capture.outputs.db_host }} steps: - uses: actions/checkout@v4 - name: Install formae run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" - name: Provision run: formae apply --mode reconcile --yes infra/database.pkl - id: capture name: Capture DB host run: | DB_HOST=$(formae inventory resources \ --query='label:pg-server' \ --output-consumer machine --output-schema json \ | jq -r '.Resources[0].ReadOnlyProperties.fullyQualifiedDomainName') echo "db_host=$DB_HOST" >> "$GITHUB_OUTPUT" deploy: needs: provision runs-on: ubuntu-latest steps: - name: Deploy run: deploy_cmd --db-host=${{ needs.provision.outputs.db_host }} ``` ```yaml GitLab CI theme={"languages":{"custom":["/languages/pkl.json"]}} provision: stage: provision script: - /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)" - formae apply --mode reconcile --yes infra/database.pkl - | DB_HOST=$(formae inventory resources \ --query='label:pg-server' \ --output-consumer machine --output-schema json \ | jq -r '.Resources[0].ReadOnlyProperties.fullyQualifiedDomainName') echo "DB_HOST=$DB_HOST" >> deploy.env artifacts: reports: dotenv: deploy.env deploy: stage: deploy needs: [provision] script: - deploy_cmd --db-host="$DB_HOST" ``` The two systems forward the value differently. GitHub Actions writes it to `$GITHUB_OUTPUT` and reads it back through `needs.provision.outputs`. GitLab CI writes it to a `dotenv` artifact and later jobs that `needs` this one get the variable injected automatically. The formae side of the bridge is identical: one `formae inventory` query, one `jq` extraction. ## Read a discovered resource in a pipeline Sometimes a stage needs a value from a resource that lives in your cloud account but is not managed by formae: a legacy database owned by another team, shared infrastructure, anything that predates formae. Hard-coding the value in a CI variable works once and then rots. Reading it through [discovery](/documentation/concepts/discovery) keeps the pipeline correct for as long as the resource exists. First make the resource discoverable by applying a target that points at the account or region where it lives. No stack, no resources, just a target: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" forma { new formae.Target { label = "shared-rds-account" config = new aws.Config { region = "us-east-1" } } } ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --yes target.pkl ``` Discovery scans the target on an interval and surfaces the resources it finds with `managed: false`: visible to `inventory` and `extract`, untouched by `apply`. Once the resource has been picked up, query it exactly as you would a managed one, with a `managed:false` filter: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} DB_HOST=$(formae inventory resources \ --query='managed:false label:legacy-orders-db' \ --output-consumer machine --output-schema json \ | jq -r '.Resources[0].ReadOnlyProperties.endpoint.address') ``` If you later decide to bring the resource under management, slice it out by query and give it a stack: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract --query='managed:false label:legacy-orders-db' orders-db.pkl ``` Edit `orders-db.pkl` to add a stack, then `formae apply --mode reconcile orders-db.pkl`. For a larger estate, slice by team, type, or environment, one stack per slice. See [Bring resources under management](/documentation/guides/bring-resources-under-management) for the full adoption path. ## Provision the pipeline itself as a forma The pipelines above run formae. You can also let formae manage the pipeline. When you have a plugin for your CI system, the workflow file, the variables and secrets it depends on, and the environments it deploys into are all resources. One `formae apply` lays them all down. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@gha/repo/repoworkflow.pkl" as workflow import "@com.github.actions/Workflow.pkl" as GHAWorkflow local deployWorkflow = new workflow.Workflow { label = "deploy" path = ".github/workflows/deploy.yml" name = "Deploy" on = new GHAWorkflow.On { workflow_dispatch {} } permissions = new GHAWorkflow.Permissions { `id-token` = "write" contents = "read" } jobs { ["deploy"] { `runs-on` = "ubuntu-latest" steps { new { uses = "actions/checkout@v4" } new { name = "Deploy"; run = "./deploy.sh" } } } } } ``` Applying this forma writes `.github/workflows/deploy.yml` into the repository as a managed resource. When the workflow definition is code, editing it is an ordinary forma change that flows through the same review-and-apply pipeline as everything else. ## Fan out across many repositories To run the same pipeline shape across many repositories with per-repository overrides, wrap the pieces in a Pkl class and instantiate it once per service. Each instance carries its own [target](/documentation/concepts/target), so a single forma provisions the whole fleet. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // service_pipeline.pkl module service_pipeline import "@formae/formae.pkl" import "@gha/gha.pkl" import "@gha/repo/repovariable.pkl" as repovar import "@gha/repo/repoworkflow.pkl" as workflow import "@gha/env/environment.pkl" import "@gha/env/envsecret.pkl" import "@com.github.actions/Workflow.pkl" as GHAWorkflow class ServicePipeline { orgName: String repoName: String awsRegion: String = "us-east-1" prodRoleArn: String prodBranchPattern: String = "main" hidden target: formae.Target = new formae.Target { label = "gha-\(repoName)" namespace = "GHA" config = new gha.Config { owner = orgName repo = repoName } } hidden projectVar: repovar.Variable = new { label = "\(repoName)-project" target = target.res name = "PROJECT_NAME" value = repoName } hidden prodEnv: environment.Environment = new { label = "\(repoName)-production" target = target.res name = "production" preventSelfReview = true } hidden prodRoleSecret: envsecret.Secret = new { label = "\(repoName)-prod-role" target = target.res environment = prodEnv.res.name name = "AWS_ROLE_ARN" value = prodRoleArn } hidden deployWorkflow: workflow.Workflow = new { label = "\(repoName)-deploy" target = target.res path = ".github/workflows/deploy.yml" name = "Deploy" on = new GHAWorkflow.On { push { branches { prodBranchPattern } } } permissions = new GHAWorkflow.Permissions { `id-token` = "write" contents = "read" } jobs { ["deploy"] { `runs-on` = "ubuntu-latest" environment { name = "production" } steps { new { uses = "actions/checkout@v4" } new { name = "Configure AWS" uses = "aws-actions/configure-aws-credentials@v4" `with` { ["role-to-assume"] = "${{ secrets.AWS_ROLE_ARN }}" ["aws-region"] = "${{ vars.AWS_DEFAULT_REGION }}" } } new { name = "Deploy"; run = "./deploy.sh" } } } } } resources: Listing = new { target projectVar prodEnv prodRoleSecret deployWorkflow } } ``` The fleet manifest lists one entry per service. Class defaults carry the common case, and you override only the fields that differ: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // main.pkl amends "@formae/forma.pkl" import "@formae/formae.pkl" import "./service_pipeline.pkl" as sp local stack = new formae.Stack { label = "service-fleet" description = "Deploy pipelines for all service repos" } local orgName = read("env:GHA_OWNER") local services: Listing = new { new { orgName = orgName repoName = "orders-api" prodRoleArn = "arn:aws:iam::111111111111:role/orders-deploy" } new { orgName = orgName repoName = "payments-api" awsRegion = "us-west-2" prodRoleArn = "arn:aws:iam::111111111111:role/payments-deploy" } new { orgName = orgName repoName = "catalog-api" prodRoleArn = "arn:aws:iam::111111111111:role/catalog-deploy" prodBranchPattern = "release/*" } } forma { stack for (svc in services) { ...svc.resources } } ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export GHA_OWNER=my-org formae apply --mode reconcile --yes main.pkl ``` Adding a repository means appending one listing entry and re-applying. Changing a step in the class updates every repository at once. Because this is reconcile mode, anything in the `service-fleet` stack that is no longer declared gets removed, so the forma stays the single source of truth for the whole fleet. The class pattern is covered in depth in [Reuse with modules](/documentation/guides/reuse-with-modules). ## Tips and gotchas One agent serves many clients. Multiple pipelines and humans can connect to the same agent at the same time. Do not start an agent per job. Concurrent applies are safe when each pipeline works on its own stack and target. Conflicts arise when two applies touch the same stack and target at once. For ephemeral environments, put a unique ID in the stack label so each pipeline gets its own stack. For shared stacks, serialize the deploys. ## See also * [Apply modes](/documentation/concepts/apply-modes): reconcile versus patch, and what each one is allowed to do. * [Ways to work with formae](/documentation/concepts/ways-to-work): where a Git-driven pipeline sits among the operating models. * [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure): turn properties into a parameterized interface developers drive with flags. * [Discovery](/documentation/concepts/discovery): how formae finds and tracks resources you have not brought under management yet. * [CLI reference](/documentation/reference/cli): every command and flag in full. # Create a target Source: https://docs.formae.io/documentation/guides/create-a-target Register a cloud account and region with formae so it can deploy there and discover what already exists. A target is the cloud account and region formae works in. You need at least one before you can deploy resources or [discover](/documentation/concepts/discovery) what is already there, so it is usually the first thing you set up. formae learns about a target when you declare it in a forma and apply it. A target is just another thing you describe in code, registered the moment you apply. **Before you start:** the formae agent needs credentials for the account you are targeting, and you need a formae project (a `PklProject` with the schema dependencies). Starting fresh? Run `formae project init`, or follow [Write your first forma](/documentation/get-started/write-your-first-forma). A target lives in a forma, with a label and a provider `config`. Each cloud has its own config type, so pick yours: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" forma { new formae.Target { label = "prod-us-west-2" config = new aws.Config { region = "us-west-2" } } } ``` ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@azure/azure.pkl" forma { new formae.Target { label = "prod-azure" config = new azure.Config { subscriptionId = "your-subscription-id" } } } ``` ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@gcp/gcp.pkl" forma { new formae.Target { label = "prod-gcp" config = new gcp.Config { project = "your-project-id" region = "us-central1" } } } ``` The `label` is how you refer to the target from resources and on the CLI; `config` carries the provider settings. Targets are discoverable by default, which is what lets formae scan them for existing resources. Add `discoverable = false` to make a target deploy-only. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile target.pkl ``` formae shows the plan and asks you to confirm:
  formae apply · reconcile                                                                        target.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 1 create

  ▌ Targets
  Operation ▲   Label
  + create      prod-us-west-2

────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1
target(s).  Do you want to continue? (y/N)  ?: help
On confirmation it registers the target. No cloud resources are created: a target is a destination, not infrastructure. Because it is discoverable, formae also starts scanning the account for resources you can later bring under management.
```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory targets ``` Your new target appears in the list:
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                ╭───────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
────────────────╯           ╰───────────────────────────────────────────────────────────────────────────────────────────

Label ▲                   Namespace              Discoverable    Config                                         
────────────────────────────────────────────────────────────────────────────────────────────────────────────────
prod-us-west-2            AWS                    yes             Region: us-west-2, Type: AWS                   

Showing 1 of 1 targets
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                           ?: help
Filter if you have many: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory targets --query="discoverable:true" ```
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, you describe the target you want and let the assistant write and apply the forma:
Conversation
You
Set up an AWS target in us-west-2.
Assistant
I'll add a target prod-us-west-2 for AWS us-west-2 and simulate first: it creates one target, discoverable, and no cloud resources. Because it's discoverable, formae will then scan us-west-2 for existing resources. Apply it?
You
Yes.
Assistant
Done. prod-us-west-2 is registered and discoverable, and discovery is scanning it now. Once it finishes I can show you what's there to bring under management.
The assistant writes the same forma you would by hand, simulates it, and applies on your confirmation. Ask it to make a target deploy-only (`discoverable = false`) or to add more regions in one go.
## See also * [Target](/documentation/concepts/target): what a target is and how it relates to stacks and resources. * [Discovery](/documentation/concepts/discovery): how formae finds existing resources once a target is discoverable. * [Bring resources under management](/documentation/guides/bring-resources-under-management): adopt what discovery finds. # Deal with out-of-band changes Source: https://docs.formae.io/documentation/guides/deal-with-out-of-band-changes Someone changed your infrastructure outside formae. See how formae detects the drift on the next reconcile, then absorb the change into your code or discard it. Infrastructure changes outside your code more often than you would like. A teammate tightens a setting in the console, an incident responder adds a tag, an autoscaler rewrites a value. formae's job is to keep your code and your running infrastructure in agreement, so when reality drifts it tells you, and it does not silently paper over the difference. This guide walks a real drift end to end: you change a resource in the AWS console, formae detects it, and you either pull the change into your code or discard it. It uses the Lifeline foundation example, `basic_infrastructure.pkl`, which provisions a VPC, an internet gateway, subnets, routing, and security groups. ## Set the scene Apply the foundation so formae is managing it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile basic_infrastructure.pkl ``` All fifteen resources come up, and the stack is now under management. Your code and your infrastructure agree. ## Change something outside formae Now make a change the way a teammate in a hurry would: in the AWS console, open the internet gateway that the foundation created and add a tag, `Owner = platform-team`. Leave everything else alone.
The AWS console tag editor adding an Owner tag to the lifeline internet gateway
The gateway now carries a tag that exists nowhere in your code. Your infrastructure has drifted from its source of truth. ## formae detects the drift The agent synchronizes with your cloud on an interval, reading the resources it manages and noticing anything that changed underneath it. Within a sync cycle it sees the new tag on the gateway. Reconcile the foundation as you normally would. formae reads the live state first, sees that the stack changed since the last reconcile, and stops rather than overwriting the change blindly: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile basic_infrastructure.pkl ``` Instead of applying, formae opens an interactive screen listing every resource that drifted and lets you decide what to do, right here:
  formae apply                                                                                  reconcile rejected
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  Your infrastructure changed outside formae since the last reconcile.

  Keep a change: extract the resource, fold it into your code, then re-apply.
  Press r to discard all out-of-band changes and re-apply your code with --force.

  Stack: lifeline (1 change)
  ──────────────────────────────────────────────────────────
  ▸ \[x] update  lifeline-igw (AWS::EC2::InternetGateway)

  1 selected
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  e: extract selected  r: revert all (--force)  q: abort                                                   ?: help
formae will not clobber the change. From this screen you choose: press `e` to extract the selected resource and keep the change, or `r` to revert all out-of-band changes. You now decide: absorb the change or discard it.
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, just ask whether your code still matches reality:
Conversation
You
Is my codebase still in sync with what's actually running?
Assistant
Not quite. Since your last reconcile, one resource on the lifeline stack changed outside formae: the internet gateway lifeline-igw was updated. Something added a tag to it in the console. Do you want to absorb that change into your forma, or discard it?
## Absorb the change into your code The tag is worth keeping, so make your code describe it. This is the path formae offers on the rejection screen: extract the live resource, fold the change into your forma, and re-apply. On the rejection screen, with `lifeline-igw` selected, press `e`. formae asks where to write the resource and extracts it live, tag and all:
  formae apply                                                                                  reconcile rejected
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  Extracting 1 resource as code:
    lifeline-igw (AWS::EC2::InternetGateway)

  Extract to: ./extracted-drift.pkl█

  Don't apply this file directly. It holds only the selected resource(s), so
  reconciling it would delete every other resource in the stack.

  Copy the values you want to keep into your forma, then re-apply with --force:
    formae apply --mode reconcile --force basic\_infrastructure.pkl
Press enter to confirm. formae writes the file and reminds you that it holds only the selected resource, so you fold the values into your own forma rather than applying the file directly:
Extracted 1 resource(s) to ./extracted-drift.pkl
Fold the values you want to keep into your forma, then re-apply with --force:
  formae apply --mode reconcile --force basic\_infrastructure.pkl
Copy the tag into the gateway's definition in your own code. In the Lifeline example the gateway lives in `network_resources.pkl`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} hidden igw: internetgateway.InternetGateway = new { label = "lifeline-igw" tags { new { key = "Name"; value = formae.value(name + "-igw").setOnce } new { key = "Owner"; value = "platform-team" } } } ``` Reconcile again with `--force`. Your code now matches reality, so this re-applies your forma (tag included) and clears the drift flag on the stack: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --force basic_infrastructure.pkl ``` The tag is now part of your committed infrastructure, not drift. Your code and your cloud describe the same thing again.
Conversation
You
Absorb it. That tag should be part of our code going forward.
Assistant
I extracted the live internet gateway and added its Owner = platform-team tag to the gateway definition in network\_resources.pkl. Reconciling now reports no changes, so your forma and the running stack agree again and the tag is under management. Want me to commit the change?
You
Yes, commit it.
## Or discard the change If the change was a mistake or a temporary hack, do the opposite from the same rejection screen: press `r` to revert all out-of-band changes. formae shows exactly what it will undo, down to the field, and asks you to confirm:
  formae apply                                                                                  reconcile rejected
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  This will revert ALL out-of-band changes by re-applying with --force:

    revert  lifeline-igw (AWS::EC2::InternetGateway)      Tags

  Are you sure? This cannot be undone. (y/N)
Confirm with `y` and formae re-applies your unchanged code with `--force`, removing the tag and returning the gateway to exactly what your code describes. You can also run `formae apply --mode reconcile --force basic_infrastructure.pkl` directly. Reverting overwrites every out-of-band change on the stack, not just the one you have in mind. Extract and review the changes first if you are not certain what you are discarding. ## See also * [Incidents and recovery](/documentation/guides/incidents-and-recovery): absorb or discard a change you made yourself with a patch during an incident. * [Synchronization](/documentation/concepts/synchronization): how the agent reads live state and detects out-of-band changes. * [Apply modes](/documentation/concepts/apply-modes): reconcile versus patch, and why a soft reconcile stops on drift. # Everyday changes Source: https://docs.formae.io/documentation/guides/everyday-changes The routine operations once your infrastructure is under formae management: inspect it, change it surgically, and tear it down. Once your infrastructure is under management, most of your work is routine: look at what you have, find a specific resource, make a small change without disturbing the rest, and remove what you no longer need. This guide collects those everyday operations. ## See what formae manages List everything under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:true" ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 ╭─────────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─╯             ╰────────────────────────────────────────────────────────────────────────────────────────────────────

Label ▲                   Stack            Type                          NativeID                           
────────────────────────────────────────────────────────────────────────────────────────────────────────────
lifeline-alb-http-ingress lifeline         AWS::EC2::SecurityGroupIngresssgr-0ffb678dce902c817              
lifeline-alb-sg           lifeline         AWS::EC2::SecurityGroup       sg-00ec7ea75992bcc27               
lifeline-igw              lifeline         AWS::EC2::InternetGateway     igw-04356683dcee09288              
lifeline-igw-attachment   lifeline         AWS::EC2::VPCGatewayAttachmentIGW|vpc-043bbacbd0ebea8e5          
lifeline-public-route     lifeline         AWS::EC2::Route               rtb-01d9445d47e4f00f8|0.0.0.0/0|Ga…
lifeline-public-rt        lifeline         AWS::EC2::RouteTable          rtb-01d9445d47e4f00f8              
lifeline-public-subnet-1  lifeline         AWS::EC2::Subnet              subnet-0451bdccb16a96a55           
lifeline-public-subnet-1-…lifeline         AWS::EC2::SubnetRouteTableAss…rtbassoc-02a56640c3d1fd589         
lifeline-public-subnet-2  lifeline         AWS::EC2::Subnet              subnet-05c570615c4b79d89           
lifeline-public-subnet-2-…lifeline         AWS::EC2::SubnetRouteTableAss…rtbassoc-0603dbe3b5c152211         
lifeline-task-http-ingresslifeline         AWS::EC2::SecurityGroupIngresssgr-0f5bc7f88105a5f70              
lifeline-task-sg          lifeline         AWS::EC2::SecurityGroup       sg-037010e17da032dcf               
lifeline-vpc              lifeline         AWS::EC2::VPC                 vpc-043bbacbd0ebea8e5              

Showing 13 of 13 resources (filtered)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  / managed:true                                                                                     /: edit query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                       ?: help
The list shows every managed resource. Above 200 results formae shows the first 200 and asks you to narrow the query. Filter by `stack`, `type`, `label`, or `target`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="stack:production" formae inventory resources --query="stack:production type:AWS::EC2::SecurityGroup" ``` `formae inventory` opens on the Resources tab, but it is a tabbed view. Press `1`-`4` to switch between Resources, Targets, Stacks, and Policies, or run a subcommand to jump straight to one. `formae inventory stacks` opens on Stacks:
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                             ╭──────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─────────────────────────────╯          ╰───────────────────────────────────────────────────────────────────────────

Label ▲                   Description                                     Policies                            
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
lifeline                  Stack for the lifeline showcase                 none                                

Showing 1 of 1 stacks
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                       ?: help
`formae inventory targets` opens the Targets tab:
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                ╭───────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
────────────────╯           ╰───────────────────────────────────────────────────────────────────────────────────────

Label ▲                  Namespace             Discoverable    Config                                       
────────────────────────────────────────────────────────────────────────────────────────────────────────────
aws-target               AWS                   yes             Region: us-east-1, Type: AWS                 

Showing 1 of 1 targets
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                       ?: help
## Find and inspect a resource Search by any attribute, or combine filters: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="label:api-sg" formae inventory resources --query="stack:production type:AWS::EC2::SecurityGroup" ``` Type queries are case-insensitive; stack and label values are case-sensitive. To read a resource's full configuration, extract it to Pkl: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract --query="label:api-sg" api-sg.pkl ``` ## Make a change Your stack's forma is the source of truth. To change something, edit the resource in that forma and reconcile: formae compares your forma against what is deployed and applies just the difference. Reconcile is the mode you reach for on routine changes, so run `formae apply --mode reconcile`. For urgent one-off fixes during an incident, reach for patch instead (see [Incidents and recovery](/documentation/guides/incidents-and-recovery)). Change the resource in your stack's forma. For example, scale a deployment from one replica to two: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} spec { replicas = 2 // ...the rest of the deployment unchanged } ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile your-stack.pkl ``` formae shows the plan, here a single `spec.replicas` change, and asks you to confirm before applying. Use `--yes` to skip the prompt. Reconcile compares against the whole stack, so keep the forma complete: a resource you remove from it is destroyed. With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, describe the change and let the assistant edit the forma and reconcile:
Conversation
You
Scale the chat-ui deployment to 2 replicas.
Assistant
I'll update the forma and reconcile, simulating first: the plan changes spec.replicas on the chat-ui deployment in vllm-chat from 1 to 2, with nothing else affected. Apply it?
You
Yes.
Assistant
Done. chat-ui is now running 2 replicas.
## Tear down a stack To remove everything in a stack, destroy it. formae shows the full deletion plan and asks you to confirm before deleting anything, in dependency order (dependents before their dependencies): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --query="stack:my-app" ```
  formae destroy                                                                             query: stack:lifeline
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  - 13 delete

  ▌ Resources
  Operation ▲   Label                                                       Type
  - delete      lifeline-igw                                                AWS::EC2::InternetGateway
  - delete      lifeline-vpc                                                AWS::EC2::VPC
  - delete      lifeline-public-rt                                          AWS::EC2::RouteTable
  - delete      lifeline-igw-attachment                                     AWS::EC2::VPCGatewayAttachment
  - delete      lifeline-task-sg                                            AWS::EC2::SecurityGroup
  - delete      lifeline-alb-sg                                             AWS::EC2::SecurityGroup
  - delete      lifeline-public-route                                       AWS::EC2::Route
  - delete      lifeline-public-subnet-1                                    AWS::EC2::Subnet
  - delete      lifeline-public-subnet-2                                    AWS::EC2::Subnet
  - delete      lifeline-alb-http-ingress                                   AWS::EC2::SecurityGroupIngress
      ↓ show 10 more (3 remaining)

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will delete 13 resource(s).
Do you want to continue? (y/N)  ?: help
Use `--yes` to skip the confirmation (for scripts). You can also destroy by pointing at the forma: `formae destroy my-app.pkl`. The stack is removed once its last resource is gone; unmanaged (discovered) resources are untouched. ## See also * [Apply modes](/documentation/concepts/apply-modes): patch versus reconcile, and how drift is handled. * [Stack](/documentation/concepts/stack): how stacks group resources. * [Explore your infrastructure](/documentation/guides/explore-your-infrastructure): view what is not yet managed. # Explore your infrastructure Source: https://docs.formae.io/documentation/guides/explore-your-infrastructure See what already exists in your infrastructure with formae, without changing anything. The safest way to get started with formae is read-only. Point it at a cloud account and it discovers and catalogs everything running there as [unmanaged resources](/documentation/concepts/resources), touching nothing until you ask it to. This is how you get the lay of the land before you adopt or deploy. **Before you start:** you need a discoverable target for the account you want to explore. If you haven't set one up, do [Create a target](/documentation/guides/create-a-target) first (targets are discoverable by default). formae scans each discoverable target right after you apply it, and again every few minutes. List your targets to see which ones it is scanning: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory targets ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                ╭───────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
────────────────╯           ╰───────────────────────────────────────────────────────────────────────────────────────────

Label ▲                   Namespace              Discoverable    Config                                         
────────────────────────────────────────────────────────────────────────────────────────────────────────────────
eu-north-1                AWS                    yes             Region: eu-north-1, Type: AWS                  

Showing 1 of 1 targets
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                           ?: help
The output has a discoverable column; those are the targets formae scans. The first scan of a large account can take a few minutes.
List everything formae discovered but is not managing: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:false" ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 ╭─────────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─╯             ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Label ▲                      Stack              Type                             NativeID                               
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
demo-igw-1                   ⚠ unmanaged        AWS::EC2::InternetGateway        igw-08143e60f8d577b3a                  
demo-igw-2                   ⚠ unmanaged        AWS::EC2::InternetGateway        igw-04aef1b32b5c06823                  
igw-02394c20322e9a98e        ⚠ unmanaged        AWS::EC2::InternetGateway        igw-02394c20322e9a98e                  

Showing 3 of 3 resources (filtered)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  / managed:false                                                                                                /: edit query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                                   ?: help
The list shows the first 10 by default; raise it with `--max-results`, and filter by attribute to focus: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="managed:false type:AWS::EC2::InternetGateway" --max-results=50 ``` Nothing here is under management. These are resources that exist in your cloud, catalogued so you can decide what to do with them.
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, ask what you have and what is running in it:
Conversation
You
What targets do we have?
Assistant
You have three AWS targets, all discoverable: prod-us-east-1, prod-eu-west-1, and prod-us-east-2.
You
What's in them that formae isn't managing?
Assistant
Discovery found six unmanaged internet gateways on the unmanaged stack: legacy-igw-inline, legacy-igw-standalone, igw-0a1b2c3d4e5f6a7b8, igw-0f9e8d7c6b5a4f3e2, webapp-1558-igw-1, and webapp-abc1-igw. Nothing has been changed. Want to bring any under management?
You
Not yet, just getting the lay of the land.
Assistant
All good. They stay catalogued as unmanaged, and I'll flag anything new discovery turns up. Say the word when you want to adopt them.
Nothing is created, changed, or destroyed: the assistant only reads your targets and inventory.
## See also * [Create a target](/documentation/guides/create-a-target): register the account formae should scan. * [Bring resources under management](/documentation/guides/bring-resources-under-management): adopt what discovery found. * [Discovery](/documentation/concepts/discovery): how formae keeps its catalog in sync. # Extend the agent image Source: https://docs.formae.io/documentation/guides/extend-the-agent-image Build a derived agent image that layers extra plugins on top of the base formae image, then deploy it wherever you run the agent. The base formae agent image at `ghcr.io/platform-engineering-labs/formae` ships with the standard plugin set: `aws`, `azure`, `gcp`, `oci`, `ovh`, and `auth-basic`. If you deploy through one of the cloud install guides to manage one of those clouds, you are done, with no extra steps. To manage anything else (`grafana`, `datadog`, `k8s`, `gha`, `gitlab`, `compose`, `databricks`, and so on) build a derived image that layers the plugin on top of the base, then point your deployment at the derived image. `formae plugin install` runs locally on the host that invokes it; for a cloud-deployed agent the derived image is the supported path. ## Dockerfile pattern ```dockerfile theme={"languages":{"custom":["/languages/pkl.json"]}} ARG BASE_VERSION=0.87.1 FROM ghcr.io/platform-engineering-labs/formae:${BASE_VERSION} USER root RUN apt-get update && \ apt-get install -y --no-install-recommends jq curl && \ HOME=/home/pel /bin/bash -e -c "$(curl -fsSL https://hub.platform.engineering/get/setup.sh)" -- install --yes && \ apt-get purge -y --auto-remove jq curl && \ rm -rf /var/lib/apt/lists/* && \ /opt/pel/bin/formae clean --all RUN chown -R pel:pel /opt/pel USER pel WORKDIR /home/pel ``` Replace `` with the plugin you want. Add more plugins to the same `install` invocation by space-separating them. The `chown -R pel:pel /opt/pel` is required. The plugin install runs as `root` and can leave root-owned files under `/opt/pel`. The agent runs as `pel` and refuses to start when its install tree is root-owned, and the image has no `sudo`, so restore ownership at build time. ## Build and verify locally ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} docker build -t formae-extended:test . docker run --rm formae-extended:test /opt/pel/bin/formae plugin list ``` The plugin you added should appear alongside the standard set. To confirm the agent loads it at startup: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} docker run --rm -p 49684:49684 formae-extended:test # In another shell: docker logs 2>&1 | grep "Plugin registered" ``` Look for a `Plugin registered: namespace=` line for the plugin you added. ## Deploy the derived image Each install path pulls the agent image from its own registry. Push the derived image to the registry your deployment reads from, then point the deployment at it instead of `ghcr.io/.../formae:latest`. * **AWS (Bootstrap):** push to ECR, then pass the derived image as `--formae-image` when you [install or upgrade the agent](/documentation/guides/install-agent). * **Azure Container Instances:** push to ACR (or any registry the ACI managed identity can read), then set `--image` on the [Azure deploy](/documentation/guides/install-agent-azure). * **GCP Cloud Run:** push to an Artifact Registry repository in the same project, then set `--image` on the [GCP deploy](/documentation/guides/install-agent-gcp). * **Kubernetes / Helm:** push to any registry the cluster can pull from, then override the chart image with `--set image.repository=/formae-extended --set image.tag=` on the [Helm install](/documentation/guides/install-agent-helm). ## Verify the running agent `formae plugin list` reads the local `/opt/pel` tree only; there is no remote API for plugin inspection. To verify a cloud-deployed agent loaded the plugin, exec into the running container or read its startup logs. | Deployment | Verification | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | AWS ECS | `aws ecs execute-command --cluster --task --container formae-agent --interactive --command /bin/sh`, then `formae plugin list` (the service needs `--enable-execute-command`) | | Azure Container Instances | `az container exec --resource-group --name --exec-command /bin/sh`, then `formae plugin list` | | GCP Cloud Run | No interactive exec; use `gcloud logging read` and look for the `Plugin registered` line at startup | | Kubernetes | `kubectl exec deploy/ -- formae plugin list` | ## Known constraints * The formae CLI talks only to an agent on the same version. Whoever runs the CLI against a derived-image deployment must run the version the image ships. Update the local CLI with `formae update `. * `BASE_VERSION` pins the image tag, not the agent binary: a `:0.87.1` image can ship a slightly newer binary because the base resolves the version at build time. To pin the binary deterministically, add an explicit `setup.sh install --yes formae@` step before the plugin install. # Incidents and recovery Source: https://docs.formae.io/documentation/guides/incidents-and-recovery Fix a live infrastructure incident fast with a patch and settle up afterwards, and deal with a formae command that is itself the problem. Incidents come in two shapes. Sometimes your infrastructure is on fire and you need the smallest, fastest change that resolves it. Other times a formae command itself is the problem: one you triggered by mistake, or one that is stuck. This guide covers both. ## Fix a live incident with a patch Patch mode is built for firefighting. It only creates or updates the resources you name and never destroys anything, so the blast radius is exactly the resource or two you are touching. See [Apply modes](/documentation/concepts/apply-modes) for the full contrast with reconcile. Include only the resources you are adding or fixing. The stack and target must already exist: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/aws.pkl" import "@aws/s3/bucket.pkl" forma { new formae.Stack { label = "production" description = "Production infrastructure" } new formae.Target { label = "prod-target" config = new aws.Config { region = "us-east-1" } } new bucket.Bucket { label = "incident-logs" bucketName = "acme-incident-logs" } } ``` The file does not need to live in your repo. Create it anywhere, for example `/tmp/hotfix.pkl`, apply it now, and fold it into your code later. formae shows the plan and asks you to confirm, then streams the run in a live view: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode patch hotfix.pkl ```
  formae apply · patch                                                                                  hotfix.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 1 create

  ▌ Resources
  Operation ▲   Label                                                       Type
  + create      incident-logs                                               AWS::S3::Bucket

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1 resource(s).  Do
you want to continue? (y/N)  ?: help
Add `--yes` to skip the confirmation prompt when you are certain and need speed.
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, describe the fix and let the assistant patch it in:
Conversation
You
We need an S3 bucket for incident logs in the production stack right now, without touching anything else.
Assistant
I'll patch it in so nothing else is affected, and simulate first: it creates one resource, the S3 bucket incident-logs in production, and changes nothing else. Apply it?
You
Yes.
Assistant
Applied. incident-logs is live in production, and the rest of the stack is untouched. This is drift now, so fold it into your forma once the incident is over.
## Settle up after the fix A patch leaves your codebase describing something slightly different from reality: it created drift. Once the fire is out, you settle up in one of two ways, depending on whether the change was worth keeping. Until you do, the next soft reconcile on that stack detects the drift and fails, which is the reminder that code and reality disagree. ### Absorb the change, if you want to keep it The bucket you patched in is worth keeping, so make your code describe it. Extract the live resource into a forma file, fold it into your formae project, and reconcile. Now the stack matches your code again, and the resource is a first-class part of it. Pull the resource formae created during the incident into a forma file: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract --query "stack:production label:incident-logs" ./incident-logs.pkl ```
Extracted 1 resource to ./incident-logs.pkl

  incident-logs (AWS::S3::Bucket) on stack production
Add the extracted resource to your formae project, then reconcile so code and infrastructure agree: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl ```
╭─ formae apply ──────────────────────────────────────────────────────────────╮
 No changes needed                                                          
                                                                            
 The specified forma resources are up to date.                              
╰────────────────────────────────────────────────────────────────────────────╯
The reconcile is now a no-op for the bucket, because your code already describes it. The drift is gone.
Conversation
You
The incident is over. I patched an S3 bucket called incident-logs into production and never put it in my code. Pull it into my forma so we're back in sync.
Assistant
I extracted incident-logs from the production stack and added it to your forma. Reconciling now shows no changes for it, so your code and the live stack agree again. Want me to reconcile to confirm?
You
Yes.
Assistant
Done. The stack reconciles clean: incident-logs is now part of your committed infrastructure, not drift.
### Discard the change, if it was a throwaway If the patch was a temporary workaround you do not want to keep, do the opposite: reconcile your unchanged code back over it. Reconcile removes anything not in the forma, so the patched resource goes away and the stack returns to exactly what your code describes. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --force main.pkl ```
  formae apply · reconcile                                                                                main.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  - 1 delete

  ▌ Resources
  Operation ▲   Label                                                       Type
  - delete      incident-logs                                               AWS::S3::Bucket

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will delete 1 resource(s).  Do
you want to continue? (y/N)  ?: help
`--force` overwrites every out-of-band change on the stack, not just your patch. Reach for it only when you know what you are discarding. Extract and compare the current state first if you are unsure.
Conversation
You
That incident-logs bucket was a throwaway. Drop it and put production back to what my code says.
Assistant
Reconciling your unchanged forma over production removes incident-logs, the one resource not in your code, and leaves everything else as is. This discards the patch. Go ahead?
You
Go ahead.
Assistant
Done. incident-logs is gone and production matches your code exactly.
## See also * [Apply modes](/documentation/concepts/apply-modes): patch versus reconcile, and how drift is detected and resolved. * [Synchronization](/documentation/concepts/synchronization): how formae detects the out-of-band changes a patch introduces. * [Everyday changes](/documentation/guides/everyday-changes): the routine inspect, change, and tear-down operations. * [Troubleshoot a failed command](/documentation/guides/troubleshoot-a-failed-command): read the detailed status layout to see which resource broke and why. * [Cancel a running command](/documentation/guides/cancel-a-running-command): stop an in-progress command without leaving orphaned resources. * [CLI reference](/documentation/reference/cli): full flags for `apply`, `cancel`, `status`, `inventory`, and `extract`. # Install and run the agent Source: https://docs.formae.io/documentation/guides/install-agent Stand up a production formae agent on AWS with the formae-bootstrap installer, then connect your CLI to it. formae runs as a client and an agent. The agent lives in your infrastructure, executes changes, and keeps state in sync with your cloud; you use your local CLI to provision it once, then [point the CLI at it](/documentation/guides/manage-profiles) with a profile and hand off. See [Architecture](/documentation/concepts/architecture) for the client/agent model. The recommended production path is the open-source [`formae-bootstrap`](https://github.com/platform-engineering-labs/formae-bootstrap) installer. One apply stands up the whole agent (a VPC, an ECS Fargate task, and a PostgreSQL datastore) secure by default. This guide covers the AWS bootstrap path. For other clouds, see [Install on Azure](/documentation/guides/install-agent-azure) and [Install on GCP](/documentation/guides/install-agent-gcp). Express and standard-ECS AWS installs exist for finer control and will be documented separately. ## Choose an access mode * **Public (ALB)**: a public HTTPS endpoint fronted by an Application Load Balancer using your ACM certificate. Requires a domain you own. * **Tailscale (tailnet)**: private, reachable only over your Tailscale tailnet; the agent serves a trusted `*.ts.net` certificate. No public ingress. Basic auth is on in both modes. ## Prerequisites * The formae CLI, matching the agent image version. If you have not installed it, see the [Quick start](/documentation/get-started/quickstart). * AWS credentials for the target account. * `openssl` and `htpasswd` (from `apache2-utils` / `httpd-tools`), or `docker`, to generate the basic-auth credential. Clone the installer: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-bootstrap.git cd formae-bootstrap ``` ## Install You also need a domain you own and an ACM certificate for it, status `ISSUED`, in the same region as the ALB. Note the certificate ARN. Basic auth validates a bcrypt hash, produced locally: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** goes to the apply. RDS spin-up is the long pole (\~10 min); on a terminal the apply shows live progress. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile aws/bootstrap.pkl \ --access alb --region \ --cert-arn --domain agent.example.com \ --api-user formae --api-password-hash '' ``` To restrict who can reach the ALB, add `--allow-cidr ` (open by default). Add `agent.example.com` pointing at the ALB's DNS name (a Route53 alias record, or a DNS-only CNAME elsewhere). Connect via `https://`, not the raw `*.elb.amazonaws.com` name: the certificate only matches your domain. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws/scripts/write-bootstrap-profile.sh --profile bootstrap \ --domain agent.example.com --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. You also need a Tailscale tailnet with a `tag:formae` tag, HTTPS certificates enabled, and a reusable auth key carrying that tag: * Sign in at [login.tailscale.com/start](https://login.tailscale.com/start) with an identity provider (the free Personal plan is enough) and note your tailnet name (e.g. `tailXXXX.ts.net`). * **DNS tab:** enable **MagicDNS** and **HTTPS Certificates** (required for the `*.ts.net` certificate). * **Access controls → Tags:** create a tag named `formae` (no `tag:` prefix), owned by yourself. * **Settings → Keys → Generate auth key:** **Reusable** on, **Ephemeral** off, **Tags** → `tag:formae`. Copy the `tskey-auth-...` value. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** goes to the apply. RDS spin-up is the long pole (\~10 min); on a terminal the apply shows live progress. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile aws/bootstrap.pkl \ --access tailnet --region \ --ts-authkey '' --ts-hostname formae-bootstrap \ --api-user formae --api-password-hash '' ``` The agent joins your tailnet as `..ts.net` and serves the API over a trusted certificate on port `49684`. Confirm it joined in the Tailscale admin console under **Machines**. Your CLI machine must be on the same tailnet (a device can be on only one at a time; use `tailscale switch` / `tailscale login` to reach it): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws/scripts/write-bootstrap-profile.sh --profile bootstrap --access tailnet \ --fqdn formae-bootstrap..ts.net --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. ## Operate the agent Day-2 procedures for a bootstrapped agent. ### Update Upgrade the agent the same way you created it: re-apply `bootstrap.pkl` with a newer image. `--formae-image` is the version knob; find a target tag on [GitHub Releases](https://github.com/platform-engineering-labs/formae/releases). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile aws/bootstrap.pkl \ --formae-image ghcr.io/platform-engineering-labs/formae: ``` Pass the **same flags you applied with** (access mode, region, credentials, and the `alb`/`tailnet` options) so the reconcile changes only the image: formae plans a new task definition and a rolling service update. Then match your local CLI to the new version: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update ``` To roll back, re-apply with the previous image tag. **Keep the install you bootstrapped from.** The local install you ran `bootstrap.pkl` from holds the agent's own infrastructure (the VPC, database, and ECS service that *are* the agent) in its state. That state lives only there, so re-applying `bootstrap.pkl` to upgrade or change the agent depends on keeping that install and its datastore. ### Add extra plugins The bootstrap image ships the standard plugin set (AWS, Azure, GCP, OCI, OVH, and `auth-basic`). To manage anything else, build a derived image and pass it as `--formae-image`. See [Extend the agent image](/documentation/guides/extend-the-agent-image). ### Rotate Aurora credentials If you use Aurora as the datastore, its managed master-credential rotation (the default 30-day cycle) updates the cluster password but not the agent's `formae-config` secret that holds the connection string, so the agent fails to connect after the next rotation. The simplest mitigation is to disable managed rotation and rotate on your own schedule: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws rds modify-db-cluster \ --db-cluster-identifier formae-db \ --manage-master-user-password false \ --apply-immediately ``` Address this before the first 30-day mark. ### Back up state The agent is stateless; everything recoverable lives in the datastore. Aurora's default automated backups (1-day retention with point-in-time recovery) cover the formae state. For longer retention, raise the cluster's `--backup-retention-period` or run scheduled `aws rds create-db-cluster-snapshot` jobs. ### Shell into the agent The service runs with ECS Exec enabled, so you can open a shell inside the running Fargate container (requires the AWS Session Manager plugin locally): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} aws ecs execute-command --region \ --cluster \ --task "$(aws ecs list-tasks --region --cluster --query 'taskArns[0]' --output text)" \ --container formae-agent --interactive --command /bin/sh ``` ### Tune for production The bootstrap installer favors a lean default. For production, edit `aws/bootstrap.pkl` / `aws/vars.pkl`: * **Multi-AZ database:** the RDS instance is single-AZ by default. Set `multiAZ = true` for automatic failover (roughly doubles RDS cost and deploy time). * **Storage:** `maxAllocatedStorage` caps storage autoscaling at 100 GB; raise it for larger inventories. * **Ingress (`alb` mode):** scope the ALB with `--allow-cidr`, or use `tailnet` mode for no public surface at all. * **Sizing:** choose a larger `--size` as your inventory grows (see the sizing table below). ### Tear down Destroy in two steps; `destroy` removes the resources but not the target registration: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --yes aws/bootstrap.pkl formae destroy --yes aws/destroy-target.pkl ``` ## Reference Flags for `formae apply ... aws/bootstrap.pkl`: | Flag | Mode | Notes | | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `--access` | both | `alb` (default) or `tailnet` | | `--region` | both | region for the apply | | `--size` | both | Fargate t-shirt size (see below); default `small` | | `--database` | both | `new` (default) provisions RDS; `existing` uses your DB (with `--db-host` / `--db-user` / `--db-name` / `--db-password-secret-arn`) | | `--formae-image` | both | agent image reference, the version knob | | `--api-user` | both | basic-auth username (default `formae`) | | `--api-password-hash` | both | bcrypt hash from `gen-api-credential.sh` | | `--cert-arn` | alb | ACM certificate ARN (same region) | | `--domain` | alb | hostname clients connect to; must match the certificate | | `--allow-cidr` | alb | restrict ALB ingress to one CIDR (open by default) | | `--ts-authkey` | tailnet | reusable Tailscale auth key | | `--ts-hostname` | tailnet | tailnet (MagicDNS) hostname (defaults to the stack name) | Passing a flag that belongs to the other mode fails fast with a clear message. `--size` maps to a Fargate-valid CPU/memory pair. Memory has a \~2 GB floor regardless of inventory size, because the agent image loads all its bundled resource plugins at once. | size | vCPU / memory | rough capacity | | -------- | ------------- | ----------------------- | | `small` | 0.5 / 2 GB | up to \~1,000 resources | | `medium` | 1 / 2 GB | \~1,000 to 5,000 | | `large` | 2 / 4 GB | \~5,000 to 10,000 | | `xlarge` | 4 / 8 GB | \~10,000 to 20,000 | ## See also * [Manage profiles](/documentation/guides/manage-profiles): connect the CLI to the agent you just started, and switch between environments. * [Configuration](/documentation/reference/configuration): agent and CLI settings, including authentication. * [Architecture](/documentation/concepts/architecture): how the client, agent, and plugins fit together. # Install and run the agent on Azure Source: https://docs.formae.io/documentation/guides/install-agent-azure Stand up a production formae agent on Azure with the formae-bootstrap installer, then connect your CLI to it. formae runs as a client and an agent. The agent lives in your infrastructure, executes changes, and keeps state in sync with your cloud; you use your local CLI to provision it once, then [point the CLI at it](/documentation/guides/manage-profiles) with a profile and hand off. See [Architecture](/documentation/concepts/architecture) for the client/agent model. The recommended path is the open-source [`formae-bootstrap`](https://github.com/platform-engineering-labs/formae-bootstrap) installer. One apply stands up the whole agent (a resource group, a VNet, a private Azure Database for PostgreSQL Flexible Server, and a VM running the agent container), secure by default. This guide covers the Azure bootstrap path. For other clouds, see [Install on AWS](/documentation/guides/install-agent) and [Install on GCP](/documentation/guides/install-agent-gcp). ## Choose an access mode Compute is always a VM; `--access` decides how clients reach it. Basic auth is on in every mode. * **Public (`public`, default)**: the agent's own public IP, with the API port opened to `--allowed-cidr`. The agent terminates HTTPS itself with a self-signed certificate; the connect profile sets `insecureSkipVerify` so the CLI accepts it. * **App Gateway (`appgw`)**: an Application Gateway v2 fronts the VM on `:443`, terminating TLS with a Key Vault certificate (self-signed by default, or your own PFX for a browser-trusted cert). The Azure analog of the AWS `alb` mode. Requires a domain you own. * **Tailscale (`tailnet`)**: private, reachable only over your Tailscale tailnet; the agent serves a trusted `*.ts.net` certificate. No public ingress. ## Prerequisites * The formae CLI, matching the agent image version. If you have not installed it, see the [Quick start](/documentation/get-started/quickstart). * Local Azure credentials for the formae agent you run locally to perform the apply. The Azure plugin uses `DefaultAzureCredential`, so `az login` is enough (or set `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`). Restart the local agent after setting them. * A service principal the remote agent operates Azure with: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} az ad sp create-for-rbac --name formae-agent --role Contributor \ --scopes /subscriptions/ # note appId (--client-id), password (--client-secret), tenant (--tenant-id) ``` * An SSH public key (Azure requires one on the VM even though no inbound SSH is opened): `ssh-keygen -t ed25519` if you don't have one. * App Gateway mode: a domain you control, a globally-unique Key Vault name (3 to 24 chars), and the applying principal's objectId (`az ad sp show --id --query id -o tsv`). * Tailscale (tailnet mode only): a reusable auth key tagged `tag:formae`, with HTTPS certificates enabled on your tailnet. Clone the installer: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-bootstrap.git cd formae-bootstrap ``` ## Install Basic auth validates a bcrypt hash, produced locally; the script also prints a stable Postgres password: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} azure/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** and **db-password** go to the apply. Reuse the **same** db-password on every re-apply. PostgreSQL spin-up is the long pole; `--watch` streams progress. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile azure/bootstrap.pkl --access public \ --location \ --subscription-id --tenant-id \ --client-id --client-secret '' \ --api-user formae --api-password-hash '' --db-password '' \ --ssh-public-key "$(cat ~/.ssh/id_ed25519.pub)" --watch ``` To restrict who can reach the agent, add `--allowed-cidr ` (open by default). The agent is reachable at `..cloudapp.azure.com:49684`. The self-signed cert needs `-k` for curl; the profile script sets `insecureSkipVerify` for the CLI: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} curl -k https://formae-bootstrap..cloudapp.azure.com:49684/api/v1/health azure/scripts/write-bootstrap-profile.sh --profile bootstrap --access public \ --fqdn formae-bootstrap..cloudapp.azure.com --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. You also need a domain you own, a globally-unique Key Vault name, and the applying principal's objectId. For a browser-trusted cert, supply a PFX; omit it for a self-signed cert. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} azure/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** and **db-password** go to the apply. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile azure/bootstrap.pkl --access appgw \ --location \ --subscription-id --tenant-id \ --client-id --client-secret '' \ --domain agent.example.com --kv-name \ --applier-object-id "$(az ad sp show --id --query id -o tsv)" \ --api-user formae --api-password-hash '' --db-password '' \ --ssh-public-key "$(cat ~/.ssh/id_ed25519.pub)" --watch ``` For a browser-trusted cert, add `--cert-pfx "$(base64 -i cert.pfx)" --cert-password ''`. Point `agent.example.com`'s DNS at the gateway's public IP (`formae-bootstrap-gw..cloudapp.azure.com`), then verify (self-signed: `-k`; trusted PFX: drop `-k`): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} curl -k https://agent.example.com/api/v1/health ``` The profile targets port `443` in appgw mode: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} azure/scripts/write-bootstrap-profile.sh --profile bootstrap --access appgw \ --fqdn agent.example.com --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. Key Vault role assignments take a minute or two to propagate. If the first apply fails with a 403 on certificate creation, re-run the same command; it succeeds once the "Certificates Officer" grant lands. You also need a Tailscale tailnet with a `tag:formae` tag, HTTPS certificates enabled, and a reusable auth key carrying that tag: * Sign in at [login.tailscale.com/start](https://login.tailscale.com/start) with an identity provider (the free Personal plan is enough) and note your tailnet name (e.g. `tailXXXX.ts.net`). * **DNS tab:** enable **MagicDNS** and **HTTPS Certificates** (required for the `*.ts.net` certificate). * **Access controls → Tags:** create a tag named `formae` (no `tag:` prefix), owned by yourself. * **Settings → Keys → Generate auth key:** **Reusable** on, **Ephemeral** off, **Tags** → `tag:formae`. Copy the `tskey-auth-...` value. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} azure/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** and **db-password** go to the apply. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile azure/bootstrap.pkl --access tailnet \ --location \ --subscription-id --tenant-id \ --client-id --client-secret '' \ --ts-authkey '' --ts-hostname formae-bootstrap \ --api-user formae --api-password-hash '' --db-password '' \ --ssh-public-key "$(cat ~/.ssh/id_ed25519.pub)" --watch ``` The agent joins your tailnet as `..ts.net` and serves the API over a trusted certificate on port `49684`. Confirm it joined in the Tailscale admin console under **Machines**. Your CLI machine must be on the same tailnet (use `tailscale switch` / `tailscale login` to reach it): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} azure/scripts/write-bootstrap-profile.sh --profile bootstrap --access tailnet \ --fqdn formae-bootstrap..ts.net --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. ## Operate the agent Day-2 procedures for a bootstrapped agent. ### Update Upgrade the agent the same way you created it: re-apply `bootstrap.pkl` with a newer image. `--formae-image` is the version knob; find a target tag on [GitHub Releases](https://github.com/platform-engineering-labs/formae/releases). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile azure/bootstrap.pkl \ --formae-image ghcr.io/platform-engineering-labs/formae: \ --watch ``` Pass the **same flags you applied with**, and critically the **same `--db-password`** (it is set once at DB creation and carried in the agent config), so the reconcile changes only the image. Then match your local CLI to the new version: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update ``` To roll back, re-apply with the previous image tag. **Keep the install you bootstrapped from.** The local install you ran `bootstrap.pkl` from holds the agent's own infrastructure (the resource group, PostgreSQL database, and VM that *are* the agent) in its state. That state lives only there, so re-applying `bootstrap.pkl` to upgrade or change the agent depends on keeping that install and its datastore. ### Add extra plugins The bootstrap image ships the standard plugin set (AWS, Azure, GCP, OCI, OVH, and `auth-basic`). To manage anything else, build a derived image and pass it as `--formae-image`. See [Extend the agent image](/documentation/guides/extend-the-agent-image). ### Tune for production The bootstrap installer favors a lean default. For production, choose a larger `--size` as your inventory grows (see the sizing table below), scope `public` mode ingress with `--allowed-cidr`, and prefer `appgw` with your own PFX (or `tailnet`) for a trusted certificate. ### Tear down Destroy the stack, then deregister the target: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --query "stack:formae-bootstrap-azure" formae apply --mode destroy azure/destroy-target.pkl ``` In `tailnet` mode, the agent's Tailscale node identity lives on the VM's OS disk and does not survive teardown. After destroying, delete the stale node manually in the Tailscale admin console. ## Reference Flags for `formae apply ... azure/bootstrap.pkl`: | Flag | Mode | Notes | | ------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | | `--access` | all | `public` (default), `appgw`, or `tailnet` | | `--location` | all | Azure region; default `eastus` | | `--name` | all | resource name prefix (default `formae-bootstrap`); **must be globally unique**; drives the Postgres FQDN and public DNS label | | `--size` | all | t-shirt size (see below); default `small` | | `--subscription-id` | all | target subscription (required) | | `--tenant-id` / `--client-id` / `--client-secret` | all | remote agent's service principal (required) | | `--api-user` / `--api-password-hash` | all | basic-auth username (default `formae`) and bcrypt hash | | `--db-password` | all | stable Postgres admin password; reuse on every re-apply | | `--ssh-public-key` | all | admin key on the VM (no inbound SSH is opened) | | `--vnet-cidr` / `--subnet-cidr` | all | address space; default `10.100.0.0/16` / `10.100.1.0/24` | | `--formae-image` | all | agent image reference, the version knob | | `--allowed-cidr` | public | source CIDR allowed to the API (open by default) | | `--domain` | appgw | hostname on the cert; point its DNS at the gateway IP | | `--kv-name` | appgw | globally-unique Key Vault name (3 to 24 chars) the stack creates | | `--applier-object-id` | appgw | objectId of the applying principal (granted Certificates Officer) | | `--cert-pfx` / `--cert-password` | appgw | optional base64 PFX + password for a trusted cert (else self-signed) | | `--ts-authkey` | tailnet | reusable Tailscale auth key (`tag:formae`) | | `--ts-hostname` | tailnet | tailnet (MagicDNS) hostname (defaults to `--name`) | Passing a flag that belongs to another mode is ignored or fails fast with a clear message. `--size` maps to an Azure VM size. Memory has a \~2 GB floor regardless of inventory size, because the agent image loads all its bundled resource plugins at once. Sizes use the Dsv6 family, which has default vCPU quota on fresh subscriptions. | size | VM size | vCPU / memory | rough capacity | | -------- | ----------------- | ------------- | ----------------------- | | `small` | `Standard_D2s_v6` | 2 / 8 GB | up to \~1,000 resources | | `medium` | `Standard_D2s_v6` | 2 / 8 GB | \~1,000 to 5,000 | | `large` | `Standard_D4s_v6` | 4 / 16 GB | \~5,000 to 10,000 | | `xlarge` | `Standard_D8s_v6` | 8 / 32 GB | \~10,000 to 20,000 | ## See also * [Manage profiles](/documentation/guides/manage-profiles): connect the CLI to the agent you just started, and switch between environments. * [Configuration](/documentation/reference/configuration): agent and CLI settings, including authentication. * [Architecture](/documentation/concepts/architecture): how the client, agent, and plugins fit together. # Install the agent with Docker Source: https://docs.formae.io/documentation/guides/install-agent-docker Run the formae agent as a container from the published image, for local evaluation or a self-managed host. The [one-command bootstrap installer](/documentation/guides/install-agent) currently targets AWS. To run the agent anywhere Docker runs (your laptop or a self-managed host), start it from the published container image. This is the interim manual path until Docker lands in the bootstrap installer. ## Prerequisites * The formae CLI on your local machine (see the [Quick start](/documentation/get-started/quickstart)). * Docker installed and running. The formae agent is published as a container image: ```text theme={"languages":{"custom":["/languages/pkl.json"]}} ghcr.io/platform-engineering-labs/formae ``` Supported architectures: `linux/amd64` and `linux/arm64`. ## Run with Docker ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} docker run -d -p 49684:49684 ghcr.io/platform-engineering-labs/formae:latest ``` The agent API is available on port `49684`. ## Run a specific version ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} docker run -d -p 49684:49684 ghcr.io/platform-engineering-labs/formae:0.82.0 ``` ## Custom configuration Mount a custom config file and point the agent at it with `--config`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} docker run -d -p 49684:49684 \ -v /path/to/formae.conf.pkl:/config/formae.conf.pkl:ro \ ghcr.io/platform-engineering-labs/formae:latest \ formae agent start --config /config/formae.conf.pkl ``` The config file is Pkl and amends the bundled schema. For example, to point the agent at PostgreSQL instead of the default SQLite: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" agent { datastore { datastoreType = "postgres" postgres { host = "" port = 5432 user = "" password = "" database = "formae" } } } ``` See the [Configuration](/documentation/reference/configuration) reference for all available options. ## Connect your CLI The CLI runs on your machine, evaluates Pkl locally, and sends the result to the agent API. No forma files need to be loaded into the container. When you run the container locally with `-p 49684:49684`, the agent is reachable at `http://localhost:49684`, which is exactly where the `default` [profile](/documentation/guides/manage-profiles) already points. So a local container needs no profile setup at all: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent formae apply --mode reconcile your-forma.pkl ``` If the agent runs on a remote host, add a [profile](/documentation/guides/manage-profiles) whose `cli.api.url` is that host, on port `49684`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} cli { api { url = "http://" port = 49684 } } ``` Then target it per command: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent --profile docker formae apply --mode reconcile your-forma.pkl --profile docker ``` ## Next steps * [Manage profiles](/documentation/guides/manage-profiles): switch the CLI between this agent and others. * [Configuration](/documentation/reference/configuration): agent and CLI options. # Install and run the agent on GCP Source: https://docs.formae.io/documentation/guides/install-agent-gcp Stand up a production formae agent on GCP with the formae-bootstrap installer, then connect your CLI to it. formae runs as a client and an agent. The agent lives in your infrastructure, executes changes, and keeps state in sync with your cloud; you use your local CLI to provision it once, then [point the CLI at it](/documentation/guides/manage-profiles) with a profile and hand off. See [Architecture](/documentation/concepts/architecture) for the client/agent model. The recommended path is the open-source [`formae-bootstrap`](https://github.com/platform-engineering-labs/formae-bootstrap) installer. One apply stands up the whole agent (a VPC, a Cloud SQL for PostgreSQL datastore, and a GCE VM running the agent container), secure by default. This guide covers the GCP bootstrap path. For other clouds, see [Install on AWS](/documentation/guides/install-agent) and [Install on Azure](/documentation/guides/install-agent-azure). ## Choose an access mode Compute is always a GCE VM on Container-Optimized OS, running the agent container plus a Cloud SQL Auth Proxy sidecar. `--access` decides how clients reach it. Basic auth is on in both modes. * **Tailscale (`tailnet`, default)**: private, reachable only over your Tailscale tailnet; the agent serves a trusted `*.ts.net` certificate. No public ingress. * **Public (`public`)**: a global external HTTPS load balancer fronts the agent. Requires a domain you own. ## Prerequisites * The formae CLI, matching the agent image version. If you have not installed it, see the [Quick start](/documentation/get-started/quickstart). * A GCP project, with the required APIs enabled: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcloud services enable compute.googleapis.com sqladmin.googleapis.com \ secretmanager.googleapis.com --project ``` * Local credentials for the formae GCP plugin: either `gcloud auth application-default login` (user ADC, simplest) or `export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa-key.json`. Restart the agent after setting them so the plugin picks them up. * Tailscale (tailnet mode only): a reusable auth key tagged `tag:formae`, with HTTPS certificates enabled on your tailnet. Clone the installer: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-bootstrap.git cd formae-bootstrap ``` ## Install You also need a Tailscale tailnet with a `tag:formae` tag, HTTPS certificates enabled, and a reusable auth key carrying that tag: * Sign in at [login.tailscale.com/start](https://login.tailscale.com/start) with an identity provider (the free Personal plan is enough) and note your tailnet name (e.g. `tailXXXX.ts.net`). * **DNS tab:** enable **MagicDNS** and **HTTPS Certificates** (required for the `*.ts.net` certificate). * **Access controls → Tags:** create a tag named `formae` (no `tag:` prefix), owned by yourself. * **Settings → Keys → Generate auth key:** **Reusable** on, **Ephemeral** off, **Tags** → `tag:formae`. Copy the `tskey-auth-...` value. Basic auth validates a bcrypt hash, produced locally: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcp/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** goes to the apply. Also pick a stable Cloud SQL postgres password. Cloud SQL spin-up is the long pole; `--watch` streams progress. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile gcp/bootstrap.pkl \ --project \ --api-user formae --api-password-hash '' \ --db-password '' \ --ts-authkey '' --ts-hostname formae-bootstrap \ --watch ``` The agent joins your tailnet as `..ts.net` and serves the API over a trusted certificate on port `49684`. Confirm it joined in the Tailscale admin console under **Machines**. Your CLI machine must be on the same tailnet (a device can be on only one at a time; use `tailscale switch` / `tailscale login` to reach it): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcp/scripts/write-bootstrap-profile.sh --profile bootstrap \ --fqdn formae-bootstrap..ts.net --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. You also need a domain you own. The recommended certificate is a **Google-managed** one: pass `--domain` and Google provisions and auto-renews a publicly-trusted cert: no cert files, no renewal to manage. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcp/scripts/gen-api-credential.sh ``` Keep the printed **password** for the connect step; the **hash** goes to the apply. Also pick a stable Cloud SQL postgres password. Cloud SQL spin-up is the long pole; `--watch` streams progress. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile gcp/bootstrap.pkl --access public \ --project \ --domain formae.example.com \ --api-user formae --api-password-hash '' \ --db-password '' \ --watch ``` Instead of `--domain`, you can reference a pre-created cert with `--cert-name `, or bring your own PEM with `--cert-file ./fullchain.pem --key-file ./privkey.pem`. Pass exactly one of the three. The stack reserves a global anycast address and prints it. Add an `A` record for `formae.example.com` pointing at it. A Google-managed cert stays `PROVISIONING` until DNS resolves and Google validates ownership (\~15 to 60 min), then goes `ACTIVE`. Verify: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} curl https://formae.example.com/api/v1/health # 200, auth-exempt curl -u formae:'' https://formae.example.com/api/v1/agent # 200 ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcp/scripts/write-bootstrap-profile.sh --profile bootstrap \ --fqdn formae.example.com --port 443 --user formae --password '' formae status agent --profile bootstrap ``` That writes a [profile](/documentation/guides/manage-profiles) carrying the agent's URL so your CLI can reach it. ## Operate the agent Day-2 procedures for a bootstrapped agent. ### Update Upgrade the agent the same way you created it: re-apply `bootstrap.pkl` with a newer image. `--formae-image` is the version knob; find a target tag on [GitHub Releases](https://github.com/platform-engineering-labs/formae/releases). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile gcp/bootstrap.pkl \ --formae-image ghcr.io/platform-engineering-labs/formae: \ --watch ``` Pass the **same flags you applied with** (access mode, region, credentials) so the reconcile changes only the image. Then match your local CLI to the new version: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update ``` To roll back, re-apply with the previous image tag. **Keep the install you bootstrapped from.** The local install you ran `bootstrap.pkl` from holds the agent's own infrastructure (the VPC, Cloud SQL database, and VM that *are* the agent) in its state. That state lives only there, so re-applying `bootstrap.pkl` to upgrade or change the agent depends on keeping that install and its datastore. ### Add extra plugins The bootstrap image ships the standard plugin set (AWS, Azure, GCP, OCI, OVH, and `auth-basic`). To manage anything else, build a derived image and pass it as `--formae-image`. See [Extend the agent image](/documentation/guides/extend-the-agent-image). ### Tune for production The bootstrap installer favors a lean default. For production, choose a larger `--size` as your inventory grows (see the sizing table below), and scope public ingress to your load balancer with a Google-managed certificate rather than a self-signed one. ### Tear down Destroy the stack, then deregister the target: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --query "stack:formae-gcp-bootstrap" formae apply --mode destroy gcp/destroy-target.pkl ``` You may need to run `destroy` twice. The first pass deletes the agent VM, but its Cloud SQL Auth Proxy connections take a moment to drain, so the `formae` database delete can fail with `pq: database "formae" is being accessed by other users`. Re-running `destroy` once the sessions have been reaped completes the teardown. ## Reference Flags for `formae apply ... gcp/bootstrap.pkl`: | Flag | Mode | Notes | | ---------------------------- | ------- | ---------------------------------------------------------- | | `--project` | all | GCP project ID (required) | | `--access` | all | `tailnet` (default) or `public` | | `--size` | all | t-shirt size (see below); default `small` | | `--region` / `--zone` | all | location; default `us-central1` / `us-central1-a` | | `--subnet-cidr` | all | private subnet range; default `10.100.1.0/24` | | `--formae-image` | all | agent image reference, the version knob | | `--api-user` | all | basic-auth username (default `formae`) | | `--api-password-hash` | all | bcrypt hash from `gen-api-credential.sh` | | `--db-password` | all | stable Cloud SQL postgres password | | `--name` | all | resource name prefix (default `formae-bootstrap`) | | `--domain` | public | Google-managed cert hostname; also the DNS name for the LB | | `--cert-name` | public | full selfLink of a pre-created global `SslCertificate` | | `--cert-file` + `--key-file` | public | PEM chain + key; creates a `SELF_MANAGED` cert in-stack | | `--ts-authkey` | tailnet | reusable Tailscale auth key (`tag:formae`) | | `--ts-hostname` | tailnet | tailnet (MagicDNS) hostname (defaults to `--name`) | Passing a flag that belongs to another mode fails fast with a clear message. `--size` maps to a GCE machine type. Memory has a \~2 GB floor regardless of inventory size, because the agent image loads all its bundled resource plugins at once. | size | VM machine type | rough capacity | | -------- | ----------------------- | ----------------------- | | `small` | `e2-small` (2 GB) | up to \~1,000 resources | | `medium` | `e2-medium` (4 GB) | \~1,000 to 5,000 | | `large` | `e2-standard-2` (8 GB) | \~5,000 to 10,000 | | `xlarge` | `e2-standard-4` (16 GB) | \~10,000 to 20,000 | ## See also * [Manage profiles](/documentation/guides/manage-profiles): connect the CLI to the agent you just started, and switch between environments. * [Configuration](/documentation/reference/configuration): agent and CLI settings, including authentication. * [Architecture](/documentation/concepts/architecture): how the client, agent, and plugins fit together. # Install the agent with Helm Source: https://docs.formae.io/documentation/guides/install-agent-helm Deploy the formae agent to a Kubernetes cluster with the formae Helm chart. The [one-command bootstrap installer](/documentation/guides/install-agent) currently targets AWS. To run the agent in Kubernetes, deploy it with the formae Helm chart. This is the interim manual path until Helm lands in the bootstrap installer. ## Prerequisites * A Kubernetes cluster. * Helm 3.x. * `kubectl` configured for your cluster. * The formae CLI on your local machine (see the [Quick start](/documentation/get-started/quickstart)). ## Install from source The formae Helm chart is installed from its source repository: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} git clone https://github.com/platform-engineering-labs/formae-helm.git cd formae-helm ``` Standalone (SQLite, good for evaluation): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} helm install formae . -f examples/formae-only.yaml ``` With PostgreSQL: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} helm install formae . -f examples/formae-db.yaml \ --set postgresql.auth.password= ``` Full monitoring stack (requires Prometheus and Grafana): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} helm install formae . -f examples/formae-db-grafana.yaml \ --set postgresql.auth.password= ``` ## Example values files | File | Description | | --------------------------------- | ------------------------------------ | | `formae-only.yaml` | SQLite, ephemeral storage | | `formae-only-persistent.yaml` | SQLite with PVC | | `formae-db.yaml` | In-cluster PostgreSQL | | `formae-db-grafana.yaml` | PostgreSQL + Grafana + OTel | | `formae-external-db.yaml` | External PostgreSQL | | `formae-external-db-grafana.yaml` | External PostgreSQL + Grafana + OTel | | `formae-aurora.yaml` | Aurora Data API | See the [chart README](https://github.com/platform-engineering-labs/formae-helm) for all configuration parameters. ## Connect your CLI The CLI runs on your machine, evaluates Pkl locally, and sends the result to the agent API. No forma files need to be loaded into the cluster. For quick access, port-forward the agent service. The agent then answers on `http://localhost:49684`, which is exactly where the `default` [profile](/documentation/guides/manage-profiles) already points, so no profile setup is needed: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} kubectl port-forward svc/formae 49684:49684 ``` With the port forward running, check connectivity and apply from your machine: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent formae apply --mode reconcile your-forma.pkl ``` For persistent access without port-forwarding, expose the agent with an ingress or a `LoadBalancer` service, then add a [profile](/documentation/guides/manage-profiles) whose `cli.api.url` is that endpoint, on port `49684`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} cli { api { url = "http://" port = 49684 } } ``` Then target it per command: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent --profile helm formae apply --mode reconcile your-forma.pkl --profile helm ``` ## Adding plugins beyond the defaults To add plugins the base image does not ship, build a derived image, push it to a registry the cluster can pull from, and override the chart's image on `helm install`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} helm install formae . -f examples/formae-only.yaml \ --set image.repository=/formae-extended \ --set image.tag= ``` ## Next steps * [Manage profiles](/documentation/guides/manage-profiles): switch the CLI between this agent and others. * [Configuration](/documentation/reference/configuration): agent and CLI options. # Manage profiles Source: https://docs.formae.io/documentation/guides/manage-profiles Switch the formae CLI between environments with named configuration profiles. A profile is a complete formae configuration for one environment: the agent endpoint the CLI talks to, plus targets, credentials, and any other settings. Profiles let you point the CLI at different environments (local-dev, staging, prod) without hand-editing or swapping config files. ## You start with a default profile A fresh install ships with a ready-to-use `default` profile pointed at a local agent. So if you [run the agent on your machine](/documentation/guides/install-agent) with `formae agent start`, the CLI already reaches it, with no profile setup at all. See what you have: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile list ``` The active profile is marked with `*`; on a clean install that is `default`. ## Edit the default profile To point the default profile somewhere else, or add settings, open it in your editor: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile edit ``` With no name, `edit` opens the active profile. A profile is a Pkl file; its `cli.api.url` is the agent endpoint the CLI connects to (a local agent by default). ## Create a profile for another environment Once you have more than one environment (say a local agent and a bootstrapped one in AWS), give each its own profile: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile create staging formae profile edit staging ``` `create` starts from a minimal template; `edit` opens it so you can set the agent URL and any other settings. When you [install an agent with bootstrap](/documentation/guides/install-agent), the installer writes this profile for you. ## Switch between environments One profile is active at a time, and it applies to every command that connects to the agent (`apply`, `destroy`, `status`, `inventory`, and the rest). Switch it with: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile use staging ``` To target a different environment for a single command without changing the active one, pass `--profile`: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --profile prod ./infra.pkl formae status --profile prod ``` `--profile` is mutually exclusive with `--config`: a profile is a config source, so passing both is an error. ## Where profiles live Profiles are stored as Pkl files under `~/.config/formae/profiles/.pkl` (honoring `$XDG_CONFIG_HOME` and `$FORMAE_CONFIG_DIR`). The active profile is recorded in a plain-text `active` file alongside them. Existing setups are migrated into this layout automatically the first time you run a command that needs configuration. ## Command reference ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile [COMMAND] ``` | Command | What it does | | ---------------- | --------------------------------------------------------------------- | | `list` | List all profiles, marking the active one with `*` | | `current` | Print the active profile name | | `use ` | Switch the active profile | | `create ` | Create a new profile from a minimal template (`--force` to overwrite) | | `save ` | Snapshot the active profile under a new name (`--force` to overwrite) | | `edit []` | Open a profile in `$EDITOR` (the active one if no name) | | `delete ` | Delete a profile (refuses the active one) | | `diff []` | Compare two profiles, or one against the active | `list` and `current` accept `--output-consumer machine` (with `--output-schema json|yaml`) for scripting. ## See also * [Install and run the agent](/documentation/guides/install-agent): the installer writes a profile for your new agent. * [Configuration](/documentation/reference/configuration): the settings a profile holds. # Observe the agent Source: https://docs.formae.io/documentation/guides/observe-the-agent Export the formae agent's metrics, logs, and traces through OpenTelemetry, or scrape them from a Prometheus endpoint. The formae agent exposes its own telemetry through OpenTelemetry: metrics, logs, and traces pushed over OTLP, plus a Prometheus-compatible `/metrics` endpoint for pull-based collection. This guide turns it on and shows what you get. The full list of configuration knobs lives in the [configuration reference](/documentation/reference/configuration#opentelemetry). ## What gets exported With OpenTelemetry enabled, the agent exports three signals over OTLP: | Signal | Transport | What it covers | | ------- | --------- | ----------------------------------------------------------- | | Metrics | OTLP push | Agent stats, Go runtime, host metrics, database performance | | Logs | OTLP push | Structured application logs, correlated with traces | | Traces | OTLP push | API requests, resource operations, and database queries | A Prometheus-compatible endpoint is also available at `http://localhost:49684/api/v1/metrics` for pull-based scraping. ## Enable OpenTelemetry Add an `oTel` block to your [configuration file](/documentation/reference/configuration): ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} agent { oTel { enabled = true serviceName = "formae-agent" otlp { enabled = true endpoint = "localhost:4317" protocol = "grpc" insecure = true } } } ``` Restart the agent and it begins pushing telemetry to the OTLP endpoint. For the complete property table (including `otlp.temporality` and the `prometheus` settings), see the [configuration reference](/documentation/reference/configuration#opentelemetry). **Metric temporality.** Keep the default `delta` for OpenTelemetry-native backends such as Grafana Cloud, or a collector running the `deltatocumulative` processor. Switch to `cumulative` for Prometheus or Mimir backends that do not support delta temporality. ### Scrape with Prometheus instead To scrape metrics rather than push them, keep the Prometheus endpoint on and turn OTLP push off: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} agent { oTel { enabled = true serviceName = "formae-agent" otlp { enabled = false } prometheus { enabled = true } } } ``` ## Key metrics Alongside Go runtime, host, Ergo actor-system, and database metrics, the agent exports formae-specific stats: | Metric | Labels | Description | | ---------------------------- | ------------------------- | -------------------------------------------------------- | | `formae_stacks_total` | | Number of stacks | | `formae_targets_total` | `plugin` | Targets by plugin | | `formae_resources_managed` | `plugin` | Managed resources by plugin | | `formae_resources_unmanaged` | `plugin` | Unmanaged resources by plugin | | `formae_commands_total` | `command_type` | Commands by type (apply, destroy, sync, eval) | | `formae_commands_by_state` | `state` | Commands by state (Pending, InProgress, Success, Failed) | | `formae_resources_by_type` | `plugin`, `resource_type` | Resource count by type | | `formae_resource_errors` | `plugin`, `resource_type` | Resources with errors by type | ## Logs and traces Structured logs are pushed to the OTLP endpoint with trace and span IDs attached, so you can jump from a log line to the operation that produced it. Local file logging stays available whatever the OpenTelemetry setting: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} agent { logging { filePath = "~/.pel/formae/log/formae.log" fileLogLevel = "debug" consoleLogLevel = "info" } } ``` Traces cover API request handling, resource create/read/update/delete operations, database queries (with query text and latency), and plugin interactions, all correlated with the matching logs. ## Grafana dashboards Pre-built dashboards live in the [formae-grafana-dashboards](https://github.com/platform-engineering-labs/formae-grafana-dashboards) repository. To import one by hand, open **Dashboards > Import** in Grafana and upload the dashboard JSON, then select your Prometheus and Loki datasources. To provision them automatically, point a Grafana dashboard provider at the cloned repository: ```yaml theme={"languages":{"custom":["/languages/pkl.json"]}} apiVersion: 1 providers: - name: 'formae' orgId: 1 folder: 'Formae' type: file options: path: /path/to/formae-grafana-dashboards/dashboards ``` ## See also * [Configuration reference](/documentation/reference/configuration#opentelemetry): every `oTel` and `prometheus` property. * [Deploy the LGTM observability stack](/documentation/reference/providers/kubernetes/patterns/deploy-the-lgtm-observability-stack): stand up Grafana, Loki, Tempo, and Mimir with formae. # Set up Pkl in your editor Source: https://docs.formae.io/documentation/guides/pkl-ide-support Install the Pkl language server and editor support so writing formae is type-checked and autocompleted. formae uses [Pkl](https://pkl-lang.org/) as its configuration language. Pkl has strong editor support (type checking, autocomplete, go-to-definition), which makes writing formae far easier. This guide gets it working in your editor. The Pkl language server runs on Java, so you need a JDK installed. SDKMAN lets you manage and switch between Java versions easily. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} curl -s "https://get.sdkman.io" | bash source "$HOME/.sdkman/bin/sdkman-init.sh" sdk install java ``` Verify: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} java --version ``` ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} brew install openjdk ``` Download and install OpenJDK from [jdk.java.net](https://jdk.java.net/24/), then make sure the `java` binary is on your `PATH`. Install the official Pkl extension: 1. Open the [Pkl VS Code installation guide](https://pkl-lang.org/vscode/current/installation.html). 2. Install the extension from the VS Code Marketplace. Install Pkl support with your plugin manager. Using [lazy.nvim](https://github.com/folke/lazy.nvim): ```lua theme={"languages":{"custom":["/languages/pkl.json"]}} { 'apple/pkl-neovim', dependencies = { 'nvim-treesitter/nvim-treesitter', }, build = function() vim.cmd('TSInstall! pkl') end, ft = 'pkl', } ``` Using [vim-plug](https://github.com/junegunn/vim-plug): ```vim theme={"languages":{"custom":["/languages/pkl.json"]}} Plug 'apple/pkl-neovim' Plug 'nvim-treesitter/nvim-treesitter' ``` After installing, run `:TSInstall pkl` to install the Pkl Tree-sitter parser. ## New to Pkl? If you're new to the language, the [Pkl primer](https://pkl.platform.engineering) covers the fundamentals in a few minutes: basic syntax and structure, types and validation, and modules and imports. See also the [Pkl cheatsheet](/documentation/reference/pkl-cheatsheet) for a quick syntax reference. # Manage stack policies Source: https://docs.formae.io/documentation/guides/policies Attach a lifecycle policy to a stack: expire it after a duration with TTL, or keep it pinned to its declared state with auto-reconcile. Attach one inline, or define it once and reuse it across stacks. A [policy](/documentation/concepts/policy) is a lifecycle rule you attach to a stack. Where a forma declares what resources should exist, a policy declares how formae manages the stack over time on its own, without you running `apply` again. There are two: * [TTL](/documentation/concepts/policies/ttl): destroy the stack after a duration, for ephemeral environments. * [Auto-reconcile](/documentation/concepts/policies/auto-reconcile): re-apply the declared state on an interval, so drift never sticks. You attach a policy one of two ways: * **Inline**, declared directly on a single stack. It belongs to that stack: it is deleted with the stack, and a reconcile apply that omits it removes it. * **Reusable** (standalone), defined once with a label and referenced from any number of stacks. Edit it in one place and every stack that references it picks up the change. The [Policy concept](/documentation/concepts/policy) covers the trade-offs in full. This guide shows how to attach both kinds. Add a `policies` listing to the stack. A TTL policy expires it after a duration: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new formae.Stack { label = "dev-environment" description = "Ephemeral dev workspace" policies = new Listing { new formae.TTLPolicy { ttl = 4.h onDependents = "abort" } } } ``` Or keep the stack pinned to its declared state with auto-reconcile: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} policies = new Listing { new formae.AutoReconcilePolicy { interval = 5.min } } ``` Declare the policy inside your stack's complete forma, alongside its resources. Reconcile compares the whole stack, so applying a forma that holds only the policy would remove the stack's resources. To apply the same rule to several stacks, give the policy a `label` and define it once at the top of the forma, then reference it from each stack with `.res`: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local ephemeral = new formae.TTLPolicy { label = "ephemeral-24h" ttl = 24.h onDependents = "abort" } forma { ephemeral new formae.Stack { label = "dev-sandbox-1" description = "Sandbox for the payments team" policies = new Listing { ephemeral.res } } new formae.Stack { label = "dev-sandbox-2" description = "Sandbox for the checkout team" policies = new Listing { ephemeral.res } } } ``` Change `ttl` in the one definition and both stacks pick it up on the next apply. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile your-infra.pkl ```
  formae apply · reconcile                                                                          your-infra.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 3 create

  ▌ Policies
  Operation ▲   Label                                   Type                Stack
  + create      ephemeral-24h                           ttl                 
  + create      ephemeral-24h                           ttl                 dev-sandbox-1
  + create      ephemeral-24h                           ttl                 dev-sandbox-2

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1 policy(ies).  Do
you want to continue? (y/N)  ?: help
The plan shows the policy being created and attached to each stack (for the reusable example above, `create policy ttl ephemeral-24h`, then `attach ephemeral-24h to dev-sandbox-1` and `attach ephemeral-24h to dev-sandbox-2`). From then on formae enforces it: TTL destroys the stack when the duration elapses; auto-reconcile hard-reconciles on every interval.
Reusable policies and the stacks they are attached to show in: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory policies ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                                         ╭────────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─────────────────────────────────────────╯            ╰─────────────────────────────────────────────────────────────

Label ▲             Type                AttachedStacks                
──────────────────────────────────────────────────────────────────────
ephemeral-24h       ttl                 dev-sandbox-1, dev-sandbox-2  

Showing 1 of 1 policies
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                       ?: help
Inline policies have no independent identity, so to see the policy on a given stack, check the stack itself: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks ```
  formae inventory
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                             ╭──────────╮
  1 Resources    2 Targets    3 Stacks    4 Policies 
─────────────────────────────╯          ╰───────────────────────────────────────────────────────────────────────────

Label ▲                   Description                                     Policies                            
──────────────────────────────────────────────────────────────────────────────────────────────────────────────
dev-sandbox-1             Sandbox for the payments team                   TTL: 1d, expires in 23h58m (ephemer…
dev-sandbox-2             Sandbox for the checkout team                   TTL: 1d, expires in 23h58m (ephemer…

Showing 2 of 2 stacks
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  /: query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓/j/k: navigate  enter: detail  /: search  s: sort  r: refresh  1-4: tab  q: quit                       ?: help
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, describe the lifecycle you want and let the assistant define the reusable policy and attach it to each stack:
Conversation
You
Create a reusable 24-hour TTL and attach it to both the dev-sandbox-1 and dev-sandbox-2 stacks.
Assistant
I simulated it first. It creates one ttl policy, ephemeral-24h (24 hours, onDependents = abort), then attaches it to dev-sandbox-1 and dev-sandbox-2. Each stack is destroyed 24 hours after the policy attaches, and editing the one policy updates both. Apply it?
You
Yes.
Assistant
Done. ephemeral-24h now covers both sandboxes. Attach it to another stack any time by pointing that stack at the same policy.
A TTL with `onDependents = "cascade"` deletes resources in other stacks if they depend on this one, anywhere the dependency chain leads. Use the default `abort` unless you are certain nothing outside the stack should survive it. ## See also * [Policy](/documentation/concepts/policy): inline versus reusable policies. * [TTL policy](/documentation/concepts/policies/ttl): automatic cleanup after a duration. * [Auto-reconcile policy](/documentation/concepts/policies/auto-reconcile): automatic enforcement of declared state. # Rename a resource Source: https://docs.formae.io/documentation/guides/rename-a-resource Change a managed resource's label without recreating the cloud object, using alias. A [label](/documentation/concepts/label) is a formae-side identifier that lives in formae's inventory and is never sent to the cloud provider. formae tracks each resource by an internal id, so changing a label is a metadata update: the inventory row changes and the cloud object stays exactly as it is. To rename, set the resource's `alias` to its current label and its `label` to the new one, then apply. formae matches the resource by its `alias` and updates the label in place. This works in both reconcile and patch mode. In the forma where the resource is declared, add `alias` with its current label and change `label` to the new one: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new bucket.Bucket { label = "lambda-artifacts" // the new label alias = "artifacts-bucket-cdk" // its current label bucketName = "acme-lambda-artifacts" } ``` Simulate first. A rename is a single label update with no cloud operation, so the plan shows one `~ update` and nothing touches the provider: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --simulate my-forma.pkl ```
  formae apply · reconcile                                                                         rename-demo.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  \~ 1 update

  ▌ Resources
  Operation ▲   Label                                                       Type
  \~ update      lambda-artifacts                                            AWS::S3::Bucket

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
                                                              simulation only — command will not continue  ?: help
Drop `--simulate` to make the change. formae renames the inventory row and leaves the cloud resource untouched; you can drop the `alias` afterwards, or leave it in place (a later apply with the alias still set is a no-op): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile my-forma.pkl ```
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
✓    3GprjIv3mF4CxosYjVhW5dbSUhi apply     reconcile   completed 1/1                    1    0    0    0    00:00
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       lambda-artifacts                                      AWS::S3::Bucket                     update      00:00

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  →←: column  s: toggle sort  space: expand  d: details  esc: back  q: quit                                ?: help
With the [formae MCP plugin](/documentation/guides/ai-coding-assistants) connected, ask for the rename and let the assistant set the `alias` for you:
Conversation
You
Rename the bucket artifacts-bucket-cdk to lambda-artifacts.
Assistant
I'll set its alias to the current label and change the label, then simulate first: the plan changes the label from artifacts-bucket-cdk to lambda-artifacts on the S3 bucket in web-backend, with no change to the bucket itself. Apply it?
You
Yes.
Assistant
Renamed. It's now lambda-artifacts, and the bucket itself was untouched.
## Naming a discovered resource When [discovery](/documentation/concepts/discovery) labels a resource with a raw provider id (a VPC discovered as `vpc-008eef40942ac586b`, say), give it a readable name as you bring it under management: set `alias` to the discovered label and `label` to the name you want. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} new vpc.VPC { label = "production-vpc" alias = "vpc-008eef40942ac586b" cidrBlock = "172.31.0.0/16" } ``` Renaming does not free the old label for future discoveries. The numeric suffix that [disambiguates discovered labels](/documentation/concepts/label) advances past whatever is already in the inventory, so renaming `database` to `aurora-writer` while `database-1` and `database-2` still exist means the next discovery that would land on `database` becomes `database-3`. ## See also * [Label](/documentation/concepts/label): what a label is and how discovered resources get one. * [Bring existing resources under management](/documentation/guides/bring-resources-under-management): adopt discovered resources, often renaming them in the process. * [Apply modes](/documentation/concepts/apply-modes): reconcile and patch both support renaming. # Reuse infrastructure with modules Source: https://docs.formae.io/documentation/guides/reuse-with-modules Structure formae code into Pkl modules and classes so you share configuration, compose resources, and write a pattern once instead of copying it across formae. As your infrastructure grows, a single forma file stops scaling. The same stack, target, and tagging conventions get copied from file to file, and a networking pattern you got right once gets pasted into the next project. formae files are [Pkl](https://pkl-lang.org), so you have Pkl's own tools for reuse: put shared values in a module, group related resources into a class, and compose those pieces into the forma you apply. This guide covers the general reuse mechanics: sharing configuration, grouping resources, and composing modules. For a complete worked class that a platform team hands to developers as a self-service offering, see [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure). ## Share configuration with a vars module Most projects reuse the same stack and target across several formae. Put those in a plain Pkl module and import it wherever you need them. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // vars.pkl import "@formae/formae.pkl" import "@aws/aws.pkl" region = "us-east-1" stack: formae.Stack = new { label = "lifeline" description = "Lifeline infrastructure" } target: formae.Target = new formae.Target { label = "default" config = new aws.Config { region = module.region } } ``` Import it and reference the shared values from any forma: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // main.pkl amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@aws/s3/bucket.pkl" import "./vars.pkl" forma { vars.stack vars.target new bucket.Bucket { label = "artifacts" bucketName = "lifeline-artifacts" } } ``` Now the stack and target are defined once. Change the region in `vars.pkl` and every forma that imports it picks up the new value. ## Group resources into a class When several resources always travel together, a VPC with its subnets and route table, wrap them in a Pkl class. The class takes its inputs as typed fields, builds the resources with `hidden`, and exposes them as a single `resources` listing. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // infrastructure/networking.pkl import "@formae/formae.pkl" import "@aws/ec2/vpc.pkl" import "@aws/ec2/subnet.pkl" import "@aws/ec2/securitygroup.pkl" class Networking { name: String vpcCidr: String region: String hidden vpc: vpc.VPC = new { label = "\(name)-vpc" cidrBlock = vpcCidr enableDnsHostnames = true enableDnsSupport = true } hidden publicSubnet: subnet.Subnet = new { label = "\(name)-public-subnet" vpcId = vpc.res.id cidrBlock = "10.0.1.0/24" availabilityZone = "\(region)a" } hidden appSecurityGroup: securitygroup.SecurityGroup = new { label = "\(name)-app-sg" vpcId = vpc.res.id groupDescription = "Application security group" } hidden resources: Listing = new { vpc publicSubnet appSecurityGroup } } ``` A few things worth noting: * `hidden` keeps each resource internal to the class. Only the `resources` listing needs to be consumed from outside. * `vpc.res.id` is a [resolvable](/documentation/concepts/resolvable). Inside the class the subnet references the VPC's ID, and formae works out that the VPC has to be created first. * The class exposes one thing worth reading: `resources`, a listing formae knows how to apply. Wire the class to your inputs and spread its resources into the forma. The `...` operator expands the listing into individual resources: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // main.pkl amends "@formae/forma.pkl" import "@formae/formae.pkl" import "./infrastructure/networking.pkl" as nw import "./vars.pkl" local network = new nw.Networking { name = "lifeline" vpcCidr = "10.0.0.0/16" region = vars.region } forma { vars.stack vars.target ...network.resources } ``` ## Compose classes Layered infrastructure is where classes pay off. Build the networking layer, then pass its resources into a class that needs them. A field typed as a resource accepts an instance produced by another class, and the receiving class reads properties off it with `.res`. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // A database layer that depends on the networking layer class Database { name: String vpc: vpc.VPC // Passed in from the networking class subnet1: subnet.Subnet subnet2: subnet.Subnet hidden dbSubnetGroup: dbsubnetgroup.DBSubnetGroup = new { label = "\(name)-db-subnet-group" dbSubnetGroupDescription = "Subnet group for \(name)" subnetIds { subnet1.res.subnetId subnet2.res.subnetId } } hidden resources: Listing = new { dbSubnetGroup } } ``` Instantiate both layers, hand the networking resources to the database, and spread each layer's resources into the forma: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local network = new nw.Networking { name = "lifeline" vpcCidr = "10.0.0.0/16" region = vars.region } local database = new db.Database { name = "lifeline" vpc = network.vpc subnet1 = network.publicSubnet subnet2 = network.privateSubnet } forma { vars.stack vars.target ...network.resources ...database.resources } ``` ### Reference parent fields with outer When one class nests another, use `outer` inside the inner definition to reach a field on the enclosing class. This lets a top-level class thread a single input, like a region or an account ID, down into the resources it builds without repeating it. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} class Platform { name: String region: String hidden networking: nw.Networking = new { name = outer.name // Reference the Platform's name region = outer.region // Reference the Platform's region vpcCidr = "10.0.0.0/16" } } ``` ## Prefer functions for straightforward creation If a module just produces resources from inputs without composing layers, a function is lighter than a class. A function takes its inputs as arguments and returns either a single resource or a `Listing`. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // infrastructure/network.pkl import "@aws/ec2/vpc.pkl" import "@aws/ec2/subnet.pkl" function vpc(name: String): vpc.VPC = new vpc.VPC { label = "\(name)-vpc" cidrBlock = "10.0.0.0/16" enableDnsHostnames = true } function privateSubnets(name: String): Listing = new Listing { new subnet.Subnet { label = "\(name)-private-1" vpcId = vpc(name).res.id cidrBlock = "10.0.1.0/24" } new subnet.Subnet { label = "\(name)-private-2" vpcId = vpc(name).res.id cidrBlock = "10.0.2.0/24" } } ``` Call the functions in the forma, spreading any that return a listing: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "./infrastructure/network.pkl" as network forma { vars.stack vars.target network.vpc("lifeline") ...network.privateSubnets("lifeline") } ``` Reach for a class when you need to hide internal resources behind an interface or pass resources between layers. Reach for a function when creation follows a clear flow and there is nothing to encapsulate. ## Apply as usual Modules and classes are an authoring convenience. By the time formae sees your forma, the class fields and function calls have resolved to a flat set of resources, so applying works exactly as it does for a single-file forma. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile main.pkl ```
  formae apply · reconcile                                                                                main.pkl
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  + 15 create

  ▌ Targets
  Operation ▲   Label
  + create      aws-target

  ▌ Stacks
  Operation ▲   Label
  + create      lifeline

  ▌ Resources
  Operation ▲   Label                                                       Type
  + create      lifeline-vpc                                                AWS::EC2::VPC
  + create      lifeline-igw                                                AWS::EC2::InternetGateway
  + create      lifeline-igw-attachment                                     AWS::EC2::VPCGatewayAttachment
  + create      lifeline-public-subnet-1                                    AWS::EC2::Subnet
  + create      lifeline-public-subnet-2                                    AWS::EC2::Subnet
  + create      lifeline-public-rt                                          AWS::EC2::RouteTable
  + create      lifeline-public-route                                       AWS::EC2::Route
  + create      lifeline-public-subnet-1-assoc                              AWS::EC2::SubnetRouteTableAssociation
  + create      lifeline-public-subnet-2-assoc                              AWS::EC2::SubnetRouteTableAssociation
  + create      lifeline-alb-sg                                             AWS::EC2::SecurityGroup
      ↓ show 10 more (3 remaining)

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  ↑↓: select  space: expand  →←: column  s: sort  y: confirm  q: abort This operation will create 1 stack(s), create
1 target(s) and create 13 resource(s).  Do you want to continue? (y/N)  ?: help
formae shows the plan and asks for confirmation before making any changes, then streams the run in a live view. Pass `--yes` to skip the confirmation prompt in a CI/CD job. ## See also * [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure): a full class-based offering exposed to developers through property flags. * [Resolvable](/documentation/concepts/resolvable): how `.res` references let resources depend on each other, including across classes. * [Properties](/documentation/concepts/properties): parameterize a module's inputs as CLI flags. * [Stack](/documentation/concepts/stack): how the resources you compose are grouped and reconciled together. * [Apply modes](/documentation/concepts/apply-modes): reconcile versus patch once your composed forma is ready to apply. # Read tfvars in a forma Source: https://docs.formae.io/documentation/guides/tfvars Reuse existing Terraform and OpenTofu .tfvars files inside a forma, so one set of inputs drives both tools during a migration. formae can read Terraform and OpenTofu `.tfvars` files directly inside a [forma](/documentation/concepts/forma). Existing variable files from a Terraform or OpenTofu codebase can drive a formae deployment without conversion or duplication, so the same inputs stay shared between both tools while you migrate. The `terraform.readTFVars()` function parses an HCL-format `.tfvars` file and returns its variables as a Pkl `Dynamic`, so you read values with dot notation. ## Read a tfvars file Add the import alongside your other Pkl imports at the top of the forma: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/ext/terraform.pkl" ``` Bind the parsed values to a local. The path is resolved relative to the forma file's directory, and absolute paths also work: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local vars = terraform.readTFVars("prod.tfvars") ``` Given this `prod.tfvars`: ```hcl theme={"languages":{"custom":["/languages/pkl.json"]}} region = "us-west-2" instance_count = 3 enable_logging = true tags = { Name = "web-server", Environment = "production" } zones = ["us-west-2a", "us-west-2b"] ``` the values come through as: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local vars = terraform.readTFVars("prod.tfvars") vars.region // "us-west-2" vars.instance_count // 3 vars.enable_logging // true vars.tags.Name // "web-server" vars.zones // List("us-west-2a", "us-west-2b") ``` ## Example forma This forma reads `env/prod.tfvars` and uses its values to set a [target](/documentation/concepts/target) region and interpolate a queue name: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@formae/ext/terraform.pkl" import "@aws/aws.pkl" import "@aws/sqs/queue.pkl" local tfvars = terraform.readTFVars("env/prod.tfvars") forma { new formae.Stack { label = "my-stack" description = "Stack using tfvars" } new formae.Target { label = "aws-target" config = new aws.Config { region = tfvars.region } } new queue.Queue { label = "my-queue" queueName = "app-\(tfvars.region)-queue" } } ``` Apply it the same way as any other forma. formae shows the plan and asks you to confirm before making changes: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile prod.pkl ``` Use `--simulate` to preview the plan without applying, or `--yes` to skip the confirmation prompt. ## Supported types Values are mapped from HCL to Pkl types as follows: | tfvars type | Pkl type | | ----------------- | -------------------------- | | `"string"` | `String` | | `42` | `Int` | | `3.14` | `Float` | | `true` / `false` | `Boolean` | | `["a", "b"]` | `List` | | `{ key = "val" }` | `Dynamic` (dot-accessible) | | `null` | `Null` | | Heredoc (`< `readTFVars` reads HCL-format `.tfvars` files. The `.tfvars.json` format is not supported, and the file extension must be `.tfvars`. Values must be literals: variable references and function calls inside the file are not evaluated. ## See also * [Forma](/documentation/concepts/forma): the file that declares your infrastructure. * [Values](/documentation/concepts/values): how formae resolves values in a forma. * [Create a target](/documentation/guides/create-a-target): configure the cloud account and region a forma deploys to. # Troubleshoot a failed command Source: https://docs.formae.io/documentation/guides/troubleshoot-a-failed-command Diagnose a failed apply or destroy with the detailed status layout: find the resource that broke and read its provider error. An apply or destroy can come back failed: a resource the cloud rejected, a permissions problem, a name clash. formae records exactly which resource broke and why. This guide shows how to read it. ## See what broke When a command comes back failed, switch to the detailed layout to see which resource broke and its error: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status command --output-layout=detailed ```
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
     ID                          Command   Mode        Progress                         ✓    ✗    ◐    ○    Time
✗    3GnWjXDeziP4JuLFNnub3f23hGf apply     patch       failed 0/1                       0    1    0    0    00:20
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ▌ Resources
        Label ▲                                               Type                                Operation   Time
       incident-logs                                         AWS::S3::Bucket                     create      00:20
      test already exists (Service: S3, Status Code: 0, Request ID: null)

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  →←: column  s: toggle sort  space: expand  d: details  esc: back  q: quit                                ?: help
Here the create failed because the bucket name was already taken: the error line reads `test already exists (Service: S3, Status Code: 0, Request ID: null)`. Pick a name that is not in use and apply again. The detailed layout gives you the resource label, its type, the operation formae attempted, and the provider error verbatim, so you can act on the cause rather than guess at it. ## Find a specific failure With no query, `formae status command` reports your most recent command. To find a specific failure, filter: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status command --query="status:Failed" ```
  formae status command                                                                                     ↻ live
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 ▲   ID                          Command   Mode        Progress                   ✓    ✗    ◐    ○    Time    Age
✗    3Fsu27ZRJNwlWDPYesoTXwk7hXq apply     reconcile   failed 2/3                 2    1    0    0    01:06   20d
✗    3FsrQqQrqYKrgjzCVo6Nvch62ns apply     reconcile   failed 28/29               28   1    0    0    12:36   20d
✗    3FsqCdKnO2Qh0jAFGMfiiXQi5Ri apply     reconcile   failed 22/30               22   8    0    0    01:10   20d
✗    3FqBvbCCS1F5wgQUVFszRR4Rwgh destroy   -           failed 6/7                 6    1    0    0    01:48   21d
✗    3FpfrjzJvGkq1V1rcjJanxrY4qF apply     reconcile   failed 26/29               26   3    0    0    04:55   22d
✗    3FpPTiEKK7nXvtGKBsSaryi9ZKr apply     reconcile   failed 0/13                0    13   0    0    00:01   22d
✗    3FpNiE3GNCMye3uVJ79tScbvR5O apply     reconcile   failed 15/28               15   13   0    0    01:48   22d
✗    3FkfhkcC8ESxEJRneolwBFXFKuS apply     reconcile   failed 5/9                 5    4    0    0    00:51   23d
✗    3FjMidSHpIDv3qpv8HsYUmQvfwM apply     reconcile   failed 24/26               24   2    0    0    02:24   24d
✗    3FjLo0gs0Rp73tLjOEnVzY4kYnt apply     reconcile   failed 12/14               12   2    0    0    01:33   24d

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  / status:Failed                                                                                    /: edit query
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Command queries filter on `id`, `status`, `client`, `command`, and `stack`; on a shared agent, add `client:me` to see only your own commands. For pipelines, add `--output-consumer=machine --output-schema=json`. ## See also * [Cancel a running command](/documentation/guides/cancel-a-running-command): stop a command you triggered by mistake, or one that is stuck. * [Incidents and recovery](/documentation/guides/incidents-and-recovery): fix a live incident with a patch and settle up afterwards. * [CLI reference](/documentation/reference/cli): full flags for `apply`, `destroy`, `status`, and `cancel`. # formae documentation Source: https://docs.formae.io/documentation/index Manage live infrastructure as code with precise, granular changes and minimal blast radius. formae is an open-source infrastructure-as-code platform that continuously discovers what is actually running, captures changes made outside the tool, and lets engineers extract that live infrastructure as code, modify it, and apply precise changes at any granularity - from a complete rollout to a single property - with minimal blast radius. ## Get started Install formae and deploy a ready-made example to AWS, Azure, or GCP in about ten minutes. Deploy by describing what you want to Claude Code, Codex, or another MCP client. Build a forma from scratch and learn stacks, targets, references, and properties. ## Find your way around The documentation follows four kinds of material, so you can tell what a page is for before you open it. Guided, start-to-finish tutorials for your first hour with formae. Task-focused how-tos: adoption, CI/CD, operating the agent, per-cloud patterns. How formae thinks: stacks, targets, resources, reconciliation, and drift. Look-up material: the CLI, configuration, and the Pkl cheatsheet. ## Building a plugin? Extending formae to a new provider or service lives in its own space. The SDK tutorial, advanced topics, and the plugin interface reference. # CLI reference Source: https://docs.formae.io/documentation/reference/cli Reference for the formae command-line interface: global flags, the command index, and links to every command. The formae CLI is the client you use to drive the [formae agent](/documentation/concepts/architecture). It parses your command, sends a request to the agent over its REST API, and renders the result. Most commands are asynchronous: `apply` and `destroy` submit work and return. On an interactive terminal they show a live progress view by default; otherwise they return immediately and you follow progress with [`formae status`](/documentation/reference/cli/status). The formae CLI is being revamped on a feature branch. This reference documents the shipping behavior. The authoritative, always-current source for any command's options is the CLI's own help: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae --help formae --help ``` ## Command index | Command | What it does | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | [`apply`](/documentation/reference/cli/apply) | Create or update infrastructure from a forma (see [apply modes](/documentation/concepts/apply-modes)) | | [`destroy`](/documentation/reference/cli/destroy) | Tear down resources from a forma or a query | | [`eval`](/documentation/reference/cli/eval) | Evaluate a forma and print the resolved result without applying it | | [`extract`](/documentation/reference/cli/extract) | Pull existing resources into a forma file | | [`inventory`](/documentation/reference/cli/inventory) | List resources, stacks, targets, and policies | | [`status`](/documentation/reference/cli/status) | Query the agent and past commands | | [`cancel`](/documentation/reference/cli/cancel) | Cancel in-progress commands | | [`agent`](/documentation/reference/cli/agent) | Start and stop the agent | | [`plugin`](/documentation/reference/cli/plugin) | Search, install, update, and scaffold [plugins](/documentation/concepts/plugin) | | [`profile`](/documentation/reference/cli/profile) | Manage named configuration [profiles](/documentation/reference/configuration) | | [`project`](/documentation/reference/cli/project) | Scaffold a forma project | | [`update`](/documentation/reference/cli/update) | Manage the formae binary version | | [`clean`](/documentation/reference/cli/clean) | Clean up old software versions | ## Global flags These flags are available on the root command: | Flag | Description | | ----------------- | ------------------------------------------------------------- | | `-h`, `--help` | Show help for formae or any command. | | `-v`, `--version` | Show the formae version and the Go version it was built with. | ## Common flags Commands that talk to the agent share a common set of flags. ### Choosing a configuration Every agent-connecting command accepts a way to pick which configuration to use. `--config` and `--profile` are mutually exclusive. | Flag | Description | | ------------------ | ---------------------------------------------------------------------------------------- | | `--config ` | Path to a config file to use for this command. | | `--profile ` | Named profile to use. See [`formae profile list`](/documentation/reference/cli/profile). | With neither flag, formae uses the active profile. See [Configuration](/documentation/reference/configuration) for how profiles are stored and selected. ### Shaping the output Commands that return data accept flags to switch between human and machine output. | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------------------------- | | `--output-consumer ` | `human` | Whether output is formatted for a person or for a machine. | | `--output-schema ` | `json` | The schema used for machine output. | On an interactive terminal, `apply`, `destroy`, and `cancel` show a live status view by default and exit when the work completes. Press `q` to detach at any time; the command keeps running on the agent. Off a TTY (piped, CI, or machine output) they are fire-and-forget: they return a command ID and you follow progress with [`formae status`](/documentation/reference/cli/status). The `status` commands accept `--watch` to continuously refresh until completion. ## Queries Several commands accept a `--query` flag to select resources, targets, or commands by their attributes. Queries are space-separated `key:value` terms, and `*` works as a wildcard anywhere in a value (for example `foo*`, `*foo`, `*foo*`, or `foo*bar`). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query 'type:AWS::S3::Bucket stack:prod' formae status command --query 'status:InProgress' ``` ## Regenerating this reference This reference is generated from the CLI's own cobra command definitions, so it stays in step with the binary. To refresh it, build a small doc-gen harness that assembles the root command from the exported constructors in `internal/cli/root.go` and runs `cobra/doc` `GenMarkdownTree`, then fold the output into the pages under `documentation/reference/cli/`. # formae agent Source: https://docs.formae.io/documentation/reference/cli/agent Start and stop the formae agent. Reference for formae agent and its subcommands. Manage the [formae agent](/documentation/concepts/architecture), the long-running process that reconciles your infrastructure and answers CLI requests. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent start formae agent stop ``` ## formae agent start Start the agent. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent start [flags] ``` ### Flags | Flag | Default | Description | | ------------------ | ------- | ---------------------- | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae agent stop Stop the agent. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae agent stop [flags] ``` This subcommand takes no flags beyond `--help`. # formae apply Source: https://docs.formae.io/documentation/reference/cli/apply Create or update infrastructure from a forma file. Reference for formae apply, its flags, and its required apply mode. Apply a forma to create or update infrastructure. `apply` reads a forma file, sends it to the agent, and reconciles or patches your resources depending on the [apply mode](/documentation/concepts/apply-modes). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode [flags] ``` The `--mode` flag is required, and a forma file argument is required. ## Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile forma.pkl formae apply --mode patch forma.pkl formae apply --mode reconcile --simulate forma.pkl ``` For human output, `apply` always simulates first and shows the planned changes before asking you to confirm. Use `--yes` to skip the confirmation, or `--simulate` to stop after the dry run. ## Flags | Flag | Default | Description | | -------------------------------------------- | --------- | -------------------------------------------------------------------------------------------- | | `--mode ` | | Apply mode. This flag is required. See [apply modes](/documentation/concepts/apply-modes). | | `--simulate` | `false` | Simulate the command rather than make actual changes. | | `--force` | `false` | Overwrite any changes since the last reconcile without prompting. Applies in reconcile mode. | | `--status-output-layout ` | `summary` | What to print as status output. | | `--yes` | `false` | Run without any confirmations. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## Property flags Properties declared in your forma become flags on `apply`, so you can set them per run without editing the file. `apply`, `destroy`, and `eval` all support the properties declared by the forma they target. Run `formae apply --help` against a specific forma to see the property flags it exposes. # formae cancel Source: https://docs.formae.io/documentation/reference/cli/cancel Cancel in-progress commands. Reference for formae cancel and its flags. Cancel commands that are currently in progress. With no query, `cancel` targets the most recent command. With a query, it cancels all in-progress commands that match. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae cancel [flags] ``` Only commands in the `InProgress` state can be canceled. Commands that are already executing resources finish those resources before moving to the `Canceled` state, so no resources are left orphaned. ## Force cancel Use `--force` to abandon in-progress work and drive the command straight to a terminal `Canceled` state, instead of waiting for in-progress resources to finish. This is an escape hatch for operations that will not complete on their own, such as a plugin stuck in an unbounded poll loop. With `--force`: * Cloud-side operations may keep running after the command is canceled. * Update and Delete operations are self-healing: the synchronizer reconciles formae's state against actual cloud state on its next cycle. * A still-running Create may orphan a cloud resource that formae cannot yet track, because it has no native id. You may need to clean it up manually, or let discovery pick it up. ## Flags | Flag | Default | Description | | -------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | | `--query ` | | Select commands to cancel. With no query, cancels the most recent command. `*` works as a wildcard. | | `--force` | `false` | Abandon in-progress work and move the command to a terminal `Canceled` state immediately. See the note above. | | `--status-output-layout ` | `summary` | What to print as status output. | | `--yes` | `false` | Run without any confirmations. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `yaml` | The schema to use for the result output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae clean Source: https://docs.formae.io/documentation/reference/cli/clean Clean up old formae software versions. Reference for formae clean and its flags. Clean up old software versions left behind by [`formae update`](/documentation/reference/cli/update). ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae clean [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae clean formae clean --all ``` ## Flags | Flag | Default | Description | | ------------------ | ------- | ---------------------------- | | `--all` | `false` | Also remove update metadata. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae destroy Source: https://docs.formae.io/documentation/reference/cli/destroy Tear down resources from a forma file or a query. Reference for formae destroy and its flags. Destroy the resources described by a forma, or select resources to destroy with a query. Because a [stack](/documentation/concepts/stack) has referential integrity, destroying one of its resources destroys the whole stack. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy [flags] [] ``` Provide a forma file to destroy everything it defines, or use `--query` to select resources by their attributes. The query is only used when no forma file is given. ## Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy forma.pkl formae destroy --query 'stack:test-* managed:false' formae destroy --query 'type:AWS::S3::Bucket stack:scratch' --yes ``` ## Flags | Flag | Default | Description | | -------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ | | `--query ` | | Find resources by their attributes. Only used when no forma file is provided. `*` works as a wildcard. | | `--on-dependents ` | `abort` | Behavior when other resources depend on those being deleted. | | `--simulate` | `false` | Simulate the command rather than make actual changes. | | `--status-output-layout ` | `summary` | What to print as status output. | | `--yes` | `false` | Run without any confirmations. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae eval Source: https://docs.formae.io/documentation/reference/cli/eval Evaluate a forma and print the resolved result without applying it. Reference for formae eval and its flags. Evaluate a forma and print the resolved result. `eval` runs the same evaluation that `apply` does, so you can inspect exactly what a forma resolves to before you apply it. It does not change any infrastructure. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae eval [flags] ``` ## Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae eval forma.pkl formae eval --mode patch forma.pkl formae eval --output-consumer machine --output-schema yaml forma.pkl ``` ## Flags | Flag | Default | Description | | ------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--mode ` | `reconcile` | Apply mode to evaluate against. | | `--beautify` | `true` | Beautify output. Human consumer only. | | `--colorize` | `true` | Colorize output. Human consumer only. | | `--schema-location ` | `remote` | How plugin PKL schemas are referenced when serializing the evaluated forma. `remote` emits `package://` URIs fetched from the hub. `local` emits local file imports against the agent's on-disk PklProject paths, and requires the CLI and agent to share a filesystem. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | Like `apply` and `destroy`, `eval` also exposes the property flags declared by the forma it evaluates. # formae extract Source: https://docs.formae.io/documentation/reference/cli/extract Pull existing resources into a forma file. Reference for formae extract and its flags. Extract resources that formae knows about into a forma file, so you can bring existing infrastructure under management as code. Select the resources with a query and give a target file to write. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract [flags] ``` ## Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae extract --query 'type:AWS::S3::Bucket' ./buckets.pkl formae extract --query 'type:GCP::Compute::* managed:false' ./gcp-unmanaged.pkl formae extract --query 'stack:prod target:eu target:us' ./prod.pkl ``` ## Flags | Flag | Default | Description | | ----------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--query ` | | Find resources by their attributes. `*` works as a wildcard. | | `--output-schema ` | `pkl` | Output schema. Only `pkl` is currently supported. | | `--schema-location ` | `remote` | How plugin PKL schemas are referenced in the generated PklProject. `remote` emits `package://` URIs fetched from the hub. `local` emits local file imports against the agent's on-disk PklProject paths, and requires the CLI and agent to share a filesystem. | | `--yes` | `false` | Overwrite existing files without prompting. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## Plugin dependencies The extracted file needs every plugin whose resources it declares to be a dependency of the `PklProject` that covers it. When you extract into a directory that already has a `PklProject`, extract adds any plugin namespace the resources need but the project is missing and re-resolves the project — silently, as part of extracting. Only the missing entries are added; your existing dependencies (including your pinned formae version) are preserved. If a resource needs a plugin the agent does not report as installed, extract fails with an actionable message instead of producing a file that can't be generated: ```text theme={"languages":{"custom":["/languages/pkl.json"]}} resource type "AZURE::Network::Subnet" requires plugin namespace "azure", but the agent does not report it installed. Install it with `formae plugin install azure` and retry ``` Missing **plugin** dependencies are added automatically, because the extracted file cannot be generated without them. The **formae core** schema version is handled differently: extract only notifies you (see below) and never rewrites it. ## Schema version compatibility Extracted forma files use `extends "@formae/forma.pkl"` (see [Properties](/documentation/concepts/properties)), a shape introduced in formae `0.88.0`. A `PklProject` pinning an older formae cannot evaluate a freshly extracted file, because the Pkl evaluator fails to resolve the `extends`. When you extract into a directory already covered by a `PklProject`, formae checks the formae version it pins: | Pinned formae version | Result | | ---------------------------------------- | --------------------------------------------------------- | | `< 0.88.0` | The file is written, then extract prints the notice below | | `>= 0.88.0` | No notice | | No `PklProject`, or no formae dependency | No notice | The extracted `.pkl` is **always written**. The notice is advisory: extract never rewrites your `PklProject`, so it introduces no surprise diff into your working tree. ```text theme={"languages":{"custom":["/languages/pkl.json"]}} '
/PklProject' is using formae version 0.87.0, however CLI is at version 0.88.0. Please update to 0.88.0 or greater and run 'pkl project resolve', in order to ensure extracted file evaluates. ``` To resolve it, bump the formae dependency in your `PklProject` to `0.88.0` or greater, then run: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} pkl project resolve ``` The extracted file then evaluates correctly. # formae inventory Source: https://docs.formae.io/documentation/reference/cli/inventory List resources, stacks, targets, and policies that formae manages. Reference for formae inventory and its subcommands. Query what formae knows about: managed and unmanaged resources, stacks, targets, and standalone policies. Each subcommand accepts `--max-results` to bound the table, and most accept a `--query` to filter by attributes. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory [flags] ``` ## formae inventory resources Query the inventory of resources. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query 'type:AWS::S3::Bucket' formae inventory resources --query 'type:GCP::Compute::* stack:prod' formae inventory resources --query 'target:eu target:us managed:false' formae inventory resources --max-results 50 ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ------------------------------------------------------------------------- | | `--query ` | | Find resources by their attributes. `*` works as a wildcard. | | `--max-results ` | `10` | Maximum number of resources to display in the table. `0` means unlimited. | | `--output-consumer ` | `human` | Consumer of the command output. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae inventory stacks Query the inventory of stacks. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory stacks formae inventory stacks --max-results 50 ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------------------------------------- | | `--max-results ` | `10` | Maximum number of stacks to display in the table. `0` means unlimited. | | `--output-consumer ` | `human` | Consumer of the command output. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae inventory targets Query the inventory of targets. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory targets [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory targets --query 'discoverable:true' formae inventory targets --query 'namespace:AWS label:prod-*' formae inventory targets --max-results 50 ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `--query ` | | Find targets by their attributes, for example `namespace:AWS`, `discoverable:true`, or `label:prod-us-east-1`. `*` works as a wildcard. | | `--max-results ` | `10` | Maximum number of targets to display in the table. `0` means unlimited. | | `--output-consumer ` | `human` | Consumer of the command output. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae inventory policies Query the inventory of standalone policies. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory policies [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory policies formae inventory policies --max-results 50 ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ------------------------------------------------------------------------ | | `--max-results ` | `10` | Maximum number of policies to display in the table. `0` means unlimited. | | `--output-consumer ` | `human` | Consumer of the command output. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae plugin Source: https://docs.formae.io/documentation/reference/cli/plugin Search, install, update, and scaffold formae plugins. Reference for formae plugin and its subcommands. Manage [plugins](/documentation/concepts/plugin) on this host: search what is available, install and update plugins, inspect them, and scaffold a new one. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin list formae plugin search aws formae plugin install aws formae plugin init ``` On a stock install the plugin store lives at a path only root can write to, so some of these commands may prompt for sudo to read or refresh their metadata. If the formae agent runs on this host, restart it after installing, updating, or uninstalling plugins so the change takes effect. ## formae plugin list List the plugins installed on this host with their version. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin list [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin search Search the plugins available for installation. With no argument, every available plugin is listed. With a query, only plugins whose name, summary, or description matches are returned. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin search [] [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--category ` | | Filter by category. | | `--type ` | | Filter by plugin type. | | `--channel ` | | Search a different channel. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin info Show the description, version, and metadata for a single plugin from the configured plugin repositories. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin info [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--channel ` | | Query a different channel. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin install Install one or more plugins on this host. Each argument is a plugin name, optionally with a version, for example `aws` or `aws@1.2.3`. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin install [@]... [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--channel ` | | Install from a different channel. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin update Update installed plugins on this host to the latest available version. With no argument, every installed plugin is considered for update. Otherwise only the named plugins are updated. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin update [[@]...] [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--channel ` | | Update from a different channel. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin uninstall Remove one or more plugins from this host. Each argument is a plugin name. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin uninstall ... [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae plugin init Initialize a new formae plugin from the GitHub plugin template. The command prompts interactively for plugin configuration, clones the template, and customizes it for your plugin. Use `--no-input` with all required flags for a non-interactive run, which is useful for automation and LLM-assisted workflows. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin init [flags] ``` ### Flags | Flag | Default | Description | | ------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--name ` | | Plugin name. Required for `--no-input`. | | `--namespace ` | | Target technology namespace, for example `AWS` or `GCP`. Required for `--no-input`. | | `--module-path ` | | Go module path, for example `github.com/your-org/formae-plugin-foo`. Required for `--no-input`. | | `--description ` | | Plugin description. Required for `--no-input`. | | `--author ` | | Plugin author for the license copyright. Required for `--no-input`. | | `--category ` | | Plugin category. One of `cloud`, `auth`, `config`, `observability`, `cicd`, `network`, `data`, `security`, `containers`, `other`. Prompted in interactive mode, `other` under `--no-input`. | | `--license ` | `Apache-2.0` | SPDX license identifier. | | `--output-dir ` | `./` | Target directory. | | `--hub ` | | Hub base URL. Defaults to `$FORMAE_HUB_URL` or `https://hub.platform.engineering`. | | `--no-input` | `false` | Disable interactive prompts and error if required flags are missing. | | `--no-availability-check` | `false` | Skip the hub availability check. Use for offline scaffolding or tests. | | `--allow-conflict` | `false` | Scaffold even if the hub reports the plugin name is already registered. | # formae profile Source: https://docs.formae.io/documentation/reference/cli/profile Manage named formae configuration profiles. Reference for formae profile and its subcommands. Manage named configuration [profiles](/documentation/reference/configuration). A profile is a stored configuration file. One profile is active at a time, and you can point a single command at another with `--profile `. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile list formae profile use prod formae profile create staging ``` ## formae profile list List all profiles, marking the active one with `*`. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile list [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae profile current Print the active profile name. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile current [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ---------------------------------------- | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the result output. | ## formae profile use Switch the active profile. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile use [flags] ``` ## formae profile create Create a new profile from the starter template. This does not switch the active profile. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile create [flags] ``` ### Flags | Flag | Default | Description | | --------- | ------- | ------------------------------ | | `--force` | `false` | Overwrite an existing profile. | ## formae profile save Snapshot the active profile under a new name. This does not switch the active profile. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile save [flags] ``` ### Flags | Flag | Default | Description | | --------- | ------- | ------------------------------ | | `--force` | `false` | Overwrite an existing profile. | ## formae profile edit Open a profile in `$EDITOR`. With no name, opens the active profile. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile edit [] [flags] ``` ## formae profile diff Run `diff -u` between two profiles, or between one profile and the active profile. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile diff [] [flags] ``` ## formae profile delete Delete a profile. The active profile cannot be deleted. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae profile delete [flags] ``` # formae project Source: https://docs.formae.io/documentation/reference/cli/project Scaffold a forma project. Reference for formae project and its subcommands. Work with formae projects. A project ties your forma files to the plugin schemas they depend on, so PKL can resolve the resource types you use. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae project init [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae project init ``` ## formae project init Initialize a project in the current directory. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae project init [flags] ``` ### Flags | Flag | Default | Description | | --------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- | | `--include ` | | Packages to include. Repeatable. Use the `@local` suffix for local plugins, for example `myplugin@local`. | | `--plugin-dir ` | `~/.pel/formae/plugins` | Directory to scan for `@local` plugin schemas. | | `--schema ` | `pkl` | Schema to use for the project. | | `-y`, `--yes` | `false` | Skip confirmation prompts. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae status Source: https://docs.formae.io/documentation/reference/cli/status Query the agent and past commands. Reference for formae status and its subcommands. Retrieve status from the agent: its own health, and the history and progress of commands you have run. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent formae status command --query 'status:InProgress' formae status command --query 'client:me' ``` ## formae status agent Receive the agent status. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status agent [flags] ``` ### Flags | Flag | Default | Description | | ------------------------------------ | ------- | ----------------------------------------------------------- | | `--watch` | `false` | Continuously refresh and print the status until completion. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae status command Receive the status of previously executed commands. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status command [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae status command --query 'status:InProgress' --max-results 10 formae status command --query 'client:me command:apply' formae status command --query 'stack:prod status:Success' formae status command --watch ``` ### Flags | Flag | Default | Description | | ------------------------------------- | --------- | ---------------------------------------------------------------------------- | | `--query ` | | Find past and current commands by their attributes. `*` works as a wildcard. | | `--max-results ` | `10` | Maximum number of command results to return when using a query. | | `--output-layout ` | `summary` | What to print as status output. | | `--watch` | `false` | Continuously refresh and print the status until completion. | | `--output-consumer ` | `human` | Consumer of the command result. | | `--output-schema ` | `json` | The schema to use for the machine output. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # formae update Source: https://docs.formae.io/documentation/reference/cli/update Manage the formae binary version. Reference for formae update and its subcommands. Manage updates to the formae binary. With no argument, `update` moves to the latest available version. Pass a version to move to a specific one. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update [version] [flags] ``` ### Examples ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update formae update list ``` ## Flags | Flag | Default | Description | | --------------------- | ------- | ---------------------------- | | `--channel ` | | Override the update channel. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | ## formae update list List the available formae versions. ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae update list [flags] ``` ### Flags | Flag | Default | Description | | --------------------- | ------- | ---------------------------- | | `--channel ` | | Override the update channel. | | `--config ` | | Path to a config file. | | `--profile ` | | Named profile to use. | # Configuration Source: https://docs.formae.io/documentation/reference/configuration Complete reference for the formae Pkl configuration file: server, datastore, discovery, synchronization, logging, telemetry, plugins, and artifacts. formae is configured using a Pkl configuration file that controls agent behavior, datastore settings, discovery, synchronization, logging, and observability. ## Configuration format The configuration file uses Pkl syntax and amends the base `formae:/Config.pkl` schema, which provides validation and defaults for all settings. ## Default configuration Configuration lives under your formae config directory, `$HOME/.config/formae/` by default (it also honors `$XDG_CONFIG_HOME` and `$FORMAE_CONFIG_DIR`). Each configuration is stored as a named **profile** at `profiles/.pkl`, and the active one is recorded in a plain-text `active` file alongside them. The first time you run a command that needs configuration, formae creates a ready-to-use `default` profile pointed at a local agent (any pre-existing configuration is migrated into this layout automatically). Use the [`formae profile`](/documentation/reference/cli) command to create, switch between, and compare profiles, or `--profile ` to target one for a single command. Everything below describes the contents of a single profile file. A minimal profile file looks like: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" agent { } cli { } ``` ## Complete configuration reference Below is a complete configuration showing all available options with their defaults: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" agent { server { nodename = "formae" hostname = "localhost" port = 49684 secret = "secret" tlsCert = null tlsKey = null } datastore { datastoreType = "sqlite" sqlite { filePath = "~/.pel/formae/data/formae.db" } postgres { host = "localhost" port = 5432 user = "postgres" password = "admin" database = "" schema = "" connectionParams = "" } auroraDataAPI { clusterArn = "" secretArn = "" database = "" region = "" } mssql { host = "" port = 1433 database = "" authMode = "sql" user = "" password = "" encrypt = true trustServerCertificate = false connectionParams = "" maxOpenConns = 4 connMaxLifetime = 1.h } } synchronization { enabled = true interval = 5.min } discovery { enabled = true labelTagKeys = new Listing { "Name" } interval = 10.min resourceTypesToDiscover = new Listing { } } logging { filePath = "~/.pel/formae/log/formae.log" fileLogLevel = "debug" consoleLogLevel = "info" } oTel { enabled = false serviceName = "formae-agent" otlp { enabled = true endpoint = "http://localhost:4317" protocol = "grpc" insecure = true temporality = "delta" } prometheus { enabled = true } } retry { statusCheckInterval = 20.s maxRetries = 9 retryDelay = 10.s } } cli { api { url = "http://localhost" port = 49684 } theme = "quiet" appearance = "auto" } pluginDir = "~/.pel/formae/plugins" artifacts { repositories { new { uri = "https://hub.platform.engineering/repos/platform.engineering/pel#stable" type = "binary" } new { uri = "https://hub.platform.engineering/repos/platform.engineering/community#stable" type = "formae-plugin" } } } ``` Several optional sections have no defaults and are omitted above: [Authentication](#authentication) (`agent.auth`, `cli.auth`), [Resource plugins](#resource-plugins) (`agent.resourcePlugins`), and [Network](#network) (top-level `network`). See the respective sections below to configure them. ## Configuration reference ### Server settings Controls the agent's network configuration and cluster identity. Essential for multi-agent deployments. | Property | Type | Default | Description | | ---------- | ------- | ------------- | ------------------------------------------------------------------ | | `nodename` | String | `"formae"` | Unique identifier for this agent in the cluster | | `hostname` | String | `"localhost"` | Network interface where the agent listens for incoming connections | | `port` | Number | `49684` | Port number for the agent API | | `secret` | String | `"secret"` | Cluster-wide authentication token for secure agent communication | | `tlsCert` | String? | `null` | Path to TLS certificate file for secure connections | | `tlsKey` | String? | `null` | Path to TLS private key file for secure connections | ### Datastore Choose between SQLite (default, suitable for single-agent), PostgreSQL (recommended for production with high availability requirements), Aurora Data API (for AWS deployments), or Microsoft SQL Server (for Azure deployments, including Azure SQL Database). | Property | Type | Default | Description | | --------------- | ------ | ---------- | ----------------------------------------------------------------------------------- | | `datastoreType` | String | `"sqlite"` | Type of datastore to use: `"sqlite"`, `"postgres"`, `"auroradataapi"`, or `"mssql"` | **SQLite configuration** | Property | Type | Default | Description | | ---------- | ------ | -------------------------------- | -------------------------------- | | `filePath` | String | `"~/.pel/formae/data/formae.db"` | Path to the SQLite database file | **PostgreSQL configuration** | Property | Type | Default | Description | | ------------------ | ------ | ------------- | ------------------------------------ | | `host` | String | `"localhost"` | PostgreSQL server hostname | | `port` | Number | `5432` | PostgreSQL server port | | `user` | String | `"postgres"` | Database user for authentication | | `password` | String | `"admin"` | Database password for authentication | | `database` | String | `""` | Database name to connect to | | `schema` | String | `""` | PostgreSQL schema to use | | `connectionParams` | String | `""` | Additional connection parameters | **Aurora Data API configuration** Use Aurora Data API for data access via AWS RDS Data API. | Property | Type | Default | Description | | ------------ | ------ | ------- | ---------------------------------------------------------------------- | | `clusterArn` | String | `""` | ARN of the Aurora Serverless cluster | | `secretArn` | String | `""` | ARN of the Secrets Manager secret containing database credentials | | `database` | String | `""` | Database name to connect to | | `region` | String | `""` | AWS region where the cluster is located (uses default region if empty) | **Microsoft SQL Server configuration** Use Microsoft SQL Server for self-hosted SQL Server instances or Azure SQL Database. | Property | Type | Default | Description | | ------------------------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | `host` | String | `""` | SQL Server hostname (for Azure SQL Database, the fully qualified server name, e.g. `myserver.database.windows.net`) | | `port` | Number | `1433` | SQL Server port | | `database` | String | `""` | Database name to connect to | | `authMode` | String | `"sql"` | Authentication mode: `"sql"` (username and password) or `"workload-identity"` (Azure AD workload identity) | | `user` | String | `""` | Database user for SQL authentication | | `password` | String | `""` | Database password for SQL authentication | | `encrypt` | Boolean | `true` | Encrypt the connection with TLS | | `trustServerCertificate` | Boolean | `false` | Accept the server certificate without validation (use only for development and testing) | | `connectionParams` | String | `""` | Additional connection parameters | | `maxOpenConns` | Number | `4` | Maximum number of open connections in the pool | | `connMaxLifetime` | Duration | `1.h` | Maximum lifetime of a pooled connection | With `authMode = "workload-identity"`, the agent authenticates using the Azure AD workload identity available in its runtime environment, and the `user` and `password` properties are ignored. This is the recommended mode for Azure SQL Database when the agent runs on Azure infrastructure with a managed identity. ### Synchronization Enable synchronization of managed resources. When enabled, formae periodically checks if resources have been modified outside of the tool. | Property | Type | Default | Description | | ---------- | -------- | ------- | -------------------------------------------- | | `enabled` | Boolean | `true` | Enable synchronization of managed resources | | `interval` | Duration | `5.min` | How frequently to check for external changes | ### Discovery Automatically finds and catalogs infrastructure resources not yet under formae management. Useful for onboarding existing infrastructure. Discovery scans targets that have been marked as `discoverable = true` in your forma files. See the [discovery documentation](/documentation/concepts/discovery) for details on configuring discoverable targets. | Property | Type | Default | Description | | ------------------------- | ------------- | ---------- | ------------------------------------------------------------------------------ | | `enabled` | Boolean | `true` | Enable automatic resource discovery | | `labelTagKeys` | List\ | `["Name"]` | Tag keys to use when building resource labels (concatenated with dashes) | | `interval` | Duration | `10.min` | How frequently to run discovery scans | | `resourceTypesToDiscover` | List\ | `[]` | Specific resource types to discover (empty list discovers all supported types) | ### Logging Configure log output levels and file locations. Keep the file log level at `debug` for troubleshooting support. | Property | Type | Default | Description | | ----------------- | ------ | -------------------------------- | ---------------------------------------------------------------------- | | `filePath` | String | `"~/.pel/formae/log/formae.log"` | Path to the log file | | `fileLogLevel` | String | `"debug"` | Log level for file output: `"debug"`, `"info"`, `"warn"`, `"error"` | | `consoleLogLevel` | String | `"info"` | Log level for console output: `"debug"`, `"info"`, `"warn"`, `"error"` | ### OpenTelemetry Enable telemetry export to your observability platform. When enabled, metrics, logs, and traces are pushed via OTLP. | Property | Type | Default | Description | | ------------- | ------- | ---------------- | ----------------------------------------- | | `enabled` | Boolean | `false` | Enable OpenTelemetry integration | | `serviceName` | String | `"formae-agent"` | Service name for telemetry identification | **OTLP configuration** | Property | Type | Default | Description | | ------------- | ------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `enabled` | Boolean | `true` | Whether to enable OTLP push export for traces, metrics, and logs | | `endpoint` | String | `"http://localhost:4317"` | OTLP collector endpoint URL | | `protocol` | String | `"grpc"` | Protocol to use: `"grpc"` or `"http"` | | `insecure` | Boolean | `true` | Whether to use insecure connections (disable TLS) | | `temporality` | String | `"delta"` | Metric temporality: `"delta"` (OTel-native) or `"cumulative"` (for Prometheus/Mimir backends without delta support) | **Prometheus configuration** | Property | Type | Default | Description | | --------- | ------- | ------- | ------------------------------------------------------------- | | `enabled` | Boolean | `true` | Enable Prometheus `/metrics` endpoint for pull-based scraping | ### Retry Control how formae handles transient failures during resource operations. These values are the agent-wide defaults and can be overridden per plugin in [Resource plugins](#resource-plugins). | Property | Type | Default | Description | | --------------------- | -------- | ------- | ------------------------------------------------------------ | | `statusCheckInterval` | Duration | `20.s` | How frequently to check the status of a resource operation | | `maxRetries` | Number | `9` | Maximum number of retries for a recoverable failed operation | | `retryDelay` | Duration | `10.s` | How long to wait before retrying a failed operation | ### Resource plugins `agent.resourcePlugins` is a list of per-plugin configuration blocks that override defaults published by each plugin. Each installed plugin ships its own typed configuration that you import through the `plugins:/` scheme. Any plugin that is not listed uses its built-in defaults. Available overrides on every resource plugin: | Property | Type | Description | | ------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | Boolean | Set to `false` to disable the plugin. When disabled no other fields apply. Default: `true`. | | `rateLimit` | RateLimitConfig? | Cap the request rate this plugin issues to its provider. `maxRequestsPerSecondForNamespace` is the per-target cap. | | `retry` | RetryConfig? | Override the agent-wide [retry](#retry) settings for this plugin. | | `resourceTypesToDiscover` | List\? | Restrict which resource types this plugin reports during discovery. An empty/absent value means "all supported types". | | `discoveryFilters` | List\? | Exclude specific discovered resources by matching on properties (for example, skipping resources that carry a Kubernetes-owner tag). | | `labelConfig` | LabelConfig? | Customize how human-readable labels are generated for discovered resources. `defaultQuery` is a JSONPath query; `resourceOverrides` sets per-resource-type queries that win over the default. | Plugins may also expose **plugin-specific** fields defined in their own `schema/Config.pkl`. These appear alongside the fields above on the same configuration block. #### Disabling a plugin Use `enabled = false` to stop the agent from loading a plugin. No other options apply in this case. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" import "plugins:/Azure.pkl" as Azure agent { resourcePlugins { new Azure.PluginConfig { enabled = false } } } ``` #### Tuning an installed plugin This example uses the SFTP plugin because it exposes plugin-specific fields (`defaultTimeoutSeconds`, `defaultFilePermissions`) in addition to the common overrides. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" import "plugins:/Sftp.pkl" as Sftp agent { resourcePlugins { new Sftp.PluginConfig { // Common overrides rateLimit { maxRequestsPerSecondForNamespace = 5 } retry { maxRetries = 3 retryDelay = 30.s } // Plugin-specific fields defined by the SFTP plugin defaultTimeoutSeconds = 60 defaultFilePermissions = "0600" } } } ``` You can combine multiple plugin blocks under a single `resourcePlugins { ... }` listing. The `type` field is automatically set by each plugin's wrapper, so you only need to fill in the overrides you care about. #### labelConfig `labelConfig` customizes how human-readable labels are generated for resources discovered by this plugin. The query is a [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html) expression evaluated against the resource's properties. Each plugin ships a sensible default (for example, the AWS plugin uses the `Name` tag, `$.Tags[?(@.Key=='Name')].Value`). Override per resource type when the plugin's default doesn't suit your environment, or set `defaultQuery` to change the rule for every type: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" import "plugins:/Aws.pkl" as Aws agent { resourcePlugins { new Aws.PluginConfig { labelConfig { resourceOverrides { ["AWS::EC2::Instance"] = "$.Tags[?(@.Key=='env')].Value" } } } } } ``` When the query returns nothing, formae falls back to the resource's provider identifier as the label. See [Label: Labels for discovered resources](/documentation/concepts/label#labels-for-discovered-resources) for the full resolution order and collision behavior. ### CLI Configure how the CLI connects to the agent API, and how its output is themed. See [Authentication](#authentication) for `cli.auth`. | Property | Type | Default | Description | | ------------ | ------ | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `api.url` | String | `"http://localhost"` | Base URL for the agent API | | `api.port` | Number | `49684` | Port number for the agent API | | `theme` | String | `"quiet"` | CLI theme: a built-in (`"quiet"`, `"rich"`, `"colorblind"`) or the filename of a theme in `~/.config/formae/themes/`. Unknown names warn and fall back to `quiet`. See [CLI themes](/documentation/reference/themes). | | `appearance` | String | `"auto"` | Which color variant to use: `"light"`, `"dark"`, or `"auto"` (detect the terminal background). Orthogonal to `theme`. The `FORMAE_APPEARANCE` environment variable overrides it. | ### Authentication Authentication is configured separately for the agent and CLI. The `agent.auth` block controls server-side validation; the `cli.auth` block provides client-side credentials. Both fields are optional. If unset, the agent serves unauthenticated requests. Each auth plugin ships its own typed configuration that you import through the `plugins:/` scheme, the same pattern used for [Resource plugins](#resource-plugins). The example below uses the [auth-basic](https://github.com/platform-engineering-labs/formae-plugin-auth-basic) plugin, which is the default HTTP Basic Authentication option. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "formae:/Config.pkl" import "plugins:/AuthBasic.pkl" as AuthBasic agent { auth = new AuthBasic.AgentConfig { authorizedUsers { new AuthBasic.AuthorizedUser { username = "alice" // bcrypt hash, generate with: // htpasswd -bnBC 10 "" yourPassword | tr -d ':' password = "$2y$10$ki1wCrM94EViuTv0dRNEVuP3ujj2/uu2Zh8/FyFvExjZyrsdtr1SS" } } } } cli { auth = new AuthBasic.CliConfig { username = "alice" password = "mySecretPass" } } ``` See [Security and networking](/documentation/reference/security-and-networking#authentication) for more detail on the auth-basic setup. ### Network Top-level network configuration. The `type` field selects the network plugin. | Property | Type | Default | Description | | ------------------- | ---------------- | ------- | ---------------------------------------- | | `network.type` | String | none | Network plugin type (e.g. `"tailscale"`) | | `network.tailscale` | TailscaleConfig? | `null` | Tailscale-specific configuration | See [Tailscale](/documentation/reference/security-and-networking#tailscale-experimental) for setup instructions. ### Plugin directory Top-level setting for where formae discovers installed plugins. | Property | Type | Default | Description | | ----------- | ------ | ------------------------- | --------------------------------------------------------------------------------------------------------- | | `pluginDir` | String | `"~/.pel/formae/plugins"` | Directory where installed plugins live. Can also be set via the `FORMAE_PLUGIN_DIR` environment variable. | ### Artifact repositories Top-level `artifacts` block configures the orbital repositories formae consults to install and update its binary, plugins, and tooling. Both the agent and the CLI share this configuration; `formae plugin install`, `formae update`, and `formae plugin upgrade` all resolve packages against the listed repositories. | Property | Type | Default | Description | | ------------------------ | --------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `artifacts.repositories` | `Listing` | `pel` (binary) and `community` (formae-plugin) on `hub.platform.engineering` at the `stable` channel | List of orbital repositories to consult for packages. The defaults cover both binary and plugin distribution; only override when running against a private or custom hub. | Each entry in `artifacts.repositories` is a `Repository` with two fields: | Property | Type | Description | | -------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `uri` | String | Orbital repository URI; the optional `#channel` fragment selects the release channel (typically `stable` or `dev`). | | `type` | `"binary"` \| `"formae-plugin"` | Discriminator. `binary` repos serve the formae binary and tooling (consulted by `formae update`); `formae-plugin` repos serve plugins (consulted by `formae plugin install / upgrade`). | The default value is equivalent to: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} artifacts { repositories { new { uri = "https://hub.platform.engineering/repos/platform.engineering/pel#stable" type = "binary" } new { uri = "https://hub.platform.engineering/repos/platform.engineering/community#stable" type = "formae-plugin" } } } ``` A typical override that points at a private hub mirroring the same shape: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} artifacts { repositories { new { uri = "https://hub.example.com/repos/example.com/pel#stable" type = "binary" } new { uri = "https://hub.example.com/repos/example.com/community#stable" type = "formae-plugin" } } } ``` #### Deprecated flat fields Earlier releases configured a single repository via flat `artifacts.url`, `artifacts.username`, and `artifacts.password` fields. Those fields are still accepted for backwards compatibility but emit deprecation warnings on agent startup, and they cover only the binary repository: plugin installation requires the new `repositories` listing. Migrate by replacing: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} artifacts { url = "https://hub.platform.engineering/repos/platform.engineering/pel#stable" } ``` with the explicit listing shown above. The `username` and `password` fields have no like-for-like replacement in 0.85; per-repository credentials will be reintroduced in a later release. ### Deprecated `plugins` block Earlier releases configured authentication, network access, and the plugin directory under a single top-level `plugins { ... }` block. That block is still accepted for backwards compatibility but emits deprecation warnings at startup. Migrate as follows: | Old location | New location | | ------------------------ | ------------------------- | | `plugins.authentication` | `agent.auth` + `cli.auth` | | `plugins.network` | top-level `network` | | `plugins.pluginDir` | top-level `pluginDir` | # Pkl cheatsheet Source: https://docs.formae.io/documentation/reference/pkl-cheatsheet Quick reference for Pkl syntax: comments, types, control flow, functions, and the standard library. For a comprehensive introduction to Pkl, check out our [Pkl primer](https://pkl.platform.engineering), which covers the fundamentals in just a few minutes. For more in-depth documentation, see the official [Pkl language tutorial](https://pkl-lang.org/main/current/language-tutorial/index.html). ## Basic syntax ### Comments ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // This is a comment /* This is a multi-line multi-line comment */ /// User-facing documentation for a member ``` ### Module declaration ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} module MyModule ``` ### Importing modules | Type | Syntax | | ------------------------- | --------------------------------------------------------------- | | Standard library | `import "pkl:json"` | | Local module | `import "path/to/module.pkl"` | | Package | `import "package://pkg.pkl-lang.org/pkl-pantry/pkl.toml@1.0.0"` | | Project package reference | `import @toml/toml.pkl` | ### Variables and assignments ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} name = "Dodo" // Immutable by default local age = 42 // Local scope var mutableValue = 10 // Mutable (use sparingly) ``` ### Objects ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} dodo { name = "Dodo" extinct = true } // Access: dodo.name ``` ### Amendments ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} dodo { name = "Dodo" extinct = true } revived = (dodo) { extinct = false } ``` ## Data types ### Numbers ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Integer num = 123 hex = 0x1A binary = 0b1011 octal = 0o755 // Float float = 1.23 scientific = 1.2e-3 // Readable large = 1_000_000.50 ``` ### Booleans ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} isActive = true isFalse = false ``` ### Strings ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} text = "Hello, Pkl!" unicode = "\u{1F426}" // 🐦 multiline = """ Line 1 Line 2 """ ``` ### Durations ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} time = 5.min delay = 300.ms ``` ### Data sizes ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} size = 5.mb large = 1.gb ``` ### Null ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} nothing = null ``` ### Collections ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // List numbers = [1, 2, 3] // Listing (lazy evaluated) foods = new Listing { "bacon" "nachos" } // Set unique = new Set { 1 2 3 } // Mapping pairs = new Mapping { ["key1"] = "value1" ["key2"] = "value2" } ``` ## Classes and objects ### Class definition ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} class Bird { name: String extinct: Boolean } ``` ### Instantiation ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} dodo = new Bird { name = "Dodo"; extinct = true } ``` ### Type constraints ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} age: Int(isBetween(0, 130)) oddName: String(length.isOdd, chars.first == chars.last) ``` ### Operators | Category | Operators | Example | | --------------- | -------------------------------------------------------------- | -------------------------------- | | Arithmetic | `+`, `-`, `*`, `/`, `~/` (integer division), `%`, `**` (power) | `sum = 5 + 3` / `power = 2 ** 3` | | Comparison | `==`, `!=`, `<`, `<=`, `>`, `>=` | `isEqual = 5.mb == 3.kib` | | Logical | `&&`, `\|\|`, `!`, `.xor`, `.implies` | `result = true && false` | | Null coalescing | `??` | `value = maybeNull ?? "default"` | ## Control flow ### If expression ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} status = if (age > 18) "Adult" else "Minor" ``` ### For generators ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} // Generate over a list names = List("Pigeon", "Barn owl", "Parrot") birds { for (_name in names) { new { name = _name lifespan = 42 } } } // Generate over a map namesAndLifespans = Map("Pigeon", 8, "Barn owl", 15, "Parrot", 20) birdsByName { for (_name, _lifespan in namesAndLifespans) { [_name] { name = _name lifespan = _lifespan } } } ``` ### When generators ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} needNfs = true class Server { type: String name: String } servers { when (needNfs) { new Server { type = "NFS" name = "nfs-server" } } new Server { type = "WWW" name = "www-server" } new Server { type = "App" name = "app-server" } } ``` ### Let expression ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} result = let (x = 5) x * 2 ``` ## Functions and methods ### Function definition ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} function double(x: Int): Int = x * 2 ``` ### Method in class ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} class Bird { function describe() = "\(name) is \(extinct ? "extinct" : "alive")" } ``` ### Calling functions and methods ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} doubled = double(5) // 10 desc = dodo.describe() ``` ## Standard library modules The standard library is imported with `import "pkl:"`. | Module | Purpose | Example | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `pkl:base` | Fundamental types (`Int`, `Float`, `String`, `Boolean`, `Collection`) and properties (`isFinite`, `length`, `isEmpty`). Auto-imported, no explicit import needed. | `import "pkl:base"` | | `pkl:json` | Parse and render JSON | `json.parse("{\"key\": \"value\"}")`, `json.render(myObject)` | | `pkl:math` | Constants and functions | `math.pi`, `math.e`, `math.sqrt(16)`, `math.abs(-5)` | | `pkl:platform` | Platform info | `platform.os`, `platform.arch` | | `pkl:toml` (from pantry) | Parse and render TOML | `import "package://pkg.pkl-lang.org/pkl-pantry/pkl.toml@1.0.0"` | | `pkl:reflect` | Reflection utilities | | | `pkl:protobuf` | Experimental Protocol Buffers renderer | | | `pkl:yaml` | YAML parsing/rendering | | ## Error handling and validation ### Throw ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} throw("Invalid input") ``` ### Constraints ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} age: Int(isPositive) // Throws if negative ``` # Plugin catalog Source: https://docs.formae.io/documentation/reference/plugin-catalog Where to find every formae plugin, and which ones are documented here. formae's [plugins](/documentation/concepts/plugin) are published on the **formae hub**. The hub is the full, versioned catalog: every plugin, its channels (`stable` and `dev`), and the exact install command for each version. The complete plugin catalog, across all providers and versions. Per-plugin release notes now live on the hub, not in these docs. Each plugin's hub page has a **changelog** tab with the notes for every version, alongside its readme and version history. For example, see the [AWS plugin](https://hub.platform.engineering/platform.engineering/aws). ## Documented here The reference for the providers that ship with the standard package lives in these docs: Every other plugin (observability, data, CI/CD, and more) is documented on the hub. Its per-resource reference is generated from the plugin's own schema, so it always matches the version you install. ## Installing a plugin Install a plugin on the host running the agent: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae plugin install ``` See [Plugins](/documentation/concepts/plugin) for how plugins are loaded, and `formae plugin --help` for `search`, `info`, `install`, and `update`. # AWS configuration Source: https://docs.formae.io/documentation/reference/providers/aws/configuration Configure an AWS target for formae: credentials and target settings. The AWS plugin enables formae to manage AWS resources using the [AWS Cloud Control API](https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/what-is-cloudcontrolapi.html). ## Configuration ### Target Configure an AWS target in your Forma file: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/formae.pkl" import "@aws/aws.pkl" target: formae.Target = new formae.Target { label = "aws-target" config = new aws.Config { region = "us-east-1" // Optional: specify a named profile // profile = "my-profile" } } ``` **Config field mutability:** | Field | Mutable | Description | | --------- | ------- | ------------------------------------------------------------------------------------------------------- | | `profile` | Yes | Changing the profile updates the target in place | | `region` | No | Changing the region triggers a full [target replace](/documentation/concepts/target#replacing-a-target) | See [Replacing a target](/documentation/concepts/target#replacing-a-target) for details. ### Credentials The plugin uses the standard AWS credential chain. Configure credentials using one of the following methods: **Environment Variables:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export AWS_ACCESS_KEY_ID="your-access-key" export AWS_SECRET_ACCESS_KEY="your-secret-key" export AWS_REGION="us-east-1" # For temporary credentials (e.g., from STS AssumeRole) export AWS_SESSION_TOKEN="your-session-token" ``` **Named Profile:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} # Use a profile from ~/.aws/credentials export AWS_PROFILE="my-profile" ``` You can also restrict credential usage to a named profile using the `profile` property: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} config = new aws.Config { region = "us-east-1" profile = "my-profile" } ``` ```ini theme={"languages":{"custom":["/languages/pkl.json"]}} # ~/.aws/credentials [my-profile] aws_access_key_id = YOUR_ACCESS_KEY_ID aws_secret_access_key = YOUR_SECRET_ACCESS_KEY ``` **IAM Instance Profile / ECS Task Role:** When running on EC2 or ECS, credentials are automatically retrieved from the instance metadata service. **OIDC (for CI/CD):** For GitHub Actions, use `aws-actions/configure-aws-credentials` with OIDC federation. ### Required IAM permissions The credentials above must be allowed to perform the operations the plugin needs. Two distinct sets of permissions apply: * **Apply / destroy**: create, read, update, and delete the resource types in your formas. The AWS-managed `PowerUserAccess` policy covers most services, but **excludes IAM**; managing IAM resources (roles, policies, users, instance profiles) requires explicit `iam:*` grants. * **Discovery**: the agent continuously discovers existing resources via the CloudControl `ListResources` API. This needs **read-only `List*` / `Describe*` / `Get*` permissions** across the services you want discovered. For IAM in particular, `PowerUserAccess` does not grant `iam:List*` / `iam:Get*`, so without them discovery logs recurring `403` errors (e.g. `iam:ListServerCertificates`, `iam:ListSAMLProviders`, `iam:ListOpenIDConnectProviders`, `iam:ListGroups`). For a complete, copy-pasteable task-role policy, including the IAM management and read-only discovery statements, see [Install on AWS: Create IAM roles](/documentation/guides/install-agent). # A self-service database Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/a-self-service-database An AWS infrastructure pattern, deployed with formae. Instead of a fixed forma, expose a parameterized database that developers deploy with a few flags. This is the platform-engineering pattern: you write one forma that maps a small vocabulary (a team, an environment, a size) to a production-ready RDS instance with your standards baked in (encryption, multi-AZ, deletion protection), and developers run `formae apply --mode reconcile --team payments --env staging`. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["team-env"] direction LR vpc["VPC"]:::res subnets["Private subnets"]:::res subnetGroup["DB subnet group"]:::res sg["Database security group"]:::res db["RDS instance"]:::res vpc --> subnets subnets --> subnetGroup vpc --> sg subnetGroup --> db sg --> db end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** A developer supplies the flags and applies; reconcile brings their stack into being exactly as the forma and their choices describe it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --team payments --env staging team-database.pkl ``` The forma, the property interface, and the standards it enforces are covered in [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure). # Base VPC networking Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/base-vpc-networking An AWS infrastructure pattern, deployed with formae. The foundational network that other workloads build on, meant to be applied holistically in reconcile mode from a Git repository. One forma provisions a VPC, an internet gateway, two public subnets, a public route table with its route and subnet associations, and two security groups (one for a load balancer, one for tasks) where the task security group accepts traffic from the load balancer security group. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["lifeline"] direction LR vpc["VPC"]:::res igw["Internet gateway"]:::res subnets["Public subnets"]:::res routing["Route table + routes + associations"]:::res albSg["ALB security group"]:::res taskSg["Task security group"]:::res vpc --> igw vpc --> subnets vpc --> routing igw --> routing subnets --> routing vpc --> albSg vpc --> taskSg albSg --> taskSg end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae brings up the VPC before the subnets, route table, and security groups that reference it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/lifeline/basic_infrastructure.pkl ``` **Verify.** Once the command completes, the VPC is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::EC2::VPC" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/lifeline/basic_infrastructure.pkl ``` The full forma is in the [lifeline example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/lifeline). # Bookstore on EKS Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/bookstore-on-eks An AWS infrastructure pattern, deployed with formae. A full-stack bookstore application running on a managed EKS cluster. This example lives in the formae Kubernetes plugin, and the AWS entry file provisions the cluster itself (a VPC with public subnets, an internet gateway and routing, cluster and node IAM roles, security groups, and an EKS AutoMode cluster) and then deploys the workload onto it: a namespace, ConfigMaps and a secret, a backend service account, an nginx frontend deployment behind a LoadBalancer service, and a Node.js API backend behind a ClusterIP service. formae orders the whole graph, so the cluster is ready before the Kubernetes resources land on it. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt k8sTgt["k8s-target-aws-bookstore"]:::tgt subgraph stack["k8s-bookstore-aws"] direction LR vpc["VPC + subnets + IGW + routing"]:::res roles["IAM roles (cluster, node)"]:::res sgs["Security groups (cluster, node)"]:::res eks["EKS AutoMode cluster"]:::res sc["EBS storage class"]:::res ns["Namespace"]:::res config["ConfigMaps + secret"]:::res sa["Backend service account"]:::res frontend["Frontend deployment (nginx)"]:::res backend["Backend deployment (Node API)"]:::res feSvc["Frontend service (LoadBalancer)"]:::res beSvc["Backend service (ClusterIP)"]:::res vpc --> eks roles --> eks sgs --> eks eks --> sc ns --> config ns --> sa config --> frontend config --> backend sa --> backend frontend --> feSvc backend --> beSvc end target --> stack eks --> k8sTgt k8sTgt --> ns classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the AWS entry file. The cloud is chosen by which entry file you apply, so `aws.pkl` provisions the EKS cluster and then the workload on top of it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/aws.pkl ``` **Verify.** Once the command completes, the EKS cluster is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::EKS::Cluster" ``` **Tear down.** Remove everything the forma created. Because the workload depends on the cluster, cascade the destroy to its dependents: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --on-dependents=cascade \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/aws.pkl ``` The full forma is in the [bookstore example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/bookstore). # CloudFront edge Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/cloudfront-edge An AWS infrastructure pattern, deployed with formae. A minimal CloudFront edge stack that wires together the full set of edge building blocks: an S3 origin bucket reached through an origin access control, a CloudFront function backed by a key value store, and cache, origin request, and response headers policies. The distribution references all of them. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["cloudfront-edge-minimal"] direction LR bucket["Origin bucket"]:::res kvs["Key value store"]:::res fn["CloudFront function"]:::res cp["Cache policy"]:::res orp["Origin request policy"]:::res rhp["Response headers policy"]:::res oac["Origin access control"]:::res dist["Distribution"]:::res kvs --> fn bucket --> dist oac --> dist cp --> dist orp --> dist rhp --> dist fn --> dist end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae creates the policies, function, key value store, and origin bucket before the distribution that references them: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/cloudfront-edge-minimal/main.pkl ``` **Verify.** Once the command completes, the distribution is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::CloudFront::Distribution" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/cloudfront-edge-minimal/main.pkl ``` The full forma is in the [cloudfront-edge-minimal example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/cloudfront-edge-minimal). # Deploy a Lambda Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/deploy-a-lambda An AWS infrastructure pattern, deployed with formae. A VPC-bound Lambda function that reads its dependencies from environment variables instead of hardcoded values. One forma reconciles the foundation (a VPC with private subnets, security groups, an IAM execution role, S3 buckets, and an RDS Postgres database), and a second forma patches in the function once its deployment package is in the deployment bucket. The function's environment variables resolve to the database endpoint and bucket names at apply time. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["simple-lambda-stack"] direction LR vpc["VPC"]:::res igw["Internet gateway"]:::res subnets["Private subnets"]:::res lambdaRole["Lambda role"]:::res dbSg["Database security group"]:::res lambdaSg["Lambda security group"]:::res deployBucket["Deployment bucket"]:::res dataBucket["Data bucket"]:::res dbSubnetGroup["DB subnet group"]:::res database["RDS Postgres"]:::res fn["Lambda function"]:::res vpc --> igw vpc --> subnets vpc --> dbSg vpc --> lambdaSg subnets --> dbSubnetGroup dbSg --> database dbSubnetGroup --> database lambdaRole --> fn database --> fn dataBucket --> fn deployBucket --> fn lambdaSg --> fn subnets --> fn end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** First reconcile the foundational infrastructure. Then, once the deployment package is uploaded to the deployment bucket, patch in the function: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/lambda-env/apply_infra.pkl formae apply --mode patch \ /opt/pel/formae/examples/formae-plugin-aws/lambda-env/patch_lambda.pkl ``` **Verify.** Once the function is applied, it is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::Lambda::Function" ``` **Tear down.** Remove the function first, then the foundation: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/lambda-env/patch_lambda.pkl formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/lambda-env/apply_infra.pkl ``` The full forma is in the [lambda-env example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/lambda-env). # Deploy an ECS service Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/deploy-an-ecs-service An AWS infrastructure pattern, deployed with formae. A load-balanced containerized service on ECS, with its own networking. One forma provisions the VPC and public subnets, the security groups, an ECS cluster and task definition, an Application Load Balancer with a listener and target group, and the ECS service behind it. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["ecs-hello-world"] direction LR vpc["VPC"]:::res igw["Internet gateway"]:::res subnet1["Public subnet 1"]:::res subnet2["Public subnet 2"]:::res albSg["ALB security group"]:::res taskSg["Task security group"]:::res cluster["ECS cluster"]:::res taskDef["Task definition"]:::res alb["Load balancer"]:::res tg["Target group"]:::res listener["Listener"]:::res service["ECS service"]:::res vpc --> igw vpc --> subnet1 vpc --> subnet2 vpc --> albSg vpc --> taskSg subnet1 --> alb subnet2 --> alb albSg --> alb alb --> listener tg --> listener cluster --> service taskDef --> service tg --> service taskSg --> service subnet1 --> service subnet2 --> service end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the VPC comes up before the subnets, the load balancer before the service, and so on: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/ecs-hello-world/ecs_hello_world.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::ECS::Service" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/ecs-hello-world/ecs_hello_world.pkl ``` The full forma is in the [ecs-hello-world example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/ecs-hello-world). # ML platform Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/ml-platform An AWS infrastructure pattern, deployed with formae. A complete machine learning platform built on Amazon SageMaker with its supporting infrastructure. One forma provisions the VPC (with public and private subnets, NAT, and VPC endpoints), security groups, a KMS key, an encrypted EFS file system with access points, S3 buckets for training data and model artifacts, an RDS feature store, IAM roles, CloudWatch logs, ECR repositories with an ECS cluster, and the SageMaker domain with its user profiles and model package group. The KMS key encrypts storage across the platform, and the SageMaker domain sits on the private network with access to EFS and its execution roles. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["ml-platform"] direction LR vpc["VPC, subnets, NAT, endpoints"]:::res sgs["Security groups"]:::res kms["KMS key"]:::res efs["EFS + access points"]:::res buckets["S3 buckets"]:::res db["RDS feature store"]:::res roles["IAM roles"]:::res logs["CloudWatch logs"]:::res containers["ECR + ECS cluster"]:::res sagemaker["SageMaker domain, profiles, model group"]:::res vpc --> sgs kms --> efs kms --> buckets kms --> db kms --> logs kms --> containers vpc --> efs sgs --> efs vpc --> db sgs --> db vpc --> sagemaker sgs --> sagemaker efs --> sagemaker roles --> sagemaker end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the platform by its dependencies, so the KMS key and network come up before the storage, roles, and SageMaker domain that depend on them: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/ml-platform/main.pkl ``` **Verify.** Once the command completes, the SageMaker domain is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::SageMaker::Domain" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/ml-platform/main.pkl ``` The full forma is in the [ml-platform example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/ml-platform). # Static website (S3 + CloudFront) Source: https://docs.formae.io/documentation/reference/providers/aws/patterns/static-website-s3-cloudfront An AWS infrastructure pattern, deployed with formae. A static site served from an S3 website bucket, fronted by a CloudFront distribution, with a Route 53 hosted zone and an alias record pointing at the distribution. The bucket policy makes the objects publicly readable, the distribution uses the bucket's website endpoint as its origin, and the alias record resolves to the distribution's domain name. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["aws-target"]:::tgt subgraph stack["static-website-stack"] direction LR bucket["Website bucket"]:::res policy["Bucket policy"]:::res dist["CloudFront distribution"]:::res zone["Route 53 hosted zone"]:::res record["Alias record"]:::res bucket --> policy bucket --> dist zone --> record dist --> record end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae brings up the bucket and its policy before the distribution, and the hosted zone before the alias record: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-aws/static-website/main.pkl ``` **Verify.** Once the command completes, the distribution is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AWS::CloudFront::Distribution" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-aws/static-website/main.pkl ``` The full forma is in the [static-website example](https://github.com/platform-engineering-labs/formae-plugin-aws/tree/main/examples/static-website). # AWS supported resources Source: https://docs.formae.io/documentation/reference/providers/aws/supported-resources The resource types the formae AWS plugin supports. ## Supported Resources ### Compute | Type | Discoverable | Extractable | Comment | | --------------------------------------------- | ------------ | ----------- | ------- | | AWS::AppRunner::AutoScalingConfiguration | ✅ | ✅ | | | AWS::AppRunner::ObservabilityConfiguration | ✅ | ✅ | | | AWS::AppRunner::Service | ✅ | ✅ | | | AWS::AppRunner::VpcConnector | ✅ | ✅ | | | AWS::AppRunner::VpcIngressConnection | ✅ | ✅ | | | AWS::ECS::CapacityProvider | ✅ | ✅ | | | AWS::ECS::Cluster | ✅ | ✅ | | | AWS::ECS::ClusterCapacityProviderAssociations | ✅ | ✅ | | | AWS::ECS::ExpressGatewayService | ❌ | ✅ | | | AWS::ECS::PrimaryTaskSet | ❌ | ✅ | | | AWS::ECS::Service | ✅ | ✅ | | | AWS::ECS::TaskDefinition | ✅ | ✅ | | | AWS::ECS::TaskSet | ✅ | ❌ | | | AWS::EKS::AccessEntry | ✅ | ✅ | | | AWS::EKS::Addon | ✅ | ✅ | | | AWS::EKS::Cluster | ✅ | ✅ | | | AWS::EKS::FargateProfile | ✅ | ✅ | | | AWS::EKS::IdentityProviderConfig | ✅ | ✅ | | | AWS::EKS::Nodegroup | ✅ | ✅ | | | AWS::EKS::PodIdentityAssociation | ✅ | ✅ | | | AWS::ElasticBeanstalk::Application | ✅ | ✅ | | | AWS::ElasticBeanstalk::ApplicationVersion | ✅ | ✅ | | | AWS::ElasticBeanstalk::ConfigurationTemplate | ✅ | ✅ | | | AWS::ElasticBeanstalk::Environment | ✅ | ✅ | | | AWS::Lambda::Alias | ✅ | ✅ | | | AWS::Lambda::CodeSigningConfig | ✅ | ✅ | | | AWS::Lambda::EventInvokeConfig | ✅ | ❌ | | | AWS::Lambda::EventSourceMapping | ✅ | ✅ | | | AWS::Lambda::Function | ✅ | ✅ | | | AWS::Lambda::LayerVersion | ❌ | ✅ | | | AWS::Lambda::LayerVersionPermission | ❌ | ✅ | | | AWS::Lambda::Permission | ✅ | ✅ | | | AWS::Lambda::Url | ✅ | ✅ | | | AWS::Lambda::Version | ✅ | ✅ | | ### EC2 | Type | Discoverable | Extractable | Comment | | ---------------------------------------------------------------- | ------------ | ----------- | --------------------------------------------------- | | AWS::EC2::CapacityReservation | ✅ | ✅ | | | AWS::EC2::CapacityReservationFleet | ✅ | ✅ | | | AWS::EC2::CarrierGateway | ✅ | ✅ | | | AWS::EC2::ClientVpnAuthorizationRule | ❌ | ✅ | | | AWS::EC2::ClientVpnEndpoint | ❌ | ✅ | | | AWS::EC2::ClientVpnRoute | ❌ | ✅ | | | AWS::EC2::ClientVpnTargetNetworkAssociation | ❌ | ✅ | | | AWS::EC2::CustomerGateway | ✅ | ✅ | | | AWS::EC2::DHCPOptions | ✅ | ✅ | | | AWS::EC2::EC2Fleet | ✅ | ✅ | | | AWS::EC2::EIP | ✅ | ✅ | | | AWS::EC2::EIPAssociation | ✅ | ✅ | | | AWS::EC2::EgressOnlyInternetGateway | ✅ | ✅ | | | AWS::EC2::EnclaveCertificateIamRoleAssociation | ❌ | ✅ | | | AWS::EC2::FlowLog | ✅ | ✅ | | | AWS::EC2::GatewayRouteTableAssociation | ❌ | ✅ | | | AWS::EC2::Host | ✅ | ✅ | | | AWS::EC2::IPAM | ✅ | ✅ | | | AWS::EC2::IPAMAllocation | ✅ | ✅ | | | AWS::EC2::IPAMPool | ✅ | ✅ | | | AWS::EC2::IPAMPoolCidr | ✅ | ✅ | | | AWS::EC2::IPAMResourceDiscovery | ✅ | ✅ | | | AWS::EC2::IPAMResourceDiscoveryAssociation | ✅ | ✅ | | | AWS::EC2::IPAMScope | ✅ | ✅ | | | AWS::EC2::Instance | ✅ | ✅ | | | AWS::EC2::InstanceConnectEndpoint | ✅ | ✅ | | | AWS::EC2::InternetGateway | ✅ | ✅ | | | AWS::EC2::KeyPair | ✅ | ✅ | | | AWS::EC2::LaunchTemplate | ✅ | ✅ | | | AWS::EC2::LocalGatewayRoute | ✅ | ✅ | | | AWS::EC2::LocalGatewayRouteTable | ✅ | ✅ | | | AWS::EC2::LocalGatewayRouteTableVPCAssociation | ✅ | ✅ | | | AWS::EC2::LocalGatewayRouteTableVirtualInterfaceGroupAssociation | ✅ | ✅ | | | AWS::EC2::NatGateway | ✅ | ✅ | | | AWS::EC2::NetworkAcl | ✅ | ✅ | | | AWS::EC2::NetworkAclEntry | ❌ | ✅ | | | AWS::EC2::NetworkInsightsAccessScope | ✅ | ✅ | | | AWS::EC2::NetworkInsightsAccessScopeAnalysis | ✅ | ✅ | | | AWS::EC2::NetworkInsightsAnalysis | ✅ | ✅ | | | AWS::EC2::NetworkInsightsPath | ✅ | ✅ | | | AWS::EC2::NetworkInterface | ✅ | ✅ | | | AWS::EC2::NetworkInterfaceAttachment | ✅ | ✅ | | | AWS::EC2::NetworkInterfacePermission | ❌ | ✅ | | | AWS::EC2::NetworkPerformanceMetricSubscription | ✅ | ✅ | | | AWS::EC2::PlacementGroup | ✅ | ✅ | | | AWS::EC2::PrefixList | ✅ | ❌ | AWS-owned prefix lists are excluded from discovery | | AWS::EC2::Route | ✅ | ✅ | The implicit local route is excluded from discovery | | AWS::EC2::RouteTable | ✅ | ✅ | | | AWS::EC2::SecurityGroup | ✅ | ✅ | | | AWS::EC2::SecurityGroupEgress | ✅ | ✅ | | | AWS::EC2::SecurityGroupIngress | ✅ | ✅ | | | AWS::EC2::SecurityGroupVpcAssociation | ✅ | ✅ | | | AWS::EC2::SnapshotBlockPublicAccess | ✅ | ✅ | | | AWS::EC2::SpotFleet | ✅ | ✅ | | | AWS::EC2::Subnet | ✅ | ✅ | | | AWS::EC2::SubnetCidrBlock | ✅ | ✅ | | | AWS::EC2::SubnetNetworkAclAssociation | ✅ | ✅ | | | AWS::EC2::SubnetRouteTableAssociation | ✅ | ✅ | | | AWS::EC2::TrafficMirrorFilter | ✅ | ✅ | | | AWS::EC2::TrafficMirrorFilterRule | ✅ | ✅ | | | AWS::EC2::TrafficMirrorSession | ✅ | ✅ | | | AWS::EC2::TrafficMirrorTarget | ✅ | ✅ | | | AWS::EC2::TransitGateway | ✅ | ✅ | | | AWS::EC2::TransitGatewayAttachment | ✅ | ✅ | | | AWS::EC2::TransitGatewayConnect | ✅ | ✅ | | | AWS::EC2::TransitGatewayMulticastDomain | ✅ | ✅ | | | AWS::EC2::TransitGatewayMulticastDomainAssociation | ✅ | ✅ | | | AWS::EC2::TransitGatewayMulticastGroupMember | ✅ | ✅ | | | AWS::EC2::TransitGatewayMulticastGroupSource | ✅ | ✅ | | | AWS::EC2::TransitGatewayPeeringAttachment | ✅ | ✅ | | | AWS::EC2::TransitGatewayRoute | ✅ | ✅ | | | AWS::EC2::TransitGatewayRouteTable | ✅ | ✅ | | | AWS::EC2::TransitGatewayRouteTableAssociation | ✅ | ✅ | | | AWS::EC2::TransitGatewayRouteTablePropagation | ✅ | ✅ | | | AWS::EC2::TransitGatewayVpcAttachment | ✅ | ✅ | | | AWS::EC2::VPC | ✅ | ✅ | | | AWS::EC2::VPCBlockPublicAccessExclusion | ✅ | ✅ | | | AWS::EC2::VPCBlockPublicAccessOptions | ❌ | ✅ | | | AWS::EC2::VPCCidrBlock | ✅ | ✅ | | | AWS::EC2::VPCDHCPOptionsAssociation | ✅ | ✅ | | | AWS::EC2::VPCEndpoint | ✅ | ✅ | | | AWS::EC2::VPCEndpointConnectionNotification | ✅ | ✅ | | | AWS::EC2::VPCEndpointService | ✅ | ✅ | | | AWS::EC2::VPCEndpointServicePermissions | ✅ | ✅ | | | AWS::EC2::VPCGatewayAttachment | ✅ | ✅ | | | AWS::EC2::VPCPeeringConnection | ✅ | ✅ | | | AWS::EC2::VPNConnection | ✅ | ✅ | | | AWS::EC2::VPNConnectionRoute | ✅ | ✅ | | | AWS::EC2::VPNGateway | ✅ | ✅ | | | AWS::EC2::VPNGatewayRoutePropagation | ❌ | ✅ | | | AWS::EC2::VerifiedAccessEndpoint | ✅ | ✅ | | | AWS::EC2::VerifiedAccessGroup | ✅ | ✅ | | | AWS::EC2::VerifiedAccessInstance | ✅ | ✅ | | | AWS::EC2::VerifiedAccessTrustProvider | ✅ | ✅ | | | AWS::EC2::Volume | ✅ | ✅ | | | AWS::EC2::VolumeAttachment | ✅ | ✅ | | ### Networking & content delivery | Type | Discoverable | Extractable | Comment | | ------------------------------------------------- | ------------ | ----------- | ---------------------------------------------------------------------- | | AWS::CloudFront::CachePolicy | ✅ | ✅ | | | AWS::CloudFront::Distribution | ✅ | ✅ | | | AWS::CloudFront::Function | ✅ | ✅ | | | AWS::CloudFront::KeyValueStore | ✅ | ✅ | | | AWS::CloudFront::OriginAccessControl | ✅ | ✅ | | | AWS::CloudFront::OriginRequestPolicy | ✅ | ✅ | | | AWS::CloudFront::ResponseHeadersPolicy | ✅ | ❌ | | | AWS::ElasticLoadBalancingV2::Listener | ✅ | ✅ | | | AWS::ElasticLoadBalancingV2::ListenerCertificate | ❌ | ✅ | | | AWS::ElasticLoadBalancingV2::ListenerRule | ✅ | ✅ | | | AWS::ElasticLoadBalancingV2::LoadBalancer | ✅ | ✅ | | | AWS::ElasticLoadBalancingV2::TargetGroup | ✅ | ✅ | | | AWS::ElasticLoadBalancingV2::TrustStore | ✅ | ✅ | | | AWS::ElasticLoadBalancingV2::TrustStoreRevocation | ✅ | ✅ | | | AWS::NetworkFirewall::Firewall | ✅ | ✅ | Custom Read/Status; withholds success until per-AZ endpoints propagate | | AWS::NetworkFirewall::FirewallPolicy | ✅ | ✅ | | | AWS::NetworkFirewall::LoggingConfiguration | ✅ | ✅ | | | AWS::NetworkFirewall::RuleGroup | ✅ | ✅ | | | AWS::Route53::CidrCollection | ✅ | ✅ | | | AWS::Route53::DNSSEC | ✅ | ✅ | | | AWS::Route53::HealthCheck | ✅ | ✅ | | | AWS::Route53::HostedZone | ✅ | ✅ | | | AWS::Route53::KeySigningKey | ✅ | ✅ | | | AWS::Route53::RecordSet | ✅ | ✅ | | | AWS::Route53::RecordSetGroup | ❌ | ✅ | | | AWS::ServiceDiscovery::PrivateDnsNamespace | ✅ | ❌ | | | AWS::ServiceDiscovery::Service | ✅ | ✅ | | ### Storage | Type | Discoverable | Extractable | Comment | | ------------------------------------- | ------------ | ----------- | ------- | | AWS::EFS::AccessPoint | ✅ | ✅ | | | AWS::EFS::FileSystem | ✅ | ✅ | | | AWS::EFS::MountTarget | ✅ | ✅ | | | AWS::S3::AccessGrant | ❌ | ✅ | | | AWS::S3::AccessGrantsInstance | ✅ | ✅ | | | AWS::S3::AccessGrantsLocation | ❌ | ✅ | | | AWS::S3::AccessPoint | ✅ | ✅ | | | AWS::S3::Bucket | ✅ | ✅ | | | AWS::S3::BucketPolicy | ✅ | ✅ | | | AWS::S3::MultiRegionAccessPoint | ✅ | ✅ | | | AWS::S3::MultiRegionAccessPointPolicy | ❌ | ✅ | | | AWS::S3::Object | ✅ | ✅ | | | AWS::S3::StorageLens | ✅ | ✅ | | | AWS::S3::StorageLensGroup | ✅ | ✅ | | ### Database | Type | Discoverable | Extractable | Comment | | --------------------------------- | ------------ | ----------- | ------- | | AWS::DynamoDB::GlobalTable | ✅ | ✅ | | | AWS::DynamoDB::Table | ✅ | ✅ | | | AWS::RDS::CustomDBEngineVersion | ✅ | ✅ | | | AWS::RDS::Database | ❌ | ✅ | | | AWS::RDS::DatabaseRole | ❌ | ✅ | | | AWS::RDS::DBCluster | ✅ | ✅ | | | AWS::RDS::DBClusterParameterGroup | ✅ | ✅ | | | AWS::RDS::DBInstance | ✅ | ✅ | | | AWS::RDS::DBParameterGroup | ✅ | ✅ | | | AWS::RDS::DBProxy | ✅ | ✅ | | | AWS::RDS::DBProxyEndpoint | ✅ | ✅ | | | AWS::RDS::DBProxyTargetGroup | ✅ | ✅ | | | AWS::RDS::DBSecurityGroup | ❌ | ✅ | | | AWS::RDS::DBSecurityGroupIngress | ❌ | ✅ | | | AWS::RDS::DBShardGroup | ✅ | ✅ | | | AWS::RDS::DBSubnetGroup | ✅ | ✅ | | | AWS::RDS::EventSubscription | ✅ | ✅ | | | AWS::RDS::GlobalCluster | ✅ | ✅ | | | AWS::RDS::Integration | ✅ | ✅ | | | AWS::RDS::OptionGroup | ✅ | ✅ | | ### Security, identity & audit | Type | Discoverable | Extractable | Comment | | ------------------------------------------- | ------------ | ----------- | ----------------------------------------------------------------------- | | AWS::CertificateManager::Certificate | ✅ | ❌ | | | AWS::CloudTrail::Trail | ✅ | ✅ | | | AWS::IAM::AccessKey | ❌ | ✅ | | | AWS::IAM::Group | ✅ | ✅ | | | AWS::IAM::GroupPolicy | ❌ | ✅ | | | AWS::IAM::InstanceProfile | ✅ | ✅ | | | AWS::IAM::ManagedPolicy | ✅ | ✅ | AWS-managed policies are excluded from discovery | | AWS::IAM::OIDCProvider | ✅ | ✅ | | | AWS::IAM::Policy | ❌ | ✅ | | | AWS::IAM::Role | ✅ | ✅ | | | AWS::IAM::RolePolicy | ✅ | ❌ | | | AWS::IAM::SAMLProvider | ✅ | ✅ | | | AWS::IAM::ServerCertificate | ✅ | ✅ | | | AWS::IAM::ServiceLinkedRole | ❌ | ✅ | | | AWS::IAM::User | ✅ | ❌ | | | AWS::IAM::UserPolicy | ❌ | ✅ | | | AWS::IAM::UserToGroupAddition | ❌ | ✅ | | | AWS::IAM::VirtualMFADevice | ✅ | ❌ | | | AWS::KMS::Alias | ✅ | ✅ | Reserved `alias/aws/` aliases are excluded from discovery | | AWS::KMS::Key | ❌ | ❌ | | | AWS::SecretsManager::ResourcePolicy | ✅ | ✅ | | | AWS::SecretsManager::RotationSchedule | ✅ | ✅ | | | AWS::SecretsManager::Secret | ✅ | ✅ | | | AWS::SecretsManager::SecretTargetAttachment | ❌ | ✅ | CloudControl cannot enumerate attachments, so they are not discoverable | ### Application integration | Type | Discoverable | Extractable | Comment | | ------------------------------------------ | ------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | AWS::ApiGateway::ApiKey | ✅ | ✅ | | | AWS::ApiGateway::Deployment | ✅ | ✅ | | | AWS::ApiGateway::Method | ✅ | ✅ | | | AWS::ApiGateway::Resource | ✅ | ✅ | | | AWS::ApiGateway::RestApi | ✅ | ✅ | | | AWS::ApiGateway::Stage | ✅ | ✅ | | | AWS::ApiGateway::UsagePlan | ✅ | ✅ | | | AWS::ApiGateway::UsagePlanKey | ✅ | ✅ | | | AWS::Events::Archive | ✅ | ✅ | | | AWS::Events::EventBus | ✅ | ✅ | | | AWS::Events::Rule | ✅ | ✅ | | | AWS::SES::ConfigurationSet | ✅ | ✅ | | | AWS::SES::ConfigurationSetEventDestination | ✅ | ✅ | Discovered as a child of `ConfigurationSet`. | | AWS::SES::EmailIdentity | ✅ | ✅ | Custom Read enriches the resource state with `requiredDnsRecords` (the DNS records SES expects), exposed as a typed listing resolvable for direct wiring into Route53 (or any DNS plugin). | | AWS::SES::EmailIdentityVerification | ❌ | ✅ | formae-internal polling gate; no underlying AWS resource. Sits in the dependency graph between an `EmailIdentity` (plus its DNS records) and any resource that needs to send mail; polls SES until verification reaches `SUCCESS` or hits the 30-minute timeout. | | AWS::SQS::Queue | ✅ | ✅ | | | AWS::SQS::QueueInlinePolicy | ❌ | ✅ | | | AWS::SQS::QueuePolicy | ❌ | ✅ | | ### Machine learning | Type | Discoverable | Extractable | Comment | | --------------------------------- | ------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | | AWS::SageMaker::Domain | ✅ | ✅ | This resource creates an EFS system - AutoHomeEFS - which will need to be manually removed prior to successfully destroying the domain. | | AWS::SageMaker::Endpoint | ❌ | ✅ | | | AWS::SageMaker::ModelPackageGroup | ❌ | ✅ | | | AWS::SageMaker::UserProfile | ✅ | ✅ | | ### Developer tools | Type | Discoverable | Extractable | Comment | | ------------------------------------ | ------------ | ----------- | ------- | | AWS::CodeBuild::ImageBuild | ❌ | ✅ | | | AWS::CodeBuild::Project | ❌ | ✅ | | | AWS::ECR::PublicRepository | ✅ | ✅ | | | AWS::ECR::PullThroughCacheRule | ✅ | ✅ | | | AWS::ECR::RegistryPolicy | ✅ | ✅ | | | AWS::ECR::ReplicationConfiguration | ✅ | ✅ | | | AWS::ECR::Repository | ✅ | ✅ | | | AWS::ECR::RepositoryCreationTemplate | ✅ | ✅ | | ### Management & observability | Type | Discoverable | Extractable | Comment | | ------------------- | ------------ | ----------- | ------- | | AWS::Logs::LogGroup | ✅ | ✅ | | # Azure configuration Source: https://docs.formae.io/documentation/reference/providers/azure/configuration Configure an Azure target for formae: credentials and target settings. The Azure plugin enables formae to manage Azure resources using the Azure Resource Manager APIs. ## Configuration ### Target Configure an Azure target in your Forma file: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/formae.pkl" import "@azure/azure.pkl" target: formae.Target = new formae.Target { label = "my-azure-target" config = new azure.Config { subscriptionId = "your-subscription-id" } } ``` The subscription ID can also be read from an environment variable: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} config = new azure.Config { subscriptionId = read?("env:AZURE_SUBSCRIPTION_ID") ?? "default-subscription-id" } ``` ### Credentials The plugin uses `DefaultAzureCredential` which tries the following methods in order: 1. **Environment Variables:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export AZURE_CLIENT_ID="your-client-id" export AZURE_CLIENT_SECRET="your-client-secret" export AZURE_TENANT_ID="your-tenant-id" ``` 2. **Managed Identity:** When running on Azure (VMs, App Service, Functions, etc.), credentials are automatically retrieved from the managed identity. 3. **Azure CLI:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} az login ``` **OIDC (for CI/CD):** For GitHub Actions, use `azure/login` action with OIDC federation. # Deploy a virtual machine Source: https://docs.formae.io/documentation/reference/providers/azure/patterns/deploy-a-virtual-machine An Azure infrastructure pattern, deployed with formae. An Ubuntu virtual machine reachable over SSH, with its own networking. One forma provisions the resource group, a virtual network and subnet, a network security group that allows SSH, a static public IP, a network interface, and the virtual machine itself. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["vm-target-azure"]:::tgt subgraph stack["azure-vm-eastus"] direction LR rg["Resource group"]:::res vnet["Virtual network"]:::res subnet["Subnet"]:::res nsg["Network security group"]:::res pip["Public IP"]:::res nic["Network interface"]:::res vm["Virtual machine"]:::res rg --> vnet rg --> subnet vnet --> subnet rg --> nsg rg --> pip rg --> nic subnet --> nic pip --> nic nsg --> nic rg --> vm nic --> vm end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Export your SSH public key first, then apply the example forma. formae orders the resources by their dependencies automatically, so the network interface wires up the subnet, public IP, and security group before the machine boots: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-azure/virtual-machine/main.pkl ``` **Verify.** Once the command completes, the machine is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AZURE::Compute::VirtualMachine" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-azure/virtual-machine/main.pkl ``` The full forma is in the [virtual-machine example](https://github.com/platform-engineering-labs/formae-plugin-azure/tree/main/examples/virtual-machine). # Deploy the bookstore on AKS Source: https://docs.formae.io/documentation/reference/providers/azure/patterns/deploy-the-bookstore-on-aks An Azure infrastructure pattern, deployed with formae. A full-stack bookstore webapp on a managed AKS cluster. This example provisions the cluster itself, it does not assume one already exists. One forma stands up the Azure infrastructure (resource group, virtual network, AKS subnet, the AKS managed cluster, and an Azure RBAC role assignment for the calling user), then deploys the workload onto it (a namespace, config and secret, a backend service account, an nginx frontend, and a Node.js backend with their services). It spans two targets: the Azure subscription that provisions the cluster, and the AKS cluster itself as a Kubernetes target that the app resources land on. The bookstore ships with the formae Kubernetes plugin examples, so resolve those as well with `pkl project resolve /opt/pel/formae/examples/formae-plugin-kubernetes`. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["azure-target-bookstore"]:::tgt subgraph stack["k8s-bookstore-azure"] direction LR rg["Resource group"]:::res vnet["Virtual network"]:::res subnet["AKS subnet"]:::res aks["AKS cluster"]:::res rbac["RBAC role assignment"]:::res k8s["k8s-target-azure-bookstore"]:::tgt ns["Namespace"]:::res feCfg["Frontend ConfigMap"]:::res beCfg["Backend ConfigMap"]:::res dbSec["DB credentials Secret"]:::res beSa["Backend ServiceAccount"]:::res feDep["Frontend Deployment"]:::res beDep["Backend Deployment"]:::res feSvc["Frontend Service"]:::res beSvc["Backend Service"]:::res rg --> vnet rg --> subnet vnet --> subnet rg --> aks aks --> rbac aks --> k8s rg --> k8s k8s --> ns ns --> feCfg ns --> beCfg ns --> dbSec ns --> beSa ns --> feDep ns --> beDep ns --> feSvc ns --> beSvc end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** The cloud is selected by the entry file you apply. Export `AZURE_SUBSCRIPTION_ID` and `AZURE_PRINCIPAL_ID` (your Azure AD object id) first, then apply the Azure entry. formae orders the resources by their dependencies automatically, so the cluster comes up before the workload lands on it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/azure.pkl ``` **Verify.** Once the command completes, the AKS cluster and the workload are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AZURE::ContainerService::ManagedCluster" ``` **Tear down.** Remove the workload and the cluster it runs on: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --on-dependents=cascade \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/azure.pkl ``` The full forma is in the [bookstore example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/bookstore). # Lay down base networking Source: https://docs.formae.io/documentation/reference/providers/azure/patterns/lay-down-base-networking An Azure infrastructure pattern, deployed with formae. The base networking layer that other Azure stacks build on. One forma provisions the resource group, a virtual network, and a single subnet. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["networking-target-azure"]:::tgt subgraph stack["azure-networking-eastus"] direction LR rg["Resource group"]:::res vnet["Virtual network"]:::res subnet["Subnet"]:::res rg --> vnet rg --> subnet vnet --> subnet end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the resource group comes up before the virtual network, and the network before the subnet: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-azure/networking/main.pkl ``` **Verify.** Once the command completes, the network is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AZURE::Network::VirtualNetwork" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-azure/networking/main.pkl ``` The full forma is in the [networking example](https://github.com/platform-engineering-labs/formae-plugin-azure/tree/main/examples/networking). # Provision an AKS cluster Source: https://docs.formae.io/documentation/reference/providers/azure/patterns/provision-an-aks-cluster An Azure infrastructure pattern, deployed with formae. A managed AKS cluster with a private container registry and its own networking. One forma provisions the resource group, a virtual network and AKS subnet, an Azure Container Registry, and the AKS managed cluster with a single system node pool. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["kubernetes-target-azure"]:::tgt subgraph stack["azure-kubernetes-eastus"] direction LR rg["Resource group"]:::res vnet["Virtual network"]:::res subnet["AKS subnet"]:::res acr["Container registry"]:::res aks["AKS cluster"]:::res rg --> vnet rg --> subnet vnet --> subnet rg --> acr rg --> aks end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the resource group comes up before the network, and the network before the cluster: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-azure/kubernetes/main.pkl ``` **Verify.** Once the command completes, the cluster is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AZURE::ContainerService::ManagedCluster" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-azure/kubernetes/main.pkl ``` The full forma is in the [kubernetes example](https://github.com/platform-engineering-labs/formae-plugin-azure/tree/main/examples/kubernetes). # Run a PostgreSQL database Source: https://docs.formae.io/documentation/reference/providers/azure/patterns/run-a-postgresql-database An Azure infrastructure pattern, deployed with formae. A managed PostgreSQL Flexible Server reachable from a chosen IP address and from Azure services. One forma provisions the resource group, the PostgreSQL Flexible Server, and two firewall rules that open access to your own IP address and to other Azure services. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["database-target-azure-2"]:::tgt subgraph stack["azure-database-westus"] direction LR rg["Resource group"]:::res pg["PostgreSQL server"]:::res myIp["AllowMyIP firewall rule"]:::res azSvc["Allow Azure services rule"]:::res rg --> pg rg --> myIp rg --> azSvc pg --> myIp pg --> azSvc end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the resource group comes up before the server, and the server before its firewall rules: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-azure/database/main.pkl ``` **Verify.** Once the command completes, the server is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:AZURE::DBforPostgreSQL::FlexibleServer" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-azure/database/main.pkl ``` The full forma is in the [database example](https://github.com/platform-engineering-labs/formae-plugin-azure/tree/main/examples/database). # Azure supported resources Source: https://docs.formae.io/documentation/reference/providers/azure/supported-resources The resource types the formae Azure plugin supports. ## Supported Resources | Type | Discoverable | Extractable | Comment | | --------------------------------------------------- | ------------ | ----------- | ---------------------------------------------- | | AZURE::Authorization::RoleAssignment | ✅ | ✅ | | | AZURE::Compute::Disk | ✅ | ✅ | | | AZURE::Compute::VirtualMachine | ✅ | ✅ | | | AZURE::Compute::VirtualMachineScaleSet | ✅ | ✅ | | | AZURE::ContainerRegistry::Registry | ✅ | ✅ | | | AZURE::ContainerService::MaintenanceConfiguration | ✅ | ✅ | | | AZURE::ContainerService::ManagedCluster | ✅ | ✅ | | | AZURE::ContainerService::TrustedAccessRoleBinding | ✅ | ✅ | | | AZURE::DBforPostgreSQL::Configuration | ✅ | ✅ | Server parameters (e.g. extensions) | | AZURE::DBforPostgreSQL::Database | ✅ | ✅ | | | AZURE::DBforPostgreSQL::FirewallRule | ✅ | ✅ | | | AZURE::DBforPostgreSQL::FlexibleServer | ✅ | ✅ | | | AZURE::KeyVault::Secret | ✅ | ✅ | Write-only value; opaque rotation and set-once | | AZURE::KeyVault::Vault | ✅ | ✅ | | | AZURE::KubernetesConfiguration::Extension | ✅ | ✅ | | | AZURE::KubernetesConfiguration::FluxConfiguration | ✅ | ✅ | | | AZURE::ManagedIdentity::FederatedIdentityCredential | ✅ | ✅ | Workload identity / OIDC | | AZURE::ManagedIdentity::UserAssignedIdentity | ✅ | ✅ | | | AZURE::Network::LoadBalancer | ✅ | ✅ | | | AZURE::Network::NetworkInterface | ✅ | ✅ | | | AZURE::Network::NetworkSecurityGroup | ✅ | ✅ | | | AZURE::Network::PrivateDnsZone | ✅ | ✅ | | | AZURE::Network::PrivateDnsZoneGroup | ✅ | ✅ | | | AZURE::Network::PrivateDnsZoneVirtualNetworkLink | ✅ | ✅ | | | AZURE::Network::PrivateEndpoint | ✅ | ✅ | | | AZURE::Network::PublicIPAddress | ✅ | ✅ | | | AZURE::Network::Subnet | ✅ | ✅ | | | AZURE::Network::VirtualNetwork | ✅ | ✅ | | | AZURE::Resources::ResourceGroup | ✅ | ✅ | | | AZURE::Sql::Database | ✅ | ✅ | | | AZURE::Sql::FirewallRule | ✅ | ✅ | | | AZURE::Sql::Server | ✅ | ✅ | | | AZURE::Sql::ServerAzureADAdministrator | ✅ | ✅ | Azure AD admin for a SQL server | | AZURE::Storage::BlobContainer | ✅ | ✅ | | | AZURE::Storage::StorageAccount | ✅ | ✅ | | # GCP configuration Source: https://docs.formae.io/documentation/reference/providers/gcp/configuration Configure a GCP target for formae: credentials and target settings. The GCP plugin lets formae manage Google Cloud Platform resources. ## Configuration ### Target Configure a GCP target in your Forma file: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/formae.pkl" import "@gcp/gcp.pkl" target: formae.Target = new formae.Target { label = "gcp-target" config = new gcp.Config { project = "your-project-id" region = "us-central1" // Optional: path to service account key file // credentialsFile = read("env:GCP_CREDENTIALS_FILE") } } ``` ### Credentials The plugin uses the standard GCP credential chain. Application Default Credentials (ADC): ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} gcloud auth application-default login ``` Service Account Key File: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export GCP_CREDENTIALS_FILE="/path/to/service-account-key.json" ``` Then reference it in your target config: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} config = new gcp.Config { project = "your-project-id" region = "us-central1" credentialsFile = read("env:GCP_CREDENTIALS_FILE") } ``` Workload Identity (on GKE): when running on GKE with Workload Identity enabled, credentials are provided automatically. OIDC (for CI/CD): for GitHub Actions, use `google-github-actions/auth` with Workload Identity Federation. | Environment Variable | Description | Required | | ---------------------- | ------------------------------------ | ------------------- | | `GCP_PROJECT_ID` | GCP project ID | Yes | | `GCP_PROJECT_NUMBER` | GCP project number | For WIF | | `GCP_REGION` | GCP region (e.g., `europe-central2`) | Yes | | `GCP_ZONE` | GCP zone (e.g., `europe-central2-b`) | For zonal resources | | `GCP_CREDENTIALS_FILE` | Path to service account JSON key | Local only | # Deploy an HTTP(S) load balancer Source: https://docs.formae.io/documentation/reference/providers/gcp/patterns/deploy-an-https-load-balancer A GCP infrastructure pattern, deployed with formae. A complete global external Application Load Balancer. One forma reserves a global static IP, defines a health check and a backend service, and wires a URL map, an HTTP target proxy, and a global forwarding rule so traffic entering on the static IP is routed through to the backend service. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["gcp-target"]:::tgt subgraph stack["gcp-loadbalancer-stack"] direction LR ip["Global IP address"]:::res hc["Health check"]:::res backend["Backend service"]:::res urlmap["URL map"]:::res proxy["Target HTTP proxy"]:::res fwd["Global forwarding rule"]:::res hc --> backend backend --> urlmap urlmap --> proxy proxy --> fwd ip --> fwd end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the health check comes up before the backend service, the URL map before the proxy, and the forwarding rule last: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-gcp/gcp-loadbalancer/gcp_loadbalancer.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:GCP::Compute::GlobalForwardingRule" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-gcp/gcp-loadbalancer/gcp_loadbalancer.pkl ``` The full forma is in the [gcp-loadbalancer example](https://github.com/platform-engineering-labs/formae-plugin-gcp/tree/main/examples/gcp-loadbalancer). # Deploy base VPC networking Source: https://docs.formae.io/documentation/reference/providers/gcp/patterns/deploy-base-vpc-networking A GCP infrastructure pattern, deployed with formae. The networking baseline you build the rest of your infrastructure on. One forma provisions a VPC network with subnetworks turned off, a public and a private subnet, ingress firewall rules for SSH and for HTTP/HTTPS, and a Cloud Router. Every resource attaches to the VPC. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["gcp-target"]:::tgt subgraph stack["gcp-stack"] direction LR vpc["VPC network"]:::res publicSubnet["Public subnet"]:::res privateSubnet["Private subnet"]:::res sshFw["Allow-SSH firewall"]:::res httpFw["Allow-HTTP/HTTPS firewall"]:::res router["Cloud Router"]:::res vpc --> publicSubnet vpc --> privateSubnet vpc --> sshFw vpc --> httpFw vpc --> router end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae brings the VPC up first, then the subnets, firewall rules, and router that depend on it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-gcp/gcp-lifeline/gcp_lifeline.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:GCP::Compute::Network" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-gcp/gcp-lifeline/gcp_lifeline.pkl ``` The full forma is in the [gcp-lifeline example](https://github.com/platform-engineering-labs/formae-plugin-gcp/tree/main/examples/gcp-lifeline). # Deploy the bookstore on GKE Source: https://docs.formae.io/documentation/reference/providers/gcp/patterns/deploy-the-bookstore-on-gke A GCP infrastructure pattern, deployed with formae. A full-stack workload that provisions its own managed cluster. This example ships with the formae Kubernetes plugin and selects GCP by applying its `gcp.pkl` entry file. One forma provisions the cluster side (a project IAM grant, a VPC, a subnet, a Cloud Router, a Cloud NAT, and a Standard zonal GKE cluster) and, through a Kubernetes target authenticated against that cluster's endpoint, the workload side (a namespace, config maps, a secret, a service account, an nginx frontend and Node.js backend deployment, and their services). It needs `GCP_PROJECT` and `GCP_APPLY_AS` set so the cluster and the IAM grant resolve. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR gcpTgt["gcp-target-bookstore"]:::tgt k8sTgt["k8s-target-gcp-bookstore"]:::tgt subgraph stack["k8s-bookstore-gcp"] direction LR iam["Project IAM member"]:::res vpc["VPC network"]:::res subnet["Subnet"]:::res router["Cloud Router"]:::res nat["Cloud NAT"]:::res gke["GKE cluster"]:::res ns["Namespace"]:::res feConfig["Frontend config map"]:::res beConfig["Backend config map"]:::res dbSecret["DB credentials secret"]:::res beSa["Backend service account"]:::res feDeploy["Frontend deployment"]:::res beDeploy["Backend deployment"]:::res feSvc["Frontend load balancer service"]:::res beSvc["Backend service"]:::res vpc --> subnet vpc --> router router --> nat vpc --> gke subnet --> gke ns --> feConfig ns --> beConfig ns --> dbSecret ns --> beSa ns --> feDeploy ns --> beDeploy ns --> feSvc ns --> beSvc iam --> gke end gcpTgt --> stack gke --> k8sTgt k8sTgt --> ns classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the GCP entry file. formae provisions the cloud infrastructure first, then authenticates the Kubernetes target against the new cluster's endpoint and rolls out the workload: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/gcp.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="stack:k8s-bookstore-gcp" ``` **Tear down.** Remove everything the forma created. The GKE cluster has dependents, so cascade the destroy: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --on-dependents=cascade \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/gcp.pkl ``` The full forma is in the [bookstore example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/bookstore). # GCP supported resources Source: https://docs.formae.io/documentation/reference/providers/gcp/supported-resources The resource types the formae GCP plugin supports. ## Supported Resources 49 resource types across nine GCP services. ### BigQuery | Type | Description | | ------------------------ | ---------------------------------------- | | `GCP::BigQuery::Dataset` | Top-level container for tables and views | | `GCP::BigQuery::Table` | Table within a dataset | ### Bigtable | Type | Description | | --------------------------------- | ----------------------------------------------------- | | `GCP::Bigtable::Backup` | Point-in-time backup of a table | | `GCP::Bigtable::Cluster` | Cluster within a Bigtable instance | | `GCP::Bigtable::Instance` | Bigtable instance (container for clusters and tables) | | `GCP::Bigtable::MaterializedView` | Materialized view over a source table | | `GCP::Bigtable::Table` | Table within a Bigtable instance | ### Cloud Run | Type | Description | | --------------------------- | --------------------------------------------- | | `GCP::CloudRun::Execution` | Single run of a Cloud Run Job | | `GCP::CloudRun::Job` | Batch job that runs to completion | | `GCP::CloudRun::Revision` | Immutable snapshot of a Service configuration | | `GCP::CloudRun::Service` | Long-running HTTP service | | `GCP::CloudRun::Task` | Individual task within a Job execution | | `GCP::CloudRun::WorkerPool` | Pool of long-running worker instances | ### Compute | Type | Description | | -------------------------------------- | ------------------------------------------- | | `GCP::Compute::Address` | Regional external or internal IP address | | `GCP::Compute::BackendService` | Regional backend service for load balancing | | `GCP::Compute::Disk` | Zonal persistent disk | | `GCP::Compute::Firewall` | VPC firewall rule | | `GCP::Compute::ForwardingRule` | Regional forwarding rule for load balancers | | `GCP::Compute::GlobalAddress` | Global external or internal IP address | | `GCP::Compute::GlobalForwardingRule` | Global forwarding rule | | `GCP::Compute::HealthCheck` | Global health check | | `GCP::Compute::Instance` | Compute Engine VM instance | | `GCP::Compute::Network` | VPC network | | `GCP::Compute::RegionBackendService` | Regional backend service | | `GCP::Compute::RegionHealthCheck` | Regional health check | | `GCP::Compute::RegionTargetHttpProxy` | Regional HTTP target proxy | | `GCP::Compute::RegionTargetHttpsProxy` | Regional HTTPS target proxy | | `GCP::Compute::RegionTargetTcpProxy` | Regional TCP target proxy | | `GCP::Compute::RegionUrlMap` | Regional URL map | | `GCP::Compute::Router` | Cloud Router for dynamic routing and NAT | | `GCP::Compute::RouterNat` | Cloud NAT configuration on a Cloud Router | | `GCP::Compute::Subnetwork` | Subnet within a VPC network | | `GCP::Compute::TargetHttpProxy` | Global HTTP target proxy | | `GCP::Compute::TargetHttpsProxy` | Global HTTPS target proxy | | `GCP::Compute::TargetPool` | Target pool for network load balancing | | `GCP::Compute::TargetSslProxy` | Global SSL target proxy | | `GCP::Compute::TargetTcpProxy` | Global TCP target proxy | | `GCP::Compute::UrlMap` | Global URL map | ### Container (GKE) | Type | Description | | -------------------------- | ------------------------------ | | `GCP::Container::Cluster` | GKE cluster | | `GCP::Container::NodePool` | Node pool within a GKE cluster | ### GKE Hub | Type | Description | | ------------------------- | ---------------------------------------------------------- | | `GCP::GKEHub::Feature` | Fleet-wide feature (e.g., Config Management, Service Mesh) | | `GCP::GKEHub::Membership` | Cluster membership in a fleet | ### IAM | Type | Description | | ---------------------------- | --------------------------------------- | | `GCP::IAM::ProjectIamMember` | Single member-role binding on a project | ### Cloud SQL | Type | Description | | ---------------------------- | ---------------------------------------------------------- | | `GCP::SQL::DatabaseInstance` | Managed Cloud SQL instance (MySQL, PostgreSQL, SQL Server) | ### Cloud Storage | Type | Description | | ------------------------------------------ | ---------------------------------------------- | | `GCP::Storage::AnywhereCache` | Anywhere Cache instance for a bucket | | `GCP::Storage::Bucket` | Cloud Storage bucket | | `GCP::Storage::BucketAccessControl` | ACL entry for a bucket | | `GCP::Storage::DefaultObjectAccessControl` | Default ACL applied to new objects in a bucket | | `GCP::Storage::ObjectAccessControl` | ACL entry for an individual object | # Kubernetes configuration Source: https://docs.formae.io/documentation/reference/providers/kubernetes/configuration Configure a Kubernetes target for formae: credentials and target settings. Deploying to a Kubernetes cluster? Point the K8s plugin at it. You get typed Pkl resources, schemas that match your cluster's exact version, and auth that works with EKS, AKS, GKE, OKE, or any kubeconfig. ## Configuration ### Target ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/formae.pkl" import "@k8s/k8s.pkl" as k8s target: formae.Target = new formae.Target { label = "k8s-local" config = new k8s.Config { kubernetesVersion = "1.31" auth = new k8s.KubeconfigAuth {} } } ``` Apply it against your current kubectl context with `formae apply --mode reconcile`. ### Authentication Pick an `auth` class on `k8s.Config` and the plugin handles token refresh, request signing, and per-provider quirks. You configure it once and the plugin keeps it valid for every API call the target makes. | Cluster | Auth class | How it stays valid | | ----------------------- | ---------------- | -------------------------------------------------------------- | | Local kubectl context | `KubeconfigAuth` | Reads your kubeconfig at apply time | | formae running as a pod | `InClusterAuth` | ServiceAccount token from the pod's mounted secret | | AWS EKS | `EKSAuth` | Presigned STS token, refreshed on every request | | Azure AKS | `AKSAuth` | Azure AD token, auto-refreshed | | GCP GKE | `GKEAuth` | OAuth2 access token, auto-refreshed | | Oracle OKE | `OCIAuth` | OCI signed request, signed on each call | | OVHcloud | `OVHAuth` | `endpoint`, `certificateAuthority`, `serviceName`, `clusterId` | **KubeconfigAuth (local development).** The default for kubectl-reachable clusters. With no fields set, the plugin uses whatever your current context points at. Override either field for something specific: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.KubeconfigAuth { context = "kind-formae-test" kubeconfig = "/path/to/kubeconfig" // defaults to $KUBECONFIG or ~/.kube/config } ``` **InClusterAuth (formae running as a pod).** When the formae agent runs inside the cluster it manages, the plugin reads the ServiceAccount token mounted at `/var/run/secrets/kubernetes.io/serviceaccount/`. The pod needs the RBAC to do whatever the forma describes. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.InClusterAuth {} ``` **EKSAuth (AWS EKS).** Reference the endpoint, CA, and cluster name from the EKS cluster resource, and formae waits until the cluster exists before reading them. The plugin uses your AWS credentials to fetch a presigned STS token and signs every API call. The AWS principal needs `eks:DescribeCluster`, and its mapped Kubernetes RBAC governs what it can do inside the cluster. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.EKSAuth { endpoint = eksCluster.res.endpoint certificateAuthority = eksCluster.res.certificateAuthorityData clusterName = eksCluster.res.name region = "us-west-2" // optional, defaults to AWS_REGION } ``` **AKSAuth (Azure AKS).** The AKS create API does not return the cluster CA directly, so the plugin makes a follow-up call with the cluster's admin credentials to fetch it. Referencing `certificateAuthority` runs that follow-up automatically before your workload deploys. Auth uses `DefaultAzureCredential` (environment variables, then the `az` CLI, then managed identity). ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.AKSAuth { endpoint = aksCluster.res.fqdn certificateAuthority = aksCluster.res.certificateAuthority resourceGroup = resourceGroup.res.name clusterName = aksCluster.res.name } ``` **GKEAuth (GCP GKE).** The plugin uses application-default credentials to mint an OAuth2 access token. The principal needs `container.developer` at minimum, or more granular RBAC bound at the cluster level. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.GKEAuth { endpoint = gkeCluster.res.endpoint certificateAuthority = gkeCluster.res.clusterCaCertificate } ``` **OCIAuth (Oracle OKE).** The plugin signs each API call with your OCI keys (api-key or session token, per `~/.oci/config`), so there is no long-lived bearer token. Your OCI user needs the `OKE_CLUSTER_USE` policy plus Kubernetes RBAC. OKE grants the cluster creator an admin token, so an apply that creates the cluster gets RBAC for free. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} auth = new k8s.OCIAuth { endpoint = okeCluster.res.endpoint certificateAuthority = okeCluster.res.certificateAuthority clusterOcid = okeCluster.res.id region = "us-chicago-1" // optional, defaults to ~/.oci/config } ``` **Which to use.** When formae runs outside the cluster, use `KubeconfigAuth` for local development, or the matching cloud-native class from CI or when you provisioned the cluster in the same forma (reference the cluster resource so there is no race). When formae runs inside the cluster, use `InClusterAuth`. ### Kubernetes version Set `kubernetesVersion` to your cluster's K8s version (e.g. `"1.31"`). The plugin matches it to the right schema, so fields that don't exist in that version of Kubernetes fail at `pkl eval` time instead of failing against your live cluster. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} config = new k8s.Config { kubernetesVersion = "1.31" // must match the @k8s/v/* imports below auth = new k8s.KubeconfigAuth {} } ``` Skip it and the plugin assumes `1.36` (the newest version it ships schemas for). Set it explicitly for anything older. Each supported minor ships its own schema package and runs its own conformance suite on every push to main. The current supported set is visible in the [conformance badges](https://github.com/platform-engineering-labs/formae-plugin-k8s#kubernetes-plugin-for-formae) on the plugin's README. ### Set the namespace on every namespaced resource Every namespaced resource must set `metadata.namespace` explicitly. The plugin won't fall back to K8s' `default` namespace. Missing values produce an error at apply time. Declare a `Namespace` in the same forma and reference its name so it lives in one place: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} local appNs = new namespace.Namespace { metadata = new namespace.NamespaceMetadata { name = "my-app" } } forma { appNs new deployment.Deployment { metadata = new k8s.NamespacedObjectMeta { name = "api" namespace = appNs.res.name // resolvable ref into the namespace above } spec { ... } } } ``` Namespaced kinds use `NamespacedObjectMeta`. Cluster-scoped kinds (Namespace, ClusterRole, ClusterRoleBinding, PersistentVolume, StorageClass, and so on) use `ObjectMeta`. Pod templates and PVC templates also use `ObjectMeta`. Mix them up and `pkl eval` tells you exactly where. # Deploy a Crossplane control plane Source: https://docs.formae.io/documentation/reference/providers/kubernetes/patterns/deploy-a-crossplane-control-plane A Kubernetes infrastructure pattern, deployed with formae. Crossplane core installed into its own namespace, with the RBAC it needs to manage cluster resources. One forma provisions the `crossplane-system` namespace, a ServiceAccount, a ClusterRole and ClusterRoleBinding, a leader-election Role and RoleBinding, the Crossplane Deployment, and its Service. Crossplane's own init container installs its CRDs at first start, so those are not managed by formae. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["k8s-target-local-crossplane"]:::tgt subgraph stack["k8s-crossplane-local"] direction LR ns["Namespace (crossplane-system)"]:::res sa["ServiceAccount"]:::res clusterRole["ClusterRole"]:::res clusterRoleBinding["ClusterRoleBinding"]:::res role["Role (leader election)"]:::res roleBinding["RoleBinding (leader election)"]:::res deploy["Deployment"]:::res svc["Service"]:::res ns --> sa ns --> role ns --> roleBinding ns --> deploy ns --> svc sa --> clusterRoleBinding clusterRole --> clusterRoleBinding sa --> roleBinding role --> roleBinding sa --> deploy deploy --> svc end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the namespace and ServiceAccount come up before the RBAC bindings and the Deployment that relies on them: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/crossplane/local.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:K8S::Apps::Deployment" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-kubernetes/crossplane/local.pkl ``` The full forma is in the [crossplane example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/crossplane). # Deploy a Helm chart Source: https://docs.formae.io/documentation/reference/providers/kubernetes/patterns/deploy-a-helm-chart Manage a Helm release as formae resources: reference a chart by name and version, set values inline, and apply it alongside your other Kubernetes resources. Use Helm charts inside a forma. Reference a chart by name and version, set values inline, and apply it alongside the rest of your Kubernetes resources, with no separate `helm install` step. formae expands the chart into individual managed resources, so a release reconciles, drifts, and tears down like everything else. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["k8s-local"]:::tgt subgraph stack["helm-nginx"] direction LR ns["Namespace"]:::res chart["HelmChart bitnami/nginx"]:::res deploy["Deployment"]:::res svc["Service"]:::res cfg["ConfigMaps / secrets"]:::res chart --> deploy chart --> svc chart --> cfg ns --> chart end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` ## Scope and limitations formae **renders** a chart (`helm template`, via `pkl-reader-helm`) into individual Kubernetes resources at evaluation time, then manages those resources like any other forma. It does **not** run `helm install`, so there is no Helm release: the chart's manifests become plain formae-managed objects, reconciled and drift-corrected by the agent. That model means several Helm features are **not supported**: * **Helm hooks are not honored.** Resources annotated with `helm.sh/hook` (`pre-install`, `post-install`, `pre-upgrade`, `post-upgrade`, `pre-delete`, `post-delete`) are rendered as ordinary resources and applied together with everything else. There is no hook ordering, no run-once-then-forget behavior, and no post-completion cleanup; `helm.sh/hook-weight` and `helm.sh/hook-delete-policy` are ignored. A chart that depends on a hook Job to initialize state (migrations, CRD installation, secret generation) may not behave as it does under `helm install`. * **Chart tests are not run.** A `helm.sh/hook: test` pod is treated as a normal resource (created and left in place), not executed as a test. There is no `helm test` equivalent. * **No Helm release lifecycle.** There is no release history, no `helm rollback`, no `helm list`, and no release-tracking Secret/ConfigMap. Rollback, waiting for readiness, and atomic apply are handled by formae's own reconcile and drift correction; the Helm flags `--wait`, `--timeout`, and `--atomic` have no analog. * **No Helm ownership metadata.** Resources are not stamped with `app.kubernetes.io/managed-by: Helm` or Helm release annotations; `releaseName` is used only as a formae label prefix. * **`helm.sh/resource-policy: keep` is ignored.** formae's reconcile owns deletion, so a resource the chart marks `keep` is still removed if a reconcile no longer declares it. * **CRDs shipped under a chart's `crds/` directory may not render.** `helm template` does not emit `crds/` by default, and there is no `--include-crds` toggle. CRDs a chart templates under `templates/` do render (mapped to a custom resource); CRDs in `crds/` should be applied separately. * **Rendering has no cluster access.** Because the chart is templated at evaluation time (not against your cluster), `lookup()` returns empty and `.Capabilities` reflect Helm's defaults rather than your live cluster's API versions. Charts whose output branches on live cluster state render as if the cluster were empty. (Version alignment is instead enforced by the [version coupling](#version-coupling) below.) * **The namespace is not created for you.** There is no `--create-namespace`; declare the target namespace as its own resource in the forma (as in [the forma](#the-forma) below). If your chart relies on any of the above, prefer breaking it into explicit formae resources, or run those pieces (hook Jobs, CRD installation) as a separate step. ## Prerequisites * `pkl` 0.30 or newer. * `pkl-reader-helm` on your `PATH`, from the [apple/pkl-readers releases](https://github.com/apple/pkl-readers/releases) (look for `helm@` tags). * `helm` 3 or newer, with the chart repositories you reference added: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} helm repo add bitnami https://charts.bitnami.com/bitnami helm repo update ``` * A `@formae-helm/v` import that lines up with your target's `kubernetesVersion` and the `@k8s/v` imports in the same forma. See [Version coupling](#version-coupling). ## The forma ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} amends "@formae/forma.pkl" import "@formae/formae.pkl" import "@k8s/k8s.pkl" as k8s import "@k8s/v1.31/core/Namespace.pkl" as ns import "@formae-helm/v1.31/HelmChart.pkl" local chart = new HelmChart { chart = "bitnami/nginx" version = "22.4.7" releaseName = "my-nginx" namespace = "demo" values = new Dynamic { replicaCount = 2 service { type = "ClusterIP" } } } forma { new formae.Stack { label = "helm-nginx" } new formae.Target { label = "k8s-local" namespace = "K8S" config = new k8s.Config { kubernetesVersion = "1.31" auth = new k8s.KubeconfigAuth {} } } new ns.Namespace { label = "demo-namespace" metadata = new ns.NamespaceMetadata { name = "demo" } } ...chart.resources } ``` **Deploy and tear down.** `...chart.resources` spreads the chart's rendered objects into the forma, so they apply and reconcile with everything else: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile --yes helm-nginx.pkl formae destroy --yes helm-nginx.pkl ``` ## HelmChart fields | Field | Type | Default | Purpose | | ----------------- | ---------- | ------------- | ----------------------------------------------------------------------- | | `chart` | `String` | required | Chart reference (`/` or OCI URL) | | `version` | `String` | required | Chart version | | `releaseName` | `String` | required | Helm release name; used as the label prefix | | `namespace` | `String` | `"default"` | Target namespace for namespaced resources | | `values` | `Dynamic?` | `null` | Values overrides, as `new Dynamic { ... }` | | `labelPrefix` | `String` | `releaseName` | Prefix on formae resource labels | | `skipUnsupported` | `Boolean` | `true` | Skip resource kinds the K8s minor does not ship; `false` throws instead | ## Version coupling Three things must agree, or `pkl eval` fails before any cluster call: * the `@formae-helm/v` import, * the `@k8s/v` imports, and * `Config.kubernetesVersion = ""`. To run the same chart against several Kubernetes minors, write one forma per minor or parameterize the file with Pkl `properties`. If a chart emits a kind your Kubernetes minor does not have (for example `FlowSchema` against a 1.28 cluster), the integration drops it by default. Set `skipUnsupported = false` to fail at eval time instead. This page is about using formae to manage **other** Helm charts. Installing **formae itself** through a Helm chart is a different task, covered in [Install the agent with Helm](/documentation/guides/install-agent-helm). ## Source The `HelmChart` type ships as the `formae-helm` Pkl package on the hub. See [formae-plugin-k8s/helm](https://github.com/platform-engineering-labs/formae-plugin-k8s/tree/main/helm). # Deploy the bookstore webapp Source: https://docs.formae.io/documentation/reference/providers/kubernetes/patterns/deploy-the-bookstore-webapp A Kubernetes infrastructure pattern, deployed with formae. A two-tier web application in a single namespace: an nginx frontend that serves a static page and proxies `/api` to a Node.js backend API. One forma provisions the namespace, the frontend and backend ConfigMaps, a database-credentials Secret, a backend ServiceAccount, the two Deployments, and the two Services (the frontend exposed as a LoadBalancer). ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["k8s-target-local-bookstore"]:::tgt subgraph stack["k8s-bookstore-local"] direction LR ns["Namespace"]:::res frontendCm["Frontend ConfigMap"]:::res backendCm["Backend ConfigMap"]:::res dbSecret["DB credentials Secret"]:::res backendSa["Backend ServiceAccount"]:::res frontendDep["Frontend Deployment (nginx)"]:::res backendDep["Backend Deployment (Node API)"]:::res frontendSvc["Frontend Service (LoadBalancer)"]:::res backendSvc["Backend Service"]:::res ns --> frontendCm ns --> backendCm ns --> dbSecret ns --> backendSa frontendCm --> frontendDep backendCm --> backendDep dbSecret --> backendDep backendSa --> backendDep frontendDep --> frontendSvc backendDep --> backendSvc backendSvc --> frontendDep end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so the namespace comes up before the workloads and the backend Service before the frontend that proxies to it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/local.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:K8S::Apps::Deployment" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-kubernetes/bookstore/local.pkl ``` The full forma is in the [bookstore example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/bookstore). # Deploy the LGTM observability stack Source: https://docs.formae.io/documentation/reference/providers/kubernetes/patterns/deploy-the-lgtm-observability-stack A Kubernetes infrastructure pattern, deployed with formae. A full observability stack in one namespace, wired across two plugins. The Kubernetes plugin deploys Loki, Tempo, and Mimir (each in Simple Scalable mode with write, read, and backend tiers), MinIO as their shared object storage, an OpenTelemetry Collector (gateway plus per-node agent), and Grafana behind a LoadBalancer. The grafana plugin then configures Grafana over its HTTP API, provisioning a dashboard folder, Loki, Tempo, and Mimir datasources, and the shipped dashboards. Target chaining wires the two together: the grafana target's URL is a reference on the Grafana Service's LoadBalancer ingress, so nothing is hardcoded. Optional telemetrygen workloads produce synthetic traffic when you pass `--enable-demo-traffic`. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["k8s-target-local-lgtm"]:::tgt subgraph stack["k8s-lgtm-observability-local"] direction LR ns["Namespace (observability)"]:::res minioSecret["MinIO credentials Secret"]:::res minio["MinIO (object storage)"]:::res loki["Loki (logs)"]:::res tempo["Tempo (traces)"]:::res mimir["Mimir (metrics)"]:::res otel["OpenTelemetry Collector"]:::res grafana["Grafana"]:::res demo["telemetrygen (demo traffic)"]:::res grafanaTarget["grafana-target-local"]:::tgt folder["Dashboard folder"]:::res datasources["Datasources (Loki, Tempo, Mimir)"]:::res dashboards["Dashboards (Agent, Plugins)"]:::res ns --> minioSecret ns --> minio ns --> loki ns --> tempo ns --> mimir ns --> otel ns --> grafana ns --> demo minioSecret --> minio minioSecret --> loki minioSecret --> tempo minioSecret --> mimir loki --> minio tempo --> minio mimir --> minio demo --> otel otel --> loki otel --> tempo otel --> mimir loki --> datasources tempo --> datasources mimir --> datasources grafana --> grafanaTarget grafanaTarget --> folder grafanaTarget --> datasources grafanaTarget --> dashboards folder --> dashboards end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae orders the resources by their dependencies automatically, so MinIO and its credentials come up before the backends that store to it, and the Grafana Service before the grafana target that derives its URL from it. Add `--enable-demo-traffic` to run synthetic telemetry through telemetrygen: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-kubernetes/lgtm-observability/local.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:K8S::Apps::Deployment" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-kubernetes/lgtm-observability/local.pkl ``` The full forma is in the [lgtm-observability example](https://github.com/platform-engineering-labs/formae-plugin-kubernetes/tree/main/examples/lgtm-observability). # Kubernetes supported resources Source: https://docs.formae.io/documentation/reference/providers/kubernetes/supported-resources The resource types the formae Kubernetes plugin supports. ## Helm charts Need a Helm chart deployed through formae? The `formae-helm` Pkl wrapper lets you reference a chart by name and version, set values inline, and apply it alongside the rest of your forma. ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae-helm/v1.31/HelmChart.pkl" local chart = new HelmChart { chart = "bitnami/nginx" version = "22.4.7" releaseName = "my-nginx" namespace = "demo" values = new Dynamic { replicaCount = 2 service { type = "ClusterIP" } } } forma { ...chart.resources } ``` See the supported Helm chart resources below. ## Supported resources | Type | Discoverable | Extractable | Comment | | ---------------------------------------------------------- | ------------ | ----------- | ---------------------------------------------------------------- | | K8S::Admissionregistration::MutatingAdmissionPolicy | ✅ | ✅ | K8s 1.36+ | | K8S::Admissionregistration::MutatingWebhookConfiguration | ✅ | ✅ | | | K8S::Admissionregistration::ValidatingWebhookConfiguration | ✅ | ✅ | | | K8S::Apiextensions::CustomResourceDefinition | ❌ | ❌ | CRDs are cluster infrastructure; not enumerated during discovery | | K8S::Apps::DaemonSet | ✅ | ✅ | | | K8S::Apps::Deployment | ✅ | ✅ | | | K8S::Apps::ReplicaSet | ✅ | ✅ | | | K8S::Apps::StatefulSet | ✅ | ✅ | | | K8S::Autoscaling::HorizontalPodAutoscaler | ✅ | ✅ | | | K8S::Batch::CronJob | ✅ | ✅ | | | K8S::Batch::Job | ✅ | ✅ | | | K8S::Coordination::Lease | ✅ | ✅ | | | K8S::Core::ConfigMap | ✅ | ✅ | | | K8S::Core::Endpoints | ✅ | ✅ | | | K8S::Core::LimitRange | ✅ | ✅ | | | K8S::Core::Namespace | ✅ | ✅ | | | K8S::Core::PersistentVolume | ✅ | ✅ | | | K8S::Core::PersistentVolumeClaim | ✅ | ✅ | | | K8S::Core::Pod | ✅ | ✅ | | | K8S::Core::ResourceQuota | ✅ | ✅ | | | K8S::Core::Secret | ✅ | ✅ | | | K8S::Core::Service | ✅ | ✅ | | | K8S::Core::ServiceAccount | ✅ | ✅ | | | K8S::Custom::Resource | ❌ | ❌ | Generic custom resource; one catch-all type spans every CRD kind | | K8S::Flowcontrol::FlowSchema | ✅ | ✅ | | | K8S::Flowcontrol::PriorityLevelConfiguration | ✅ | ✅ | | | K8S::Networking::Ingress | ✅ | ✅ | | | K8S::Networking::IngressClass | ✅ | ✅ | | | K8S::Networking::NetworkPolicy | ✅ | ✅ | | | K8S::Node::RuntimeClass | ❌ | ❌ | | | K8S::Policy::PodDisruptionBudget | ✅ | ✅ | | | K8S::Rbac::ClusterRole | ❌ | ❌ | | | K8S::Rbac::ClusterRoleBinding | ✅ | ✅ | | | K8S::Rbac::Role | ✅ | ✅ | | | K8S::Rbac::RoleBinding | ✅ | ✅ | | | K8S::Scheduling::PriorityClass | ✅ | ✅ | | | K8S::Storage::CSIDriver | ❌ | ❌ | | | K8S::Storage::StorageClass | ❌ | ❌ | | CRDs and arbitrary custom resources are supported via `K8S::Apiextensions::CustomResourceDefinition` (register the CRD) and the generic `K8S::Custom::Resource` (manage instances of any CRD kind). Neither is enumerated during discovery. The full per-kind schema lives in the [plugin repo](https://github.com/platform-engineering-labs/formae-plugin-k8s/tree/main/schema/pkl). ### Discovery filters `formae discover` skips a default set of system-installed resources so a fresh managed cluster doesn't drag control-plane noise into your inventory. Skipped by default: * System namespaces: `kube-system`, `kube-public`, `kube-node-lease` * Default ServiceAccounts and their tokens * Controller-owned Pods (ReplicaSet, DaemonSet, Job, etc.) * `system:*` ClusterRoles and ClusterRoleBindings * Bootstrap FlowSchemas * Cloud-provider default StorageClasses (`gp2`, `standard`, `local-path`) * Cloud-provider admission webhooks prefixed `eks-`, `gke-`, `aks-` Want to manage one of these resources instead of skipping it? You'll need to fork the plugin, remove the matching entry from `DiscoveryFilters()`, and rebuild. # OCI configuration Source: https://docs.formae.io/documentation/reference/providers/oci/configuration Configure an OCI target for formae: credentials and target settings. The OCI plugin enables formae to manage Oracle Cloud Infrastructure resources. ## Configuration ### Target Configure an OCI target in your Forma file: ```pkl theme={"languages":{"custom":["/languages/pkl.json"]}} import "@formae/formae.pkl" import "@oci/oci.pkl" target: formae.Target = new formae.Target { label = "oci-target" config = new oci.Config { region = "us-ashburn-1" profile = "DEFAULT" // Optional: profile from ~/.oci/config } } ``` **Config field mutability:** | Field | Mutable | Description | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------- | | `profile` | Yes | Changing the profile updates the target in place | | `configFilePath` | Yes | Changing the config file path updates the target in place | | `region` | No | Changing the region triggers a full [target replace](/documentation/concepts/target#replacing-a-target) | See [Target](/documentation/concepts/target) for details on per-field config mutability. ### Credentials The plugin uses the OCI SDK's default config provider, which tries the following methods in order: **Config file (`~/.oci/config`):** ```ini theme={"languages":{"custom":["/languages/pkl.json"]}} [DEFAULT] user=ocid1.user.oc1..aaaaaaaexample fingerprint=12:34:56:78:90:ab:cd:ef:12:34:56:78:90:ab:cd:ef tenancy=ocid1.tenancy.oc1..aaaaaaaexample region=us-ashburn-1 key_file=~/.oci/oci_api_key.pem ``` **Environment variables:** ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} export OCI_CLI_USER="ocid1.user.oc1..aaaaaaaexample" export OCI_CLI_TENANCY="ocid1.tenancy.oc1..aaaaaaaexample" export OCI_CLI_FINGERPRINT="12:34:56:78:90:ab:cd:ef:12:34:56:78:90:ab:cd:ef" export OCI_CLI_KEY_FILE="~/.oci/oci_api_key.pem" export OCI_CLI_REGION="us-ashburn-1" ``` **Instance principal (on OCI Compute):** When running on OCI compute instances, credentials are automatically retrieved from the instance metadata service. # Deploy an OKE Kubernetes cluster Source: https://docs.formae.io/documentation/reference/providers/oci/patterns/deploy-an-oke-kubernetes-cluster An OCI infrastructure pattern, deployed with formae. A managed OKE (Oracle Kubernetes Engine) cluster with its networking and a node pool. One forma provisions the VCN, an internet gateway, a NAT gateway, a service gateway, a route table, subnets, and a security list, then the OKE cluster on that network and a node pool inside the cluster. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["oci-target"]:::tgt subgraph stack["oke-example"] direction LR vcn["VCN"]:::res igw["Internet gateway"]:::res nat["NAT gateway"]:::res svcgw["Service gateway"]:::res rt["Route table"]:::res subnets["Subnets"]:::res seclist["Security list"]:::res cluster["OKE cluster"]:::res nodepool["Node pool"]:::res vcn --> igw vcn --> nat vcn --> svcgw vcn --> rt vcn --> subnets vcn --> seclist subnets --> cluster vcn --> cluster cluster --> nodepool end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae provisions the network first, then the cluster on it, then the node pool inside the cluster: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-oci/oke/main.pkl ``` **Verify.** Once the command completes, the cluster is under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:OCI::ContainerEngine::Cluster" ``` **Tear down.** The cluster has dependents, so cascade the destroy: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy --on-dependents=cascade \ /opt/pel/formae/examples/formae-plugin-oci/oke/main.pkl ``` The full forma is in the [oke example](https://github.com/platform-engineering-labs/formae-plugin-oci/tree/main/examples/oke). # Deploy base VCN networking Source: https://docs.formae.io/documentation/reference/providers/oci/patterns/deploy-base-vcn-networking An OCI infrastructure pattern, deployed with formae. The networking baseline you build the rest of your infrastructure on. One forma provisions a VCN with public and private subnets, an internet gateway, a route table, and network security groups. Everything attaches to the VCN. ```mermaid theme={"languages":{"custom":["/languages/pkl.json"]}} flowchart LR target["oci-target"]:::tgt subgraph stack["oci-lifeline"] direction LR vcn["VCN"]:::res igw["Internet gateway"]:::res rt["Route table"]:::res subnets["Subnets"]:::res nsg["Network security groups"]:::res vcn --> igw vcn --> rt vcn --> subnets vcn --> nsg end target --> stack classDef tgt fill:#FF8201,stroke:#B25900,color:#ffffff classDef res fill:#FFF3E6,stroke:#FF8201,color:#02024B style stack fill:#ffffff,stroke:#02024B,stroke-width:2px,stroke-dasharray:6 4,color:#02024B ``` **Deploy.** Apply the example forma. formae brings the VCN up first, then the gateways, route table, subnets, and security groups that depend on it: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae apply --mode reconcile \ /opt/pel/formae/examples/formae-plugin-oci/lifeline/basic_infrastructure.pkl ``` **Verify.** Once the command completes, the stack's resources are under management: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae inventory resources --query="type:OCI::Core::Vcn" ``` **Tear down.** Remove everything the forma created: ```bash theme={"languages":{"custom":["/languages/pkl.json"]}} formae destroy \ /opt/pel/formae/examples/formae-plugin-oci/lifeline/basic_infrastructure.pkl ``` The full forma is in the [lifeline example](https://github.com/platform-engineering-labs/formae-plugin-oci/tree/main/examples/lifeline). # OCI supported resources Source: https://docs.formae.io/documentation/reference/providers/oci/supported-resources The resource types the formae OCI plugin supports. ## Supported resources | Type | Discoverable | Extractable | Comment | | ------------------------------------------- | ------------ | ----------- | ------- | | OCI::ContainerEngine::Cluster | ✅ | ✅ | | | OCI::ContainerEngine::NodePool | ✅ | ✅ | | | OCI::ContainerEngine::VirtualNodePool | ✅ | ✅ | | | OCI::Core::DhcpOptions | ✅ | ✅ | | | OCI::Core::Instance | ✅ | ✅ | | | OCI::Core::InternetGateway | ✅ | ✅ | | | OCI::Core::NatGateway | ✅ | ✅ | | | OCI::Core::NetworkSecurityGroup | ✅ | ✅ | | | OCI::Core::NetworkSecurityGroupSecurityRule | ✅ | ✅ | | | OCI::Core::RouteTable | ✅ | ✅ | | | OCI::Core::SecurityList | ✅ | ✅ | | | OCI::Core::ServiceGateway | ✅ | ✅ | | | OCI::Core::Subnet | ✅ | ✅ | | | OCI::Core::VCN | ✅ | ✅ | | | OCI::Core::Volume | ✅ | ✅ | | | OCI::Identity::Compartment | ✅ | ✅ | | | OCI::Identity::Policy | ✅ | ✅ | | | OCI::ObjectStorage::Bucket | ✅ | ✅ | | # Release notes Source: https://docs.formae.io/documentation/reference/release-notes What changed in each formae release: new features, breaking changes, and bug fixes. ## 0.89.0 ### Breaking changes * **Reconcile now manages a stack's inline policies exactly.** Applying a forma in reconcile mode removes any inline policy that the declared stack no longer carries, the same way reconcile already treats resources and standalone policy attachments. Re-applying the stack without a policy is now the supported way to remove it. If you added inline policies out of band (for example through the MCP server) to a stack that a forma declares, the next reconcile of that stack deletes them; declare them in the forma to keep them. Patch mode is unaffected and never touches policies you don't mention. ### New features and improvements * **TTL policies can carry an absolute deadline.** A TTL policy now takes exactly one of `ttl` (a duration, as before) or `expiresAt` (an RFC 3339 timestamp in UTC, for example `"2026-09-01T00:00:00Z"`). Use `expiresAt` when a stack must be destroyed at a known instant, such as the end of a trial or a scheduled teardown window; re-applying the stack never moves an absolute deadline. Declaring both or neither fails validation when the forma is evaluated. See the [TTL policy](/documentation/concepts/policies/ttl) page for guidance on choosing between the two forms. * **First-class secret management.** A secret is now an ordinary managed resource whose value you can reference anywhere. Write `secret.res.secretValue` to use it, `secret.res.secretValue.at("key")` to pull one entry out of a map-shaped secret such as a Kubernetes Secret's `data`, and `.json("path")` to reach into a JSON payload. The value is read live from the provider at every plugin call, so onboarding a new credential or rotating an existing one takes effect without restarting the agent. A resource or target that references a secret stores only the reference; the secret's own value is hashed at rest and never written in cleartext. References work both in a resource's properties (for example a database password) and in a target's configuration (for example an API token), and resolve during discovery and destroy as well as apply. * **Destroying a resource that others depend on now stops and reports by default.** When a resource you are destroying still has dependents, formae halts and tells you what depends on it instead of quietly removing those dependents along with it. Pass the cascade option when you mean to tear down the whole dependency chain. * **The AWS plugin discovers far more of your existing infrastructure.** With the AWS plugin ≥ 0.1.17, fifteen more resource types appear in discovery and can be brought under management, including IAM users and customer managed policies, KMS aliases, Lambda permissions, load balancer listener rules, API Gateway resources and methods, CloudFront distributions, EC2 routes, and EC2 prefix lists. Discovery keeps AWS-managed inventory out of your view: AWS-owned managed policies, reserved `alias/aws/` aliases, AWS-owned prefix lists, and the implicit local route in every route table are excluded, so what you see is what you can actually manage. The [supported resources](/documentation/reference/providers/aws/supported-resources) page reflects the current discovery status of every type. * **The `omarchy` theme follows Omarchy 4 (Quattro) as well as Omarchy 3.** Omarchy 4 keeps the active theme in a new location, replaces the numbered terminal colors in its `colors.toml` with named ones, and declares light mode inside that file rather than with a marker file beside it. formae reads either generation, so `cli.theme = "omarchy"` keeps following your desktop theme across the upgrade, switching themes still recolors a running `formae inventory` or `status` watch in place, and a light theme is still detected when `cli.appearance` is `auto`. * **A gauge of resources currently failing.** The agent now exposes a `formae_resource_errors` metric counting resources whose most recent completed operation failed, grouped by resource type. It returns to zero as resources recover, so an alert on it reflects the current state of your environment rather than accumulated history. * **Empty values inside document-style properties now reach the provider exactly as written.** Some properties are whole documents the provider owns the meaning of, where an empty object or list is itself the declaration. The clearest example is a Kubernetes custom resource spec: cert-manager selects an issuer type by which key is present, so `selfSigned: {}` is a complete, valid configuration. Previously formae cleaned empty objects and lists out of every property before writing, which turned such a spec into an empty document and made the provider reject or misread it. Plugins can now mark a property as carrying meaningful empty values, and formae preserves that property byte for byte through planning, comparison, and the write itself. The first plugin to use this is the Kubernetes plugin for custom resource specs; nothing changes for properties that are not marked. * **Out-of-band changes to cloud-defaulted properties are now treated as drift.** Many properties are filled in by the cloud when your forma doesn't declare them, such as a bucket's default encryption or a key's rotation setting, and omitting them can be a deliberate choice to rely on that default. Previously a change someone made to such a property outside formae was invisible to apply: a simulate of the unchanged forma said "no changes", and a reconcile quietly dropped the drift record without the change ever being shown. Now the default formae observed when it created or last updated the resource is defended like a declared value: a reconcile is rejected showing the change, exactly as for drift on a declared property, and a forced reconcile reverts it. To accept the new value instead, declare the property in your forma. Values the cloud or another system fills in on its own, at creation or later, such as targets a load balancer registers at runtime, are not treated as drift and remain visible in `formae drift` only. ### Bug fixes * FIXED: Adding a new property to an already-deployed resource was silently skipped when its value comes from a reference that can only be resolved while the command runs, such as another resource's secret value or an output of a resource created in the same apply. The apply reported success, the property was never written, and re-applying made no difference. formae now plans the update and writes the resolved value during execution. Properties that the provider only accepts at creation are deliberately left out: a first-time declaration there would force a destroy and recreate, and formae never replaces a resource over a value it has not resolved yet. * FIXED: A property fed by a reference to another resource could differ forever from the value the cloud provider reports back. Providers often accept one form of an identifier and return another: you pass a key's ARN, the provider echoes the bare key ID, or you pass a secret's name and it echoes the full ARN. formae compared a freshly resolved reference against the echoed form, and the two could never match. On a property you can update in place, that planned a no-op update on every reconcile. On a property the provider only accepts at creation, it silently planned a destroy and recreate for any ordinary edit: changing a Lambda function URL's CORS settings replaced the URL, and editing a Secrets Manager resource policy replaced the policy, leaving a window with no policy attached. formae now records the value it sent when it last wrote the resource and compares against that, so an unchanged reference produces no diff while a reference that genuinely points somewhere new still updates. The first reconcile after upgrading plans the same corrective change the previous version planned on every reconcile, including the destroy and recreate on properties that only accept a value at creation. That single pass records the baseline, after which the resource stops churning. Review a simulate before applying if any of your stacks are affected. * FIXED: Resources could be reported as failed while they were still being created successfully. When a cloud provider rate-limited a request, the plugin retried internally, and formae's check for an unresponsive plugin gave up before those retries could finish. The resource was marked failed and the failure cascaded to everything depending on it, which was most visible as intermittent, unexplained failures when applying many resources at once. That check now takes its patience from the retry settings the plugin is actually running with, so a plugin still working inside its retry budget is no longer mistaken for a stalled one. A genuinely stuck plugin is still detected, after about 100 seconds on default settings rather than 40. * FIXED: Map keys containing dots were split apart in a resource's stored properties. A label such as `app.kubernetes.io/name` was recorded both under its correct key and as a phantom nested `app` object, which showed up as an unexpected property when formae compared state after a create and produced malformed output from `formae extract` for that resource. Cloud state was never affected: the split existed only in what formae stored, and the first reconcile of an affected resource rewrites it correctly with no action from you. Kubernetes workloads were the most exposed, since nearly every one labels its pod template with `app.kubernetes.io/*` keys. * FIXED: Editing a stack's description silently extended its TTL. The countdown is documented to run from the stack's creation, but the expiry check read the timestamp of the stack's latest version, which a description edit refreshes. The countdown now always measures from when the stack was created. A stack whose original deadline has already passed, and that stayed alive only because of this bug, is destroyed on the first expiry check after upgrading. If you rely on such stacks, remove or extend their TTL policies before upgrading. The agent log records the creation time and computed deadline for every stack it expires, so each destroy is traceable. * FIXED: A stack's creation time, shown in the API stack listing and used for the inventory view's TTL expiry display, reported the time of the stack's last description edit instead of when the stack was created. * FIXED: A change to a value that other resources follow through a chain of references converged one hop per apply: each apply reported success while resources further down the chain kept old values. The whole chain now updates in a single apply. As part of this, a set of resources whose references form a cycle is rejected when the plan is generated, with an error naming the cycle; previous versions accepted such a cycle and silently resolved it to stale values. If an apply starts failing with a reference-cycle error, break the cycle by declaring one of the values as a literal. * FIXED: A resource referencing another resource's property kept the old value when both changed in the same apply. The plan showed only the source change and apply reported success; the referencing resource caught up only on the next apply. References now resolve against the value the source will hold after the command, so both update together. * FIXED: In reconcile mode, removing part of a resource's declaration (for example a tag) could appear in the preview but be silently skipped during execution when the same resource also references another resource. The executed change now matches the plan you were shown. * FIXED: When a reference resolved during execution to a value that changes a property the provider only accepts at creation, the change was silently ignored. formae now fails that update with an error naming the affected fields instead of proceeding as if nothing changed, and it never performs a replacement the plan did not declare. * FIXED: Changing a property that the provider neither returns on read nor allows updating in place (declared both write-only and create-only in the plugin schema, for example a cluster's access configuration) was silently ignored: no update, no replacement, and apply reported success while the cloud kept the old value. Such a change now plans the replacement it requires. On a resource brought in through discovery or import, where formae has never written the property, the declared value continues to be left alone. * FIXED: After formae absorbed an out-of-band deletion, references from other resources to the deleted resource still resolved to it. Updating such a resource failed with an internal error, and creating one was accepted only to fail during execution. Both now fail upfront with a clear error naming the missing referenced resource. * FIXED: A cascading destroy skipped the check for conflicting in-flight commands, so it could delete a resource that a concurrently running command was still creating. Cascading destroys now go through the same conflict check as every other command. * FIXED: Discovery could give two unmanaged resources the same label. The duplicate then stalled background synchronization: every subsequent sync command stayed in progress indefinitely until the agent was restarted. Discovered labels are now guaranteed unique, and a sync plans at most one update per resource. * FIXED: Replacing a resource while a background sync cycle was in flight could make formae lose track of it: a read planned before the replacement reported the old identity as gone, the record was removed, inventory no longer showed the resource, and the next simulate planned to create something that was already running. Reads made stale by a concurrent write are now detected and ignored. * FIXED: A secret declared as an opaque literal could appear in cleartext in the change preview when the resource holding it was updated as a consequence of a deleted or replaced dependency. That synthesized change is now redacted everywhere it appears: simulate output, the CLI, the stored changeset, and logs. * FIXED: Everything a plugin logged was recorded by the agent at error level regardless of the level the plugin chose, so routine warnings (for example a rejected credential) fired error-log alerts. Plugin log lines now keep the level the plugin assigned, including plugins that log in logfmt; a line naming no level on the error stream still defaults to error. * FIXED: Several `formae extract` and schema bugs produced wrong or unevaluable forma files. A field whose type is a union could extract as the wrong member, silently turning numbers into strings (or the reverse) and freezing a live cross-resource reference into a literal copy of its envelope; properties inherited through an intermediate schema base class were dropped from the output; a resource whose class extends an intermediate base was rejected or rendered into a huge unevaluable file; resolvables built on the specialised bases crashed serialization; and a plugin service directory whose name starts with "v" was mistaken for a schema version, breaking extraction for that plugin entirely. Extraction now renders these shapes correctly and fails loudly when it cannot render a resource faithfully, rather than writing incorrect code. * FIXED: TTL deadlines in the stacks view were shown in local time with nothing marking the timezone, while the stored deadline is a UTC instant. Deadlines are now rendered in UTC with a trailing Z, so the string reads the same for every operator and can be passed back to `--expires-at` without silently shifting the deadline by a UTC offset. * FIXED: The selected row in the interactive views could be nearly unreadable (light text on a light highlight) in the tokyo-night, gruvbox, and catppuccin-latte themes. The highlight is now derived from the theme background, so the selection stays legible with every theme and terminal palette. * FIXED: `formae profile show` failed with "not initialized" on a machine where formae had never run, and refused a configuration file from before profiles existed instead of migrating it. With no profile name it now resolves configuration the same way every other command does; naming a profile remains a pure read. * FIXED: Declaring an explicitly empty collection on a keyed property the cloud pre-populates was silently ignored. Writing `tags = new Listing {}` on a resource with live tags produced "no changes" in reconcile mode: the declared clear never ran, and entries added outside formae stayed invisible behind the empty declaration. An explicit empty declaration now means what it says: reconcile plans a removal for every live entry, and the simulate shows those removals before anything executes. A declaration like this that was previously inert now acts, so review the plan on stacks that carry one. Omitting the property entirely still leaves the cloud's entries alone, and patch mode never removes anything. ## 0.88.1 ### New features and improvements * **The inventory resources list loads quickly in large environments.** Opening the resources tab in `formae inventory` no longer stalls for tens of seconds when a target holds tens of thousands of resources. The list now loads a lightweight summary of each resource (label, stack, type, and native ID) and fetches a resource's full properties only when you open its row, so the first rows appear almost immediately no matter how large the environment is. * **Destroy and simulate generate plans faster.** Planning a destroy (including a simulate) used to slow down in proportion to the total number of resources in your datastore, because finding cross-stack dependents scanned the entire resources table. That lookup is now served by an index, so plan time scales with the resources actually involved rather than the size of the whole environment. On Postgres and Aurora, the first startup after upgrading runs a one-time, automatic backfill to populate the new index; it is idempotent and needs no action on your part. SQLite and MSSQL are unaffected. * **Apply and destroy show every change at once.** The simulation preview and the live progress view for `formae apply` and `formae destroy` used to show only the first several rows of each section (resources, targets, stacks, and policies) and hid the rest behind a "show more" prompt. The full list is now shown; scroll with the arrow keys to move through large plans. ### Bug fixes * FIXED: Stray characters such as `Gi=0,p=0;OK` could appear in your shell after a formae command in some terminals. The startup banner no longer leaves anything behind in the shell. * FIXED: Canceling a command, or an agent restart at the wrong moment, could record a resource as created when it had not actually been provisioned, so your inventory showed a resource that did not exist in the cloud. formae now handles these timing situations without losing in-flight resource updates, so the recorded state matches what was actually applied. * FIXED: Progress bars in the status view could render at different widths from one row to the next, and on light or low-contrast themes the unfilled part of a bar could be invisible, making the bar look shorter than it was. Every progress bar now renders at the same width with its fill shown inside, and the pending portion is always visible. * FIXED: `formae status --max-results` ignored any value above 10. A requested limit greater than the default was silently reduced to 10; the value you pass is now honored across all storage backends. ## 0.88.0 **New docs.** The formae documentation has a new home and a refreshed structure. Two kinds of release notes now live outside these docs: the MCP server's are in its [CHANGELOG](https://github.com/platform-engineering-labs/formae-mcp/blob/main/CHANGELOG.md), and each plugin's are on its hub page under the **changelog** tab (for example, the [AWS plugin](https://hub.platform.engineering/platform.engineering/aws)). ### Breaking changes * **Update your `PklProject` to the 0.88.0 formae schema.** A `PklProject` pinned to an older formae schema version no longer evaluates against this release, because the schema the binary emits has changed. Bump the formae dependency in your `PklProject` to 0.88.0; `formae extract` detects a stale pin and walks you through the upgrade. The recommended way to author a forma is now `extends "@formae/forma.pkl"` with a typed `Props` class (see New features below); existing `amends`-based formae keep working once the project is on the 0.88.0 schema. ### New features and improvements * **The formae CLI got a facelift.** The interactive views (status, inventory, drift, and the simulate/apply preview) have been redesigned for a cleaner, more consistent look, with a unified help overlay. The apply and destroy preview now ends in a prominent confirmation bar that summarizes the operation and asks you to confirm or abort before anything runs; a cascade destroy shows how many resources it will affect. Machine-readable output is unchanged. * **Theme the formae CLI.** The CLI is now themeable and ships with three built-in themes: `quiet` (the default), `rich`, and `colorblind`. Select one with `cli.theme` in your profile, or author your own as a TOML file in `~/.config/formae/themes/` and select it by name; see the [CLI themes reference](/documentation/reference/themes) for the full palette, glyph, and behavior options. Every theme carries both a light and a dark color set, and `cli.appearance` picks which one to use, independently of the theme: `auto` (the default) detects the terminal background, while `light` and `dark` force a variant for terminals where detection is unreliable, such as under `tmux` or over SSH (the `FORMAE_APPEARANCE` environment variable overrides it for a single command). Setting `cli.theme = "omarchy"` instead derives the colors from your active Omarchy desktop theme (read from `~/.config/omarchy/current/theme/colors.toml`) and follows its light or dark mode automatically; long-running views such as `formae inventory` and the `status` watch recolor live when you switch your desktop theme, and formae falls back to the default quiet theme if none is present. * **Read-only plugin and update commands no longer ask for sudo, and a new `formae refresh` warms the package cache.** `formae plugin list`, `plugin search`, `plugin info`, and `update list` now read the locally cached package index directly, so they run without sudo, and `update list` shows the newest available version even on a cold cache. `formae refresh` updates that cache for every configured repository across the stable and dev channels. * **Declare a forma's inputs as a typed class.** A forma file can now use `extends "@formae/forma.pkl"` and define its inputs in a `Props` class, so property access is statically typed and Pkl type constraints validate CLI input before apply. A `@formae.Flag { name = "..." }` annotation maps a member such as `certArn` to a differently-cased flag such as `--cert-arn`. Existing `amends`-based formae keep working unchanged; `extends` is the recommended style going forward. * **`formae extract` checks your project's schema version.** When you extract into a directory whose `PklProject` pins an older formae schema than the running binary, extract detects the mismatch and tells you how to upgrade (prompting on a TTY, or applying with `--yes`) instead of failing later with an opaque evaluation error. It also adds any missing plugin dependencies your extracted resources need. * **formae cleans up inventory for targets that have been unreachable for a long time.** If a target stays continuously unreachable for longer than a configurable period (24 hours by default), formae removes its discovered resources from inventory. It never deletes anything in the cloud, never affects resources under formae management, and never acts on a target with intermittent connectivity; re-applying the target restores its inventory. You can change the period or turn it off per target with the target's `reap` field; see [Unreachable targets](/documentation/concepts/target#unreachable-targets). ### Bug fixes * FIXED: In certain cases, secret values could be stored in plaintext even though formae treated them as redacted. Opaque secret properties (such as a Secrets Manager secret string or an RDS master password) are now stored as a one-way hash everywhere formae keeps state (apply, background sync, discovery, drift comparison, and extract), and are never written to logs. On the first startup after upgrading, a one-time backfill scrubs any secrets already stored in plaintext, including older resource versions, so existing data is redacted with no action on your part. A safeguard also stops a hashed value from being sent back to a provider in place of the real secret. * FIXED: `random.id` could crash when generating a long numeric ID. Asking for an ID of 20 or more digits aborted the command, and asking for 19 returned a value that was not uniformly distributed. `random.id` now returns a uniform value for lengths 1 through 18, and a clear error above 18 (a numeric ID is a 64-bit integer, which holds at most 18 digits). To generate longer values such as passwords, use `random.password`. * FIXED: Discovery filters did not clean up resources that were already in inventory. A discovery filter (`agent.resourcePlugins[].discoveryFilters`) prevents matching resources from being added to inventory, but it previously applied only to newly-scanned resources: anything discovered before the filter was added, or before it began matching the resource's tags, stayed in `formae inventory` indefinitely. Background sync now re-checks existing unmanaged inventory entries against the active filters and removes the ones that match, so adding a filter also clears out the entries it would have excluded. Only unmanaged, discovered entries are affected: resources under formae management are never removed even when they match a filter, and the underlying cloud resources are left untouched. * FIXED: Startup warned about duplicate config directories on Linux. When `XDG_CONFIG_HOME` was set to `$HOME/.config` (a common Linux default), formae printed a spurious warning on startup claiming two config directories both contained profiles, naming the same path twice and suggesting you set `FORMAE_CONFIG_DIR` to disambiguate. The legacy `$HOME/.config/formae` location and the `$XDG_CONFIG_HOME/formae` location were in fact the same directory, so there was never a real conflict. The warning no longer appears in this case, and the config directory resolves exactly as before. macOS and WSL were unaffected, since they typically leave `XDG_CONFIG_HOME` unset. * FIXED: The bundled plugin examples were missing from an install. The examples that ship with the bundled plugins are present again under the install's `examples/` directory, and are readable without sudo. * FIXED: An auto-reconcile policy showed a change on every apply that never settled, and an inline auto-reconcile policy was saved without its name. Auto-reconcile policies now compare and label correctly. * FIXED: On agents backed by an Aurora database, saving an updated resource refreshed only part of its stored record, leaving some details stale. The full record is now saved, matching the SQLite and Postgres backends. * FIXED: `formae extract` gave the generated import stack a description that no longer matched once its resources were adopted. It now reads "Resources imported with formae extract." ## 0.87.1 ### Bug fixes * FIXED: Running `formae extract` on certain resources failed with a Pkl type-constraint error when the resource's schema included a nested type with a rule relating two optional fields (for example, "exactly one of these two fields may be set"), even when the actual values were valid. These resources now extract correctly. ## 0.87.0 ### New features and improvements * **Manage connection profiles with `formae profile`.** formae now manages named connection profiles (each a complete config for one environment: agent endpoint, targets, credentials) directly from the CLI, instead of hand-swapping config files. Use `formae profile list`, `current`, `use `, `save`, `create`, `edit`, `delete`, and `diff` to manage them. The active profile applies to every command; to target a different environment for a single invocation without changing the active selection, pass `--profile ` (mutually exclusive with `--config`) on any command that connects to the agent. Existing setups migrate automatically the first time you run the CLI, and a fresh install starts with a ready-to-use local profile. * **Embed references to other resources inside text with `formae.embed`.** A [resolvable](/documentation/concepts/resolvable) reference to another resource's property could previously only be a field's *entire* value. You can now splice one (or several) into the middle of a text field with `formae.embed("…\(other.res.someProperty)…")`: formae resolves each reference at apply time and substitutes the real value into the surrounding text. This lets a value that only exists after another resource is created be used inside a larger literal in a single apply. For example, a CloudFront Function whose JavaScript needs a Key Value Store's generated Id, `functionCode = formae.embed("const kvsId = '\(kvStore.res.id)'; …")`, which previously meant applying the store, copying its Id by hand, then applying the function. The embedded reference round-trips through `formae extract` (it comes back as `formae.embed(...)`, not a frozen value) and is preserved across background synchronization. * **Force-cancel stuck commands with `formae cancel --force`.** A plain `formae cancel` stops a command gracefully, waiting for any in-progress resource updates to finish before the command reaches `Canceled`, so it can wait indefinitely on an operation that never completes (for example a plugin stuck in a poll loop). The new `--force` flag is an escape hatch: it abandons in-progress work and drives the command to a terminal `Canceled` state immediately. Because cloud-side operations may still be running, formae asks you to confirm before a forced cancel (pass `--yes` to skip), and lists any resources whose creation may have been left in flight so you can verify or clean them up. Update and delete operations left in flight are reconciled automatically by background synchronization on its next cycle. * **Install a production agent on AWS with one command.** A new open-source AWS bootstrap installer stands up a complete formae agent (VPC, ECS Fargate task, and database) secure by default, in either of two access modes: a public HTTPS endpoint fronted by an ALB with your own ACM certificate, or a private endpoint reachable only over your Tailscale tailnet with an automatically-provisioned trusted certificate. Basic auth is on in both modes, and a helper writes a ready-to-use connection profile so your CLI points straight at the new agent. This is now the recommended way to install the agent on AWS; the manual ECS walkthrough remains available for hand-tuned deployments. See [AWS Bootstrap](/documentation/guides/install-agent). ### Bug fixes * FIXED: A resource property whose value is a list or object taken from another resource's reference showed a perpetual update on every reconcile: it never settled, even immediately after a successful apply. (A DNS `CNAME` record whose value is sourced from an ACM certificate's DNS-validation records is the case that surfaced it.) Such a property now reconciles as a no-op once applied; properties with plain scalar values were never affected. * FIXED: When an update caused a dependent resource in a different region or account to be deleted, the delete could fail to locate its target. The resolved target configuration is now carried through to those dependent deletes, so the delete runs against the correct region and account. * FIXED: A resource with a list nested inside another list could show a change that never settled (reappearing on every reconcile even straight after a successful apply) when an entry in the inner list drew its value from another resource. An `AWS::ECS::TaskDefinition` whose container environment variables (a list nested inside the container-definitions list) reference another resource's output is the case that surfaced it. formae was matching the inner entries using the outer list's rules, so it paired the wrong entries and kept proposing a phantom update. Inner lists are now matched on their own terms, so these resources reconcile as a no-op once applied. * FIXED: Sensitive properties that the cloud provider never reads back, such as passwords and secret tokens, were listed as a change in every apply plan, tagged with a `(write-only)` label, even when their value had not changed. This cluttered an otherwise clean reconcile and made the plan harder to scan. Such a property now appears in the plan only when its value is actually changing, and the internal label is gone. * FIXED: A resource extracted with `formae extract` and then re-applied showed a perpetual update on any text property whose value contained a double-quote character: the property never settled, and its value gained extra backslashes on each apply. An inline function body (such as a serverless function's source) is the case that surfaced it. Quotes in extracted text are now escaped exactly once, so these properties round-trip through extract and re-apply as a no-op. * FIXED: Deleting a target left its discovered (unmanaged) resources behind in formae's inventory. Resources that formae had discovered but did not manage were not removed when their target was deleted, so they lingered (pointing at a target that no longer existed) until the next background synchronization cleaned them up, or indefinitely if synchronization was disabled. Deleting a target now removes its discovered resources immediately. Managed resources are unaffected and are still deleted through the normal provider cascade. ## 0.86.2 ### Bug fixes * FIXED: An `opaque` value (`formae.value(...).opaque`) was rewritten whenever another field on the same resource changed, even when the value itself was unchanged. For a rotating opaque value this minted a needless new value every time an unrelated field was edited; for a set-once opaque value (`.opaque.setOnce`) it could overwrite the stored value with an internal hash. An unchanged opaque value is now left untouched when other fields on the resource change, while a genuine change to the value still applies. * FIXED: Updating a list field marked atomic could be rejected by the cloud provider. The change was sent as a per-element remove-and-add pair; for mutually-exclusive lists such as an AWS Network Firewall policy's default stateful actions, the provider briefly held both the old and the new values and rejected the update. Atomic list fields are now updated as a single wholesale replacement, matching how the provider's own API applies them. * FIXED: When a reference to another resource's property could not be resolved, the dependent resource failed with an empty error message, before any cloud call, leaving nothing to act on. The failure now names the reference and the missing property (and the source resource where known), so the cause is clear. ## 0.86.1 ### Bug fixes * FIXED: Background synchronization could permanently stop after certain scheduling overlaps. Synchronization is now resilient to overlapping triggers and continues running until the agent is stopped. * FIXED: Auto-reconcile could not converge a stack when an earlier reconcile had failed. If the very first reconcile for a resource failed (for example, a transient outage on one node in a fleet), subsequent auto-reconcile cycles never retried the failed resource. In the worst case, after a partially-failed user reconcile, auto-reconcile would actively revert the resources whose updates had succeeded back to an older state instead of finishing the user's intent. Auto-reconcile now sources its baseline from the most recent user-submitted reconcile regardless of whether it succeeded in full, so failed updates retry on the next tick and successful ones stay applied. * FIXED: Destroying a stack with an auto-reconcile policy could leave the resources in place. The destroy itself completed, but the next auto-reconcile beat treated the pre-destroy resource list as the desired state and recreated everything within seconds, effectively rolling the destroy back. Destroys now contribute to the auto-reconcile baseline alongside applies, so a destroyed stack stays destroyed and the agent stops attempting to reconcile it. * FIXED: Slow plugin Read operations could time out before the plugin had a chance to respond. Plugin reads issued during dependency resolution were given a truncated timeout instead of the configured per-operation timeout, causing legitimate slow reads (e.g. cross-region or rate-limited APIs) to fail. Reads now get the full configured timeout. * FIXED: Periodic resource discovery could permanently stop after an apply that created a new discoverable target. Discovery is now resilient to overlapping triggers (apply, manual force-discover, and the scheduled timer) and continues running until the agent is stopped. ## 0.86.0 ### New features and improvements #### Relabeling resources * **Resources can now be relabeled via `alias`.** Previously this wasn't possible. See [Label → Renaming a resource](/documentation/concepts/label#renaming-a-resource). #### Extract * **`formae extract` handles per-version schema layouts.** Plugins that ship schemas split by API version (for example the Kubernetes plugin's `@k8s/v/` subtrees) can now be extracted and re-evaluated without the alias collisions and identifier errors that earlier 0.85.x releases produced when a forma referenced multiple per-version files. This unblocks the Kubernetes plugin 0.1.3 (K8s 1.35 and 1.36 support); see [Kubernetes plugin release notes](/documentation/reference/providers/kubernetes/configuration). #### PklProject auto-resolve * **formae auto-resolves PklProject dependencies.** Previously, running an apply in a directory with an unresolved `PklProject` failed with `NoSuchFileException` and forced a manual `pkl project resolve`. formae now runs the resolve automatically when `PklProject.deps.json` is missing. #### Cascading updates * **Mutable parent revisions no longer destroy their dependents.** Previously, when a parent resource was replaced, every dependent was cascade-replaced (torn down and recreated) regardless of whether the dependent's referring field could actually accept the new parent value via a provider-native update. The canonical case is an `AWS::ECS::Service` consuming an `AWS::ECS::TaskDefinition`: every routine TaskDefinition revision (an image deploy, an env-var bump) tore the Service down and stood it back up, typically two to four minutes of outage per deploy, even though the Service can absorb a new TaskDefinition revision via a rolling deploy without ever stopping traffic. #### Query syntax * **Same-field OR matching.** Repeating a field in a `--query` now matches any of the listed values. `formae inventory resources --query='stack:web stack:api'` returns resources in either stack; the exclusion form `-stack:scratch -stack:tmp` excludes any of them. Cross-field terms continue to AND together as before, so `type:AWS::S3::Bucket stack:prod stack:staging` still means "S3 buckets in (prod or staging)". * **Wildcard matching.** Every string-valued field (`stack`, `label`, `type`, `target`) accepts `*` as a prefix or suffix wildcard. `--query='label:prod-*'` matches every resource whose label starts with `prod-`; `label:*-prod` matches every label that ends with `-prod`. Wildcards work across the colons in type names too: `--query='type:AWS::S3::*'` matches every S3 resource type. Only `*` is supported (no `?`), and a bare `*` is rejected: wildcards must be anchored to at least one literal character. * **`target:` filter on resource queries.** Resource queries now accept `target: