Your stacks still need an order. They should not need you to wire the values.
Terraform, Pulumi and Terragrunt each make you hand-wire values between stacks, and none of them tells you when the stack you read from moved underneath you. ubx pins the reference and refuses to accept a proposal built against a moved neighbour.
Every platform team running infrastructure at scale hits the same wall. You have a handful of stacks that must be applied in a specific order: networking first, then the cluster, then the add-ons, then GitOps.
networking -> cluster -> addons -> gitopsnetworking provisions VPCs and subnets. cluster needs those subnet IDs to
place the EKS cluster. addons needs the cluster endpoint to install Helm
charts against it. gitops needs the cluster credentials to register ArgoCD.
Two separate problems live in that arrow. The first is what order to run things in. The second is how a value gets from one stack into the next, and what happens when the stack you read from changes after you read it.
Most of the argument about multi-stack infrastructure is about the first problem. The second one is where the outages come from.
The way it works today
Terraform: remote state, read at apply time
Terraform's built-in answer is terraform_remote_state:
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "myorg-tf-state"
key = "networking/dev/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_eks_cluster" "this" {
vpc_config {
subnet_ids = data.terraform_remote_state.networking.outputs.private_subnet_ids
}
}This works, and it has a property worth naming: the read happens at apply
time, against whatever is in that state file at that moment. If somebody
applied networking twenty minutes ago and replaced the subnets, your plan
output and your apply disagree, and nothing tells you. The value you reviewed
is not necessarily the value you applied.
You also have to publish an outputs block for every value another stack
needs, which turns your stack's internals into a public interface.
Pulumi: stack references, same timing
Pulumi's StackReference is the same shape with better types:
const net = new pulumi.StackReference("myorg/networking/dev");
const subnets = net.getOutput("privateSubnetIds");Typed, and still read at run time against the current state of the other
stack. The type system knows the value is a Output<T>. It does not know
whether the stack it came from moved since you last looked.
Terragrunt: order as configuration
Terragrunt makes the ordering explicit, which is a real improvement:
dependency "networking" {
config_path = "../networking"
}
inputs = {
subnet_ids = dependency.networking.outputs.private_subnet_ids
}run-all apply walks the graph. The dependency edges are declared once and
the tool honours them. What it does not give you is any guarantee about the
value itself between the moment you reviewed a plan and the moment it applied.
What ubx does differently, and what it does not do
Start with the honest part, because it is the part the old version of this post got wrong.
ubx does not orchestrate your stack order. There is no apply-all, no
cross-stack runner, no graph walker over stacks. Every ubx command operates on
one stack. If you have four stacks with a linear dependency, you still run
them in order, in whatever CI you already have. Terragrunt genuinely solves a
problem here that ubx does not attempt.
What ubx changes is the second problem: the value, and whether you can trust that it did not move.
A cross-stack reference is a string in the resource you are authoring:
stack = "platform"
blueprint "service" "api" {
source = "../blueprints/service"
replicas = 3
vpc_id = "@network.aws_vpc.main.id"
}The form is "@<stack>.<type>.<name>.<attribute>", and it is identical from
the SDK, from a hand-written intent file, and from HCL. ubx addresses prints
the copy-paste-ready form for every address in a stack, so you are not guessing
at another team's resource names:
networking.aws_vpc.main
provider: hashicorp/aws@6.54.0
@networking.aws_vpc.main.id (computed, known after ship)
@networking.aws_vpc.main.cidr_block (always present)Referencing one from the payments stack resolves it against the neighbour's
ledger at plan time, so the value you review is concrete:
Plan payments · from pay.json
+ aws_vpc.payments create
cidr_block: "10.0.0.0/16"
delta: +1 create(s), ~0 change(s), -0 terminate(s)
blast radius: +1 ~0 -0
ubx-proposal: 74a0863b4c7b
next: ubx ship 74a0863b4c7bA cross-stack reference needs no outputs block. ubx does have outputs, and
they are a separate mechanism for a separate job. What a @stack reference
gives you is the ability to read a neighbour's resource without that neighbour
publishing an interface for you, or knowing you are reading from it at all.
You address its resources the same way you address your own.
The part that matters: the reference is pinned
When ubx resolves your proposal, it records the neighbour ledger's own head at
that moment, as a cross_stack_pin entry on the proposal, alongside where to
re-derive that head from.
At accept time it re-derives the neighbour's current head and compares. If the neighbour moved, acceptance fails:
$ ubx accept 74a0863b4c7b
accept: resolve: cross-stack pin is stale -- the neighbor ledger has
advanced since this proposal was resolved: networking.aws_vpc.main
(ledger ../networking) pinned at ee9ec4e559e0..., now bb1ee40e592f...
$ echo $?
1It names the reference, the neighbour, the head it was pinned to and the head that is there now.
Every terminal block in this post was produced by running ubx just now, but
against a hermetic test fixture standing in for the AWS provider rather than
the real one. The ubx behaviour is real. The thing on the other end of the
plugin socket is not, so no AWS resources were created to write this.
That is the whole difference. Terraform and Pulumi both read the other stack's current value at apply time and proceed. ubx records what it read, and refuses to accept a proposal built against a world that has since changed. Not a warning. A refusal, with its own exit code, so CI stops.
The failure mode this removes is specific and common: you plan cluster
against networking, someone lands a change to networking, your apply goes
through against subnets that no longer exist or no longer mean what they meant.
With a pin, that apply does not happen. You re-resolve and look again.
What it costs
Two constraints worth knowing before you reach for this.
Cross-stack references need a shared ledger base. Stacks resolve each
other against a common remote store, S3, GCS or Azure Blob. A purely
git-local ledger has no base to resolve a neighbour against, and ubx refuses
the reference rather than guessing. Stacks under a different base need an
explicit [ledger.external] entry.
A pin makes staleness visible, which means you will see it. If your
networking stack changes often and your cluster proposals are long-lived,
you will hit stale pins. That is the mechanism working, but it is a real
change in workflow compared to a tool that silently reads the latest value.
What this is not
This does not replace Terragrunt's ordering. If your problem is "which stack runs first", ubx does not answer it and you should keep whatever answers it today.
It answers the narrower question of whether the value you reviewed is the value you applied, which is the one that tends to be found out in production rather than in review.
