Skip to main content

Upjet External Name Patterns

·1201 words·6 mins
Aditya Wardianto
Author
Aditya Wardianto
DevOps Engineer @ Cube Asia

Upjet External Name Patterns
#

Why?
#

I’ve been building a Crossplane provider for Snowflake using upjet, the framework that generates Crossplane CRDs and controllers from a Terraform provider. It generates 90% of the boilerplate, but the 10% you configure manually is where the sharp edges live. The sharpest edge is the external name config.

Each Terraform resource needs one, and picking the wrong pattern means either a field you expect in spec.forProvider isn’t there, or setting an annotation causes a reconciliation loop that trips the Crossplane circuit breaker. I hit both of these in one afternoon.

There are exactly four patterns, and the AWS provider-upjet-aws 1, the most mature upjet provider, uses all of them across its 1,000+ resources. Here’s what I learned.

Question Chain
#

Why does upjet need an “external name” config at all?

Crossplane manages external cloud resources. Each managed resource (a Bucket, a Queue, or a User) needs to map its Kubernetes identity to the cloud resource’s identity. That mapping is the external name. In Terraform, every resource has an id in its state file. Upjet needs to know: what is that id, and how do we derive it from the fields the user provides?

Without this config, upjet won’t even generate a resource. The external name config is the gate.2

So what are the options?

Four. I’ll show you each one, with real examples from the AWS provider.

Start with the simplest one.

NameAsIdentifier. The resource’s name IS its identity. No ARN, no URL, no UUID. Just the name the user typed.

// config/externalname.go (AWS provider)
"aws_elasticache_serverless_cache": config.NameAsIdentifier,
"aws_opensearchserverless_access_policy": config.NameAsIdentifier,
"aws_codeguruprofiler_profiling_group": config.NameAsIdentifier,

Five resources in total use this in the AWS provider. 1 Here’s what the YAML looks like:

# examples/elasticache/namespaced/v1beta1/serverlesscache.yaml
kind: ServerlessCache
metadata:
  name: example-cache           # ← THIS is the cache name
spec:
  forProvider:
    engine: memcached           # ← no "name" field here

Notice name is NOT in spec.forProvider. It’s in metadata.name. That’s because NameAsIdentifier puts name in OmittedFields. The framework removes it from the CRD spec. SetIdentifierArgumentFn copies the external name, derived from metadata.name via an initializer, into the Terraform name field at runtime.

The flow is:

sequenceDiagram
    User->>K8s: kubectl apply with metadata.name: "my-user"
    K8s->>Initializer: NewNameAsExternalName
    Initializer->>K8s: Sets crossplane.io/external-name="my-user"
    K8s->>Reconciler: Reads annotation
    Reconciler->>Terraform: SetIdentifierArgumentFn(name="my-user")
    Terraform->>Provider: Create user "my-user"
    Provider->>Terraform: SetId("my-user")

I was confused when spec.forProvider was empty on my snowflake_user. The generated example was literally forProvider: {}. That’s because the first example in the Terraform docs only set name, and name is omitted. The example was technically correct, just not useful as a starting point.3

What if I want to change the name later?

That’s the rub. With NameAsIdentifier, the name is the identity. To rename you recreate. In GitOps (Argo, Flux), you change metadata.name in your YAML. The old CR gets pruned, the new one gets created. It works, but it’s not as smooth as changing a field in spec.forProvider.

That sounds fragile. What about the annotation?

Don’t touch it. crossplane.io/external-name is set once by the initializer. Changing it on a running resource tells upjet “I’m now managing a different resource.” It tries to adopt the new name, fails because nothing exists there, and retries forever. That’s the circuit breaker loop I mentioned.

The AWS examples never set this annotation for NameAsIdentifier resources. I checked. They all rely on metadata.name and the initializer.1

What about resources where the name is in spec?

That’s ParameterAsIdentifier. The user types the name as a normal field, and that field happens to also be the identifier.

"aws_s3_bucket":               config.ParameterAsIdentifier("bucket"),
"aws_lambda_function":         config.ParameterAsIdentifier("function_name"),
"aws_redshift_cluster":        config.ParameterAsIdentifier("cluster_identifier"),
"aws_transcribe_vocabulary":   config.ParameterAsIdentifier("vocabulary_name"),
# examples/s3/namespaced/v1beta1/bucket.yaml
kind: Bucket
metadata:
  name: example          # ← just the K8s resource name
spec:
  forProvider:
    bucket: my-bucket    # ← THIS is the bucket name
    region: us-west-1

Here name stays in spec.forProvider. DisableNameInitializer: true. metadata.name is just the Kubernetes resource name, nothing more. To rename, you change spec.forProvider.bucket and let Terraform handle the recreation.

This is the pattern most people expect: the name is a field you can see and change.

What about the default case, most AWS resources?

IdentifierFromProvider. The provider generates the ID: an ARN, a URL, or a UUID. The user’s chosen name is just a parameter, not the identifier.

"aws_sqs_queue":         config.IdentifierFromProvider,
"aws_vpc":                config.IdentifierFromProvider,
"aws_db_instance":       config.IdentifierFromProvider,
"aws_lb":                config.IdentifierFromProvider,
"aws_cloudfront_distribution": config.IdentifierFromProvider,

Hundreds of resources use this. It’s the default for a reason: most cloud resources have provider-generated IDs.

# examples/sqs/namespaced/v1beta1/queue.yaml
kind: Queue
metadata:
  name: example
spec:
  forProvider:
    name: upbound-sqs          # ← the queue NAME (not the ID)
    delaySeconds: 90
    region: us-west-1

The SQS queue’s identifier is its URL (https://sqs.us-west-1.amazonaws.com/123456789012/upbound-sqs). The queue name is embedded in that URL, but the URL itself is the identity. So IdentifierFromProvider applies: the ID comes from AWS, the name is just a setting.

And the last one?

Compound IDs. TemplatedStringAsIdentifier builds the external name from multiple fields.

"aws_accessanalyzer_archive_rule": config.TemplatedStringAsIdentifier(
    "rule_name",
    "{{ .parameters.analyzer_name }}/{{ .external_name }}",
),

The Terraform ID is analyzer_name/rule_name. The external name is the rule_name part. The analyzer_name comes from another field in spec.forProvider.

Other compound patterns use FormattedIdentifierFromProvider for joining multiple spec fields with separators:

"aws_s3_bucket_metric":       FormattedIdentifierFromProvider(":", "bucket", "name"),
"aws_rds_cluster_role_association": FormattedIdentifierFromProvider(",", "db_cluster_identifier", "role_arn"),
"aws_organizations_policy_attachment": FormattedIdentifierFromProvider(":", "target_id", "policy_id"),

How do I decide which one to use?

Read the Terraform import section. It tells you everything.

Import format Pattern
terraform import <resource> '<name>' NameAsIdentifier or ParameterAsIdentifier
terraform import <resource> <name> (bare, no quotes) NameAsIdentifier
terraform import <resource> <arn> IdentifierFromProvider
terraform import <resource> '<a>|<b>' TemplatedStringAsIdentifier or FormattedIdentifierFromProvider
Then check the TF provider source for d.SetId(). It tells you exactly what format the ID takes at runtime.

For snowflake_user, the import is:

terraform import snowflake_user.example '"<user_name>"'

And the provider source does:3

name := d.Get("name").(string)
id := sdk.NewAccountObjectIdentifier(name)
d.SetId(helpers.EncodeResourceIdentifier(id))   // id.Name() = bare name, no quotes

The ID is the bare user name. That’s NameAsIdentifier.

For aws_sqs_queue, the import is the queue URL. That’s IdentifierFromProvider.

So the rule is: whatever d.SetId() receives, that’s the external name pattern.

Yes. But you should also verify by checking Parse*Identifier or d.Id() in the Read function to be sure. The TF provider source at /tmp/terraform-provider-snowflake/pkg/resources/user.go:441 parses the ID with sdk.ParseAccountObjectIdentifier(d.Id()). This confirms the ID is an account object identifier, a bare name.3

TL;DR
#

Upjet external name configs determine where a resource’s identity lives, and picking the wrong one breaks your workflow.

There are four patterns:

IdentifierFromProvider: the provider generates the ID (ARN, URL, UUID). The user’s name field is in spec.forProvider. Most common.

NameAsIdentifier: the user’s name IS the ID. name is omitted from spec.forProvider and comes from metadata.name via an initializer. Don’t touch crossplane.io/external-name manually.

ParameterAsIdentifier: a specific field in spec.forProvider doubles as the ID. The field stays visible and changeable.

TemplatedStringAsIdentifier / FormattedIdentifierFromProvider: compound IDs built from multiple fields with a separator.

The import section in the Terraform docs tells you which one to use. The TF provider source’s d.SetId() confirms it.

This is a scratches-the-surface overview of upjet external name patterns based on a single afternoon of trial-and-error while building a Snowflake provider. I’m sure I’m missing edge cases. If you’ve hit one, let me know.

For any inquiry feel free to contact me via email hi@ditwrd.dev or through my Linkedin DMs

Have a good day!


  1. Source Code - AWS provider-upjet-aws external name configurations ↩︎ ↩︎ ↩︎

  2. Documentation - Upjet configuring a resource guide ↩︎

  3. Source Code - Snowflake TF provider user.go ↩︎ ↩︎ ↩︎