Infrastructure as code, in Lean 4
Terraform-style declarative infrastructure, written in the Lean programming language and proof assistant instead of a bespoke DSL. Target and observed cloud state are dependently-typed Lean values, so an unrealisable target is a compile error rather than a failed apply.
A dangling reference, a region a cloud is not in, an instance size that does not exist, a plaintext password in a committed file — none of these get as far as a plan.
Declare a fleet
A complete, working declaration. The resource's name is written once and is the cloud's real identifier.
Clouds
What the compiler catches
Not a validation pass that runs before apply. These are type errors and decidable side-conditions, settled while the file elaborates — so the failure arrives in your editor, not after half a fleet exists.
References cannot dangle
A reference has the type of an index into this fleet's own keys. There is no "not found" case to handle, a bucket key is a different type from a security-group key, and keys are indexed by cloud as well as kind. Ordering falls out of the same fact: the scheduler reads the reference graph, handles arbitrary DAGs, and rejects cycles by name.
-- pointing at a bucket where a security group belongs Application type mismatch: The argument bucket has type keys.Key ProviderId.aws Kind.s3Bucket but is expected to have type Expr keys.Key (keys.Key ProviderId.aws Kind.securityGroup)
A cloud cannot be somewhere it is not
A locality is a place named before any cloud names it, so one
in paris resolves to eu-west-3 on AWS and
fr-par on Scaleway. Scaleway has a Warsaw region and AWS does
not — so in warsaw compiles for a Scaleway-only fleet and
fails for one that also uses AWS.
fleet crossCloud in warsaw where … Tactic `decide` proved that the proposition Assert (Locality.warsaw.covers keys) is false
Instance types are a family and a size
Not a string. Twenty-six current families share nine size lists between
them, so the pair is checked: t3 stops at
2xlarge, and gen-7 Intel skips 32xlarge entirely
and jumps to 48xlarge. Autocomplete lists the families, then
the sizes it actually comes in.
instanceType := InstanceType.of .t3 .xlarge32
Tactic `decide` proved that the proposition
Assert (InstanceFamily.t3.sizes.contains InstanceSize.xlarge32)
is falseSecrets never hold a value
A secret names an environment variable, or a function over state
that does not exist yet. The value is never known to the file, the plan
output, or a dump snapshot. That a committed fleet holds no plaintext is
itself decidable — #guard plan.secretsAreSound — so it is
checked at compile time rather than trusted.
would CREATE scaleway/secrets/db-password would CREATE scaleway/postgres/main would CREATE scaleway/secrets/db-url -- the composed secret comes last because both its -- references are ordering edges, not because anything -- here lists an order
Why this instead of Terraform or OpenTofu
Not for the breadth — they have thousands of providers and this has three. For what the type system buys, which is a different thing and worth being precise about.
Errors move to compile time
A dangling reference, a reference of the wrong kind or the wrong cloud, a missing required field, a region a cloud is not in, an instance size that does not exist, a plaintext secret in the committed file. In HCL each of these is a plan-time or apply-time failure; here they are type errors and decidable side-conditions, so they fail in your editor. See the examples above — each is a real message.
Ordering is derived, not declared
There is no depends_on. A reference is the
dependency, so the graph cannot disagree with the code — and both
directions are sorted, so teardown is the reverse of the same graph
rather than a guess. Cycles are rejected by name.
One apply, not two
A value that needs state which does not exist yet — a connection string wanting both a generated password and an endpoint the cloud assigns at creation — is written as a function over that state. Both references become ordering edges, so one apply does it. No second run, no operator pasting a string in between.
Secrets cannot be in the file
A secret names an environment variable or a composition — never a
value. That a committed fleet holds no plaintext is itself decidable
and checked at compile time, and no value reaches plan output or a
dump snapshot.
One spec, either cloud
Portable kinds are indexed by kind alone, never by provider, so the same value applies to any cloud; the provider enters only at apply. Reaching for a cloud-specific concept makes the loss of portability visible in the type, not in a comment.
It is Lean, not a DSL
Loops, functions, abstraction and testing are the host language's,
already there and already documented. Your fleet is a value you can
compute with, and #guard lets you assert things about it
that hold at build time. Apache-2.0, no CLA, no relicensing risk.
It converts both ways
toHcl turns a fleet into .tf — resource
blocks, provider blocks from the placement, and real HCL references
where the fleet has references, because a reference is an index into
the fleet and so the target's type and label are both derivable.
fleetOfState reads terraform show -json the
other way and writes a fleet declaration.
Neither is claimed to be a round trip. What HCL cannot express — a
composed secret, a value over post-apply state — comes out as a
# TODO naming what was dropped, because a silently wrong
value is worse than a visible hole.
# A STARTING POINT, NOT A ROUND TRIP. provider "aws" { region = "eu-west-3" } resource "aws_security_group" "web" { name = "web" description = "http" } resource "aws_instance" "web-1" { ami = "ami-1" instance_type = "t3.nano" vpc_security_group_ids = [aws_security_group.web.id] }
How far this has actually been run
Every kind plans offline against a placeholder backend, and most have been created and destroyed against a real account. Which kinds, on which clouds, and how far — that is the coverage report, kept in the repo next to the tests it describes. Managed-cluster provisioning and deletion report elapsed time and the cloud's status while they wait; the opt-in Kubernetes live test budgets time for teardown as well as creation, and checks AWS role reads before provisioning. EKS's default-VPC selector is recognised on observation as well as creation.
Since 0.22.0, container tags such as :latest
resolve to verified manifest digests before the live diff. Apply deploys
that frozen content; an unchanged digest converges. See the
image resolution guide
for registry authentication and the Kubernetes scope.
Start a project
infra new scaffolds a declaration repository you can commit
and deploy the same day: the canonical structure, a commented example
fleet that compiles, and CI for GitHub and GitLab with the
plan/apply split already wired. It builds on lake init, so
Lake still owns the toolchain pin — what it adds is the part Lake cannot
know about, chiefly the native link flags every consumer needs and would
otherwise copy by hand — and it converts lakefile.toml to
Lean, because those flags cannot be expressed in TOML. It writes only
what is absent, so it is safe to re-run and safe on a project with work
in it. Your declaration is a Lean program, so
lake exe my_infra is the CLI: nothing to keep in
step with your code. What it manages is decided by a marker on each
resource, not a committed file — a tag where the cloud has tags, a
marker written into the object’s description where it
has not, and the resource’s own name against the fleet’s
prefix for the two products with neither. The marker carries the
fleet’s name — fleet myFleet is
my-fleet — so two fleets sharing an account read each
other’s resources as foreign and leave them alone. The markers are
read from the cloud on every run and nothing is stored locally, so a
fresh CI runner sees what your laptop sees. forget takes the
marker off a tagged resource without deleting it, and dump writes
that view as JSON, never a secret.
lake init my_infra && cd my_infra # add to lakefile.toml: # [[require]] # name = "infra" # git = "https://github.com/typednotes/infra" # rev = "v0.23.0" lake update # fetch infra lake exe infra init # turn this into an infra project lake build lake exe my_infra # offline plan — free, no credentials lake exe my_infra apply # make it so
You get Fleet.lean (the whole declaration),
Main.lean (the account ids, and one call handing the whole
declaration to Infra.Cli.run), a .gitignore for
Lake’s build output, Catalogue.lean with every resource
kind declared once to copy from, and pipelines for GitHub
Actions, GitLab CI, CircleCI, Azure Pipelines and Jenkins — each
planning on every push and applying only when a person presses the
button. Fill in the account ids, add your secrets, commit.
Or read this one
A bare invocation is offline, credential-free and free of charge: it plans against placeholder backends. You have to ask for the real thing.
git clone https://github.com/typednotes/infra && cd infra lake build lake exe infra # offline self-checks lake exe cross-cloud # a plan spanning two clouds lake exe multi-region # six resources in four regions