Infrastructure as Code
Export real infrastructure into deterministic Terraform JSON, then plan, apply, and destroy through a policy-gated, audited lifecycle.
Infrastructure as Code
vortos-iac answers a question most teams answer badly: does your Terraform actually match what your application thinks its infrastructure looks like? Rather than hand-writing .tf files that drift from reality the moment someone clicks a change into a cloud console, IaC exports Terraform configuration from your application's own declared infrastructure needs (queues, databases, networks, IAM) and then governs the plan/apply/destroy lifecycle with policy gates and an audit trail.
Export, don't author
You don't write .tf.json by hand. You declare what your application needs (a queue, a database, a DNS record) using the same module config you'd write anyway, and an exporter renders the Terraform. The generated file is the build artifact; your application config is the source of truth.
Exporters — declaring infrastructure once
Ten exporter families translate application-level declarations into Terraform resources:
| Family | Produces |
|---|---|
| Compute | VMs, instance groups |
| ComputeService | Managed compute (App Runner-style services) |
| Database | Managed database instances |
| Network | VPCs, subnets, security groups |
| Queue | Managed queue infrastructure |
| Kafka | Kafka cluster/topic infrastructure |
| Cache | Managed Redis/cache instances |
| ObjectStore | Buckets and lifecycle policies |
| Dns | DNS zones and records |
| Iam | Roles, policies, service accounts |
Each exporter pairs an *ExporterDefinition (what your application declares) with an *Exporter (the Terraform-rendering logic) and a provider-specific *Provider driver — following the same Ops Kit port/driver split everywhere else in the framework, so a Network exporter targeting a different cloud provider is a new driver, not a rewrite of the exporter itself.
Deterministic rendering — the drift guard
TerraformDocument is the single place every exporter writes through, and it renders deterministically: sorted keys, stable formatting, a trailing newline. The same application declarations always produce byte-identical .tf.json output.
# regenerate and check whether the committed file matches
php bin/console vortos:iac:export --checkBecause output is byte-identical for the same input, --check is an exact CI gate — not a fuzzy diff, not "close enough." If someone hand-edited the generated Terraform file, or your application's declared infrastructure changed without regenerating, this fails the build with a real diff, the same run the drift was introduced.
The secret gate lives in the renderer, not the exporters
exporter declares an attribute
│
▼ TerraformDocument
└── attribute name looks secret-shaped + value is a literal string?
│
├── on an explicit allow-list → render it (loud, greppable, reviewable)
└── not allow-listed → SecretLiteralException — export failsThis check is centralized in TerraformDocument specifically so no exporter can bypass it by being written carelessly. A database exporter that accidentally inlines a literal password instead of a reference to Secrets fails the export outright, rather than producing a .tf.json file with a plaintext credential in version control.
The lifecycle: plan → policy → apply
IacLifecycleService is the orchestration layer above the raw engine (IacEngineInterface, with Terraform as the shipped driver). Three guards run before apply() is allowed to touch anything:
public function apply(IacWorkspace $ws, IacPlan $plan, IacExecutionContext $ctx): IacApplyResult
{
$this->guardPlanFile($plan); // ← plan hasn't gone stale
$this->guardBlastRadius($plan, $ws->environment, $ctx->allowDestructive); // ← destructive count under the limit
$this->guardPolicy($plan); // ← custom policy rules pass
return $this->engine->apply($ws, $plan, $ctx);
}Stale-plan protection
A Terraform plan file represents a point-in-time snapshot. If the underlying state changes between plan and apply — someone else applied a change, or the plan file was edited — applying the stale plan could do something nobody actually reviewed. guardPlanFile() re-hashes the plan file and compares against the digest recorded at plan time; any mismatch throws PlanStaleException rather than silently applying against now-incorrect assumptions.
Blast radius limits
private readonly int $maxDestructiveProd = 0;
private readonly int $maxDestructiveNonProd = 5,By default, zero destructive changes are allowed to apply in production without an explicit allowDestructive override. A plan that would destroy more resources than the configured limit throws DestructiveChangeRefusedException, naming every resource address that would be destroyed. This is a deliberate brake on the most dangerous failure mode for infrastructure-as-code: a plan that looks like a small change but actually destroys and recreates half your database tier because of an unnoticed attribute change.
Custom policy
interface PlanPolicyInterface extends DriverInterface
{
public function evaluate(IacPlan $plan): PolicyResult;
}Write your own PlanPolicyInterface driver for organization-specific rules — "no resource in us-east-1," "every S3 bucket must declare versioning," whatever your compliance posture requires. PolicyViolationException carries the full PolicyResult so a failed apply tells you exactly which rule failed and on which resource.
Every lifecycle action is audited
$this->auditSink->record(new LifecycleEvent(
$phase, // Plan | Apply | Destroy
$ws->environment,
$planDigest,
get_current_user(),
$summary,
$binaryVersion,
$timestamp,
));LedgerIacAuditSink is the production sink — wired into the framework's append-only audit ledger, the same write-once guarantee documented in Observability's audit ledger and Release's manifests. Every plan, apply, and destroy is recorded with who ran it, what the plan digest was, and how long it took — independent of whether the action succeeded.
Drift detection feeds the doctor, not just a report
php bin/console vortos:iac:drift --env=productionIacDriftAuditor::audit() runs terraform show against the current state and compares it to what's declared — if there's a difference, it's drift: someone or something changed real infrastructure outside the IaC pipeline. This isn't just a standalone report — Deploy's doctor checks include IacDriftCheck, so a deploy to an environment with undetected infrastructure drift fails preflight before the deploy even starts, rather than deploying application code onto infrastructure that no longer matches what was planned for it.
CLI
# export application declarations to Terraform JSON
php bin/console vortos:iac:export
php bin/console vortos:iac:export --check # CI drift gate — exits non-zero on mismatch
# plan, apply, destroy
php bin/console vortos:iac:plan --env=production
php bin/console vortos:iac:apply --env=production
php bin/console vortos:iac:destroy --env=staging
# detect drift between declared and actual infrastructure
php bin/console vortos:iac:drift --env=productionapply requires a plan, always
There's no direct "apply this config" shortcut — apply() takes an IacPlan produced by plan(), and guardPlanFile() verifies that exact plan file hasn't changed since it was generated. You always review a plan before anything is applied; the lifecycle service makes skipping that step structurally unavailable, not just discouraged.
Step-by-step: your first export
Declare infrastructure your application needs, e.g. an object store bucket:
$config->bucket('uploads')->versioning(true)->lifecycleDays(90);Export it:
php bin/console vortos:iac:exportReview the generated .tf.json, then plan against a real environment:
php bin/console vortos:iac:plan --env=stagingApply:
php bin/console vortos:iac:apply --env=stagingWire vortos:iac:export --check into CI so a declaration change without a regenerated, committed .tf.json fails the build immediately.
Analytics
A privacy-by-default product analytics port — denies consent until your app says otherwise, redacts PII independently of configuration, and bridges feature flag exposures.
Feature Flags
Ship every feature to production and control visibility through configuration — no redeploy, no downtime, instant kill switch.