Skip to content
GantryCD

Resource identity

A resource’s cloud identity answers one question: what real-world object does this state row describe, in a form globally unique across accounts, regions, clusters — and gantrycd installations. Two rows with the same identity and the same resource type are the same object. It feeds cross-stack duplicate detection and dependency inference — a data source in one stack reading an object another stack manages (see What inference does with it).

Implementation: pkg/domain/resource_identity.go — the contract, the two gantrycd: tag keys, the IdentityProvider interface and the IdentityRegistry that resolves a provider source to the implementation covering it — plus one resource_identity_<provider>.go per supported provider. Which providers a build mounts is a wiring decision, not a domain one: the backend builds the registry in cmd/backend/main.go, in the same block as the SCM, notification and SSO registries, and passes it down — domain.NewIdentityRegistry(domain.AWSIdentity{}).

The contract: supported or not

Identity is asserted only by an explicit, audited per-provider rule, and a rule accepts a value only when it parses as that provider’s globally-anchored grammar. Everything else yields nothing:

  • an unsupported provider — including a private-registry mirror of a supported one, because an unlisted source string is an unaudited rule;
  • a data source (see below);
  • any value that is unique only within some endpoint the state does not record. A Kubernetes namespace called platform exists in every cluster; nothing in the value says which one. A subnet id like subnet-0abc is unique within one account and region; the value records neither.

The asymmetry is deliberate: a missing identity costs at most an undetected finding, while a guessed one merges two real objects — a false duplicate today, a false dependency edge between unrelated stacks. There is no heuristic fallback and no confidence tier. This replaced a generic waterfall (first non-sensitive string among arn, id, self_link) that accepted random_pet:fluffy as a cloud identity. The decision was measured across 14 providers and ~5,400 resource types.

Sensitive values need no special case on the discovery path: the state inventory replaces sensitive leaves with the literal (sensitive value), which no identity grammar accepts. A declared id is taken verbatim and gets no grammar check at all, so a tag sourced from a sensitive variable stores that placeholder — see Declaring identity yourself.

Supported providers

ProviderRuleRefuses
hashicorp/aws (both registry FQNs)the resource’s own arn attribute, when structurally a valid ARN (arn:partition:service:region:account:resource; region/account may be empty — S3, IAM)bare ids; foreign-ARN types whose own key admits several rows carrying that ARN: aws_cloudwatch_event_target (the invoked Lambda), aws_secretsmanager_secret_version (the secret), aws_notifications_channel_association (the channel), aws_organizations_delegated_administrator (the account, once per service) and aws_quicksight_group_membership (the member, once per group)

Planned next: google (self_link / projects/… paths). Deliberately unsupported for now: kubernetesmetadata.uid is unique per cluster lifetime, is stripped by kubernetes_manifest, and survives cluster clones/restores, so it cannot assert a duplicate on its own; it waits for the provider-endpoint fingerprint work.

Coverage bounds (aws, measured)

Measured against hashicorp/aws v6.61.0: 898 of 1,709 types expose a top-level arn and can get an identity. The 633 id-only types and the 178 carrying neither arn nor id yield nothing, deliberately. An absent identity means “not asserted”, never “no duplicate exists” — and these numbers move with every provider release.

The foreign-arn exception list is an open audit, not a closed set. “arn is a required input” finds most such types mechanically, but not all (aws_secretsmanager_secret_version’s is computed and deprecated), so a missed one is a false duplicate until it is added.

A foreign ARN is not on its own enough to exclude a type — the type’s own key must also admit several rows carrying it, and that is readable from the provider schema’s required attributes. aws_organizations_delegated_administrator is keyed (account_id, service_principal), so one account legitimately appears once per service and is excluded. aws_cloudtrail_organization_delegated_admin_account looks almost identical but is keyed on account_id alone, so at most one exists per organization — two of them really are two stacks registering the same delegation, where destroying either deregisters the other, and it is not excluded. Shape-matching without checking the key suppresses real conflicts.

Adding a provider

  1. A type implementing IdentityProvider in pkg/domain/resource_identity_<name>.goSources() and Identity(). Identity returns the id and the realm, because where both live is provider-specific: AWS reads them from tags and tags_all, google would read labels, kubernetes annotations. Plain Go inside: per-type exceptions are switch cases, version gates are ifs.
  2. Sources() lists every FQN the provider ships under (the OpenTofu and Terraform registries are distinct strings). Keeping them beside the rule that reads them is what stops a half-registration; the registry panics at startup if two providers claim one source.
  3. One argument in the NewIdentityRegistry call in cmd/backend/main.go. sampleexplore mounts only the providers its fixtures generate data for — today, aws — so leave it alone until you add fixtures for yours.
  4. A table test per accepted and refused shape, including the (sensitive value) placeholder and at least one structurally-valid value that must still be refused (a foreign-object exception, if the provider has one).
  5. A row in the table above.

The bar for a rule: the accepted value’s grammar must itself anchor the object globally (partition, subscription, project — something baked into the value). If identifying the object needs anything from outside the value — provider config, an endpoint, a cluster — the provider is not supportable yet.

Declaring identity yourself

Discovery is deliberately conservative, so operators get an explicit override. Two tags, read from whichever metadata field the provider records in state. For AWS that is both tags (what the resource itself declares) and tags_all (the provider’s default_tags overlaid with them) — and which one a value arrives in changes what it means, as the table shows.

TagEffect
gantrycd:resource-identity-idDeclares the identity, on the resource’s own tags. Beats discovery. Like a discovered one it matches only within a resource type, so it cannot link an alias pair — see the known miss below. Inherited from default_tags it suppresses instead of declaring, exactly like the opt-out: one id shared by every resource a stack manages would make them all one object, so gantrycd refuses rather than guessing which it meant.
gantrycd:resource-identity-id = "null"Opts out. No identity: excluded from duplicate detection and inference. In default_tags it opts out every taggable resource in the stack — see the bound below.
gantrycd:resource-identity-realmScopes the identity to a named world, so values unique only within one endpoint stop colliding across endpoints.
// gantrycd:resource-identity: ignore=true (a code comment, not a tag)Opts out by address, for what a tag cannot reach: untaggable types and data sources. Also the way to stop a false inferred dependency — a declaration only overrides its trigger policy. Grammar in dependency_graph.md.
provider "aws" {
  endpoints { s3 = "http://localhost:4566" }
  default_tags {
    tags = { "gantrycd:resource-identity-realm" = "localstack-alice" }
  }
}

The declarations only reach taggable types

Both tags are read from tags / tags_all, so they reach only resources whose schema has them. Measured against aws v6.61.0: 157 of the 898 types that expose a top-level arn are untaggable — they take no tags at all, or use a tag block that never lands in either map (aws_autoscaling_group, aws_cloudwatch_log_stream, the aws_cloudfront_*_policy family, …).

That figure comes from reading the hashicorp/aws v6.61.0 provider schema by hand; nothing in this repository derives or checks it, and no test will fail when a provider release moves it. Treat it as a dated observation about the shape of the problem, not a constant the code depends on — the code never consults a type list, only whether the row it has carries the tag.

On those types the tag declaration, the realm and the tag opt-out all do nothing, and gantrycd falls back to the discovered ARN. The gantrycd:resource-identity: ignore=true comment is the per-address escape hatch that does reach them. Three consequences worth stating plainly:

  • A stack-wide opt-out in default_tags leaves them asserting identities.
  • Two emulator endpoints scoped with gantrycd:resource-identity-realm still collide on their untaggable resources, because those rows carry no realm.
  • Every one of the five foreign-arn exception types is untaggable, so a missed sixth cannot be corrected by a tag — the operator disclaims those addresses with the comment until the rule is added.

Closing this without operator action needs the provider-config fingerprint below, not another tag.

A realm is a short token ([A-Za-z0-9._-], up to 64) — a label, not a URL. An unparseable one refuses the identity rather than ignoring the realm, since ignoring it would silently widen the claim. Realms only match realms: a resource declaring one never matches a resource that declared none, because nothing proves they are the same world.

null is the spelling that survives, not a bare null: HCL drops a null value from a tag map before it ever reaches state. An empty value means the same.

What this solves, and what remains a bound

An ARN anchors to a partition, account and region — not to a verified endpoint. An AWS-compatible API (LocalStack, MinIO, another cloud’s S3) emits well-formed ARNs, and the endpoint appears nowhere in state: the provider’s endpoints block is provider configuration, which OpenTofu never writes there. Two stacks pointed at different such endpoints would otherwise carry identical ARNs for unrelated objects.

gantrycd:resource-identity-realm is the answer, and it is explicit rather than guessed — gantrycd does not try to detect emulators. The bound that remains is that it must be declared: an operator who runs two endpoints and sets no realm still gets the collision. Closing that without operator action needs a runner-side provider-config fingerprint, which is also what would make endpoint-scoped providers like kubernetes supportable at all.

Two things do not help, checked against the provider schema (aws v6.61.0): default_tags is itself provider configuration — its effect reaches state as tags_all, which is exactly why the tags above work, but tags alone cannot serve as an automatic scope (two stacks managing the same object routinely record different tags, which would split real duplicates). And region, on 1,522 of 1,700 types since provider v6, is already inside the ARN.

How it is stored and compared

An identity is id plus, when an operator declared one, realm — two columns (resource_index.cloud_identity_id, cloud_identity_realm) and two wire fields.

Two resources are the same object when their id, realm and resource type all match. The resource type comes from the row itself, and it matters: several types legitimately carry another object’s ARN — aws_sns_topic_policy.arn is the topic’s own, identical to aws_sns_topic.arn — so without it a stack managing a topic and a stack managing that topic’s policy would read as duplicate-managing one object.

The cost is a known miss: provider aliases for one object (aws_lb/aws_alb, aws_vpc/aws_default_vpc) are different types, so a genuine duplicate across an alias pair goes unreported. A miss is always the acceptable side.

Why data sources never store an identity

A data source reads an object; a managed resource owns it. If a data row stored the object’s identity, duplicate detection would group it with the very resource it reads — every cross-stack read would be reported as a duplicate. So data rows always store NULL. Dependency inference — which wants exactly that match — computes the data-side identity transiently at reindex time and looks up managed rows by it, without ever persisting it.

What inference does with it

A data row and a managed row in a different stack that share an id, a realm and a resource type describe one object: one stack owns it, the other reads it, so the reader depends on the owner. gantrycd records that as a dependency edge, resource-scoped on both ends and marked discovered.

# stack "account"                                    # stack "taloscluster"
resource "aws_acm_certificate" "wildcard" { ... }     data "aws_acm_certificate" "wildcard" { ... }
#   arn = arn:aws:acm:…:certificate/4948e3d3            attribute_values.arn = the same ARN

Everything on this page is therefore load-bearing twice over. The type check that keeps aws_sns_topic_policy from reading as a duplicate of its topic also keeps a data source from being pointed at the wrong owner; the realm that separates two LocalStack endpoints also separates their dependency graphs; and the opt-out excludes a resource from both. Which is why “missing beats wrong” is the rule: a guessed identity draws an edge between stacks that have nothing to do with each other.

What inference is allowed to say with it is bounded separately — it never asks for automation (every inferred edge is manual, which does still combine with a declared always and can hold it down), and a declaration wins over its trigger policy by address coverage: the same address, an ancestor of it, or the dependency stack as a whole. The inferred dependency itself remains graph and diagnostic evidence. Those rules, the write path, and how the edges heal live in Cross-Stack Dependency Graph → Inferred dependencies.

Deferred by design

  • Version-aware rules: no version signal exists in the pipeline today (the state’s per-resource schema_version is not captured). Capturing it is a small additive runner change that lands with the first rule that reads it; until then, grammar validation is what makes field changes safe — a renamed field degrades to no-identity, never to a wrong one.
  • Realm scope: a verified backend/provider realm would let endpoint-scoped providers (kubernetes and friends) be supported at all. Nothing is reserved for it in the format yet — that shape gets decided when the realm exists, alongside gantrycd_state_outputs discovery re-enablement.