Appearance
Label-to-cloud-tag propagation
This page is the label-propagation companion to ./broker.md. The broker reference documents how a Project operator's declaration becomes a persisted ProvisionedResource and how the render-and-reconcile loop drives a Crossplane Composite Resource toward Ready. This page documents one feature that hangs off that render path: how the labels an operator has attached to the underlying tenancy Resource are projected onto the cloud-provider tags of the substrate the broker provisions.
Propagation is opt-in and observable. A label only becomes a cloud tag when the registry has explicitly marked its Definition as propagatable; a label that cannot be expressed as a tag under the target provider's rules is never silently dropped or truncated — it is returned as a structured skip record and logged. Almost every design choice on this page — the per-Definition opt-in flag, the per-provider constraint profiles, the deterministic count cap, the closed skip-reason enum — exists so the propagated tag set is a pure, reproducible function of the label set and the provider, and so an operator can always tell which labels propagated and why the rest did not.
The two pages cover one bounded context. They share the ubiquitous language, the ports, and the render pipeline; the split is editorial. Read ./broker.md first for the create pipeline — the render step, the spec.parameters injection sites, and the namespace gate are documented there and are not repeated here.
Cross-references
./broker.md— the create-pipeline half of this bounded-context reference. TheProvisionedResourceaggregate, the render-and-apply pipeline, the eight ports' create-side roles, and the namespace gate live there; this page documents only the cloud-tag additions../deletion.md— the graceful-deletion companion. The teardown state machine and theNodeDeregistrarport live there; cloud tags are a render-time concern and play no part in teardown.../labels/index.md— the Label Registry bounded context that owns theLabelDefinitionandLabelAssignmentaggregates, the scope model, and thecloud_tag_propagationflag this feature reads.../../../internal/provisioning/broker/render/tags.go— the pure cloud-tag transformer: theCloudTagPrefixconstant, the per-provider constraint profiles, the closedSkipReasonenum, theSkippedLabelrecord, and theCloudTagsfunction.../../../internal/provisioning/broker/render/xr.go— theWithCloudTagsfunctional option that stamps the transformed tag map onto the Composite Resource'sspec.cloudTags.../../../internal/provisioning/broker/reconcile/apply.go— the reconcile boundary;resolveCloudTagsresolves the label set, runs the transformer, and logs each skip.../../../internal/provisioning/broker/ports.go— theLabelTagResolverconsumer-defined port and thePropagatingLabelSetbroker-local value type.../../../internal/platform/db/migrations/0005_labels.sql— the Label Registry schema: thecloud_tag_propagationcolumn and the three seeded platform-scoped Definitions.../../../internal/platform/db/queries/40_labels.sql— theEffectiveCloudTagLabelSetquery that narrows the effective label set to propagation-opted Definitions.../../../cmd/plexsphere/provisioning_broker_factory_prod.go— the production composition root;brokerLabelTagResolveris the anti-corruption adapter that satisfies theLabelTagResolverport and decodes the registry's JSON-encoded values.
Ubiquitous language
The terms below travel together across the Go code, the SQL migration, the structured-log attributes, and this page. Names are preserved verbatim so a reader chasing a string from a log line finds it in the source without translation.
| Term | Definition | Code anchor |
|---|---|---|
| Cloud tag | A key/value pair the broker stamps onto provisioned cloud substrate. Its key is the plexsphere:-prefixed fully-qualified label key; its value is the canonical decoded label value. | render/tags.go |
PropagatingLabelSet | The broker-local projection of a tenancy Resource's propagation-filtered effective label set: a fully-qualified-key to decoded-value map. An empty set is a non-nil, zero-length map — a Resource with no propagating labels is the resolver's success case, not an error. | ports.go |
cloud_tag_propagation | The boolean column on plexsphere.label_definition. A Definition with the flag true opts its assignments into cloud-tag propagation; it defaults to false, so propagation is opt-in per Definition. | 0005_labels.sql |
LabelTagResolver | The consumer-defined anti-corruption port the broker reaches the Label Registry through. Resolve returns the PropagatingLabelSet for the tenancy Resource a ProvisionedResource references. | ports.go |
CloudTags | The pure transformer in the render layer. It maps a PropagatingLabelSet and a provider discriminator to a tag map plus a slice of skipped labels. | render/tags.go |
SkippedLabel | A record of one propagating label the transformer refused to emit as a tag — the qualified key, the candidate tag key, the provider, and the machine-readable SkipReason. | render/tags.go |
SkipReason | The closed enumeration of why a label was skipped: reserved-prefix, key-too-long, value-too-long, key-character-class, count-cap. | render/tags.go |
| tag profile | The per-provider constraint set the transformer enforces — the key and value length ceilings, the per-resource count cap, the key character class, and the reserved key prefixes. | render/tags.go |
spec.cloudTags | The map field on the rendered Composite Resource the transformed tag map is stamped onto. A Composition's FromCompositeFieldPath patch copies it onto the provider's own tag field. | render/xr.go |
The plexsphere: key format
Every cloud tag the broker emits carries a key of the form `plexsphere:` joined to the label's fully-qualified Label Registry key. The prefix is the literal constant render.CloudTagPrefix (render/tags.go), and it is the same string for every provider.
A platform-scoped label such as platform/env becomes the tag key plexsphere:platform/env. A project-scoped label whose qualified key is acme:checkout/owner becomes plexsphere:acme:checkout/owner. The tag value is the canonical decoded string — the composition-root adapter decodes the registry's JSON-encoded value into a bare string before the set reaches the transformer, so the render-time transform never sees stray JSON quotes.
The prefix exists for two reasons. It gives cost-reporting and governance a single, greppable namespace: every plexsphere-originated tag on a cloud resource can be filtered by the one plexsphere: prefix regardless of which label produced it. And it keeps plexsphere tags from colliding with operator- or provider-owned tags on the same resource — a resource may already carry tags an operator set by hand or the cloud provider set itself, and the prefix isolates the plexsphere set from both.
The per-provider constraint table
A cloud tag that is valid on one provider can be rejected by another: providers disagree on key and value length ceilings, on which characters a key may carry, on how many tags a resource may hold, and on which key namespaces they reserve for themselves. The transformer enforces a per-provider tag profile so the rendered tag set is one the target cloud will accept on apply — a candidate that would be rejected at apply time is skipped at render time instead.
The four profiles are transcribed below from render/tags.go. The provider discriminator is matched case-insensitively against the CloudView.Provider string; any provider not in the table falls back to the conservative generic profile.
| Profile | Key length | Value length | Count cap | Key character class | Reserved key prefix |
|---|---|---|---|---|---|
aws | <= 128 runes | <= 256 runes | 50 | letters, digits, space, and `+ - = . _ : / @` | `aws:` |
hetzner | <= 63 runes | <= 63 runes | 64 | letters, digits, and `- _ . : /` | none |
openstack | <= 255 bytes | <= 255 bytes | 50 | any non-control rune | none |
| generic (fallback) | <= 63 runes | <= 255 runes | 32 | letters, digits, and `- _ . : /` | none |
The ceilings are drawn from the providers' own public documentation: the AWS tag key/value restrictions in the AWS tagging guide, the Hetzner Cloud label rules in the Hetzner Cloud API reference, and the OpenStack server metadata limits in the OpenStack Compute API reference.
Two details of the table are easy to miss:
- Measurement unit. AWS, Hetzner, and the generic profile state their ceilings in characters, so the transformer counts Unicode runes. OpenStack server metadata states its ceilings in bytes, so the transformer counts raw bytes — a multi-byte UTF-8 value reaches the OpenStack ceiling sooner than its rune count alone would suggest.
- The generic fallback is deliberately the strictest profile. An unknown provider's real constraints are unknown, so the transformer refuses to guess a permissive profile: it applies a 63-rune key ceiling, the strict key character class, and a low cap of 32 tags, so an unknown provider receives only a small, conservatively-shaped tag set rather than one that might be rejected on apply.
The character class is the same strict set for Hetzner and the generic profile. It admits exactly the characters the mandated key shape needs — ., :, and / for the prefix and the layered qualified keys, plus - and _ — and nothing else. The : and / are admitted deliberately even though Hetzner Cloud label keys otherwise follow Kubernetes label syntax (which forbids :): the plexsphere: prefix is fixed for every provider and the layered qualified keys carry /, so forbidding either character would skip every plexsphere tag on Hetzner and make the feature a no-op there. The genuinely Hetzner-specific narrowing the profile still enforces over AWS is the rejection of spaces and the `+ = @` symbols.
The deterministic count cap
When more labels pass validation than the provider's per-resource cap allows, the transformer keeps a deterministic subset rather than an arbitrary one. It sorts the qualified keys in ascending order and retains the lowest keys up to the cap; every surplus label is skipped with reason count-cap.
Because the retained set is the lowest-sorting prefix of a sorted key list, the selection is a pure function of the input label set and the provider profile. A re-render over an unchanged label set therefore yields an identical tag map — the cap never thrashes between renders, and an operator inspecting the propagated tags sees a stable set. The EffectiveCloudTagLabelSet query that feeds the transformer also orders its rows by qualified_key ascending, so the deterministic order is established at the database boundary and preserved end-to-end.
The adopted-vs-provisioned boundary and scope layering
Adopted Resources never propagate
platform/origin is a seeded platform-scoped Label Definition whose value is the enum provisioned | adopted (0005_labels.sql). It marks whether a tenancy Resource was provisioned by plexsphere or adopted from an external system.
The broker only renders substrate it provisions: an adopted Resource has no ProvisionedResource aggregate and never reaches the broker's render path at all. Its labels are therefore structurally never propagated — not because a rule forbids it, but because the render path that performs propagation is never entered for an adopted Resource. Cloud-tag propagation is a property of the broker's create pipeline, and adopted substrate does not run that pipeline.
The propagation-filtered effective label set
The set of labels that can propagate is the propagation-filtered effective label set of the tenancy Resource: the union of the Platform-, Domain-, and Project-scoped label Assignments on that Resource whose parent Label Definition carries cloud_tag_propagation = true. The Label Registry layers Definitions across three scopes, and any scope can opt a Definition in — a platform operator, a Domain owner, and a Project owner can each declare a propagating Definition at their own scope.
The filter is applied at the database boundary by the EffectiveCloudTagLabelSet query (40_labels.sql), which is EffectiveLabelSet narrowed by ld.cloud_tag_propagation = true. The cloud-tag projection therefore only ever sees keys the registry has explicitly marked as propagatable.
Nothing propagates by default
Propagation is strictly opt-in per Definition. The cloud_tag_propagation column defaults to false, and the three seeded platform Definitions every installation starts with — platform/origin, platform/mesh-ip, and platform/domain — all ship with cloud_tag_propagation = false (0005_labels.sql).
The consequence is the out-of-the-box behaviour: a fresh installation propagates nothing. An operator must either create a new Definition with the flag set or update an existing user-owned Definition to opt it in before any label reaches cloud substrate as a tag. The three seeded platform Definitions are immutable and cannot be opted in.
The skip-observability contract
Every label the transformer cannot map to a cloud tag is surfaced as a render.SkippedLabel — it is never silently dropped or truncated. A SkippedLabel carries the qualified label key, the candidate (prefixed) tag key, the provider profile that rejected it, and a machine-readable SkipReason. The SkipReason enum is closed and has five values:
| Reason | Meaning |
|---|---|
reserved-prefix | The qualified key opens with a provider-reserved tag namespace (notably `aws:` on AWS). |
key-too-long | The prefixed tag key exceeds the provider's per-key length ceiling. |
value-too-long | The value exceeds the provider's per-value length ceiling. |
key-character-class | The prefixed tag key carries a character the provider does not admit in a tag key. |
count-cap | The label is valid, but the provider's per-resource tag cap was already reached by lower-sorting qualified keys. |
The reconcile boundary resolveCloudTags (reconcile/apply.go) logs exactly one structured slog line per skip, at Warn level, carrying the attributes provisioned_resource, qualified_key, tag_key, provider, and reason. The reason strings are stable and identifier-free, so they are safe on a rendered log surface an operator reads.
A resolve failure fails the tick; a skip does not
The boundary draws a sharp line between two failure modes, and the distinction is the load-bearing part of the contract. Both decisions are recorded as DECISION blocks on resolveCloudTags.
- A
LabelTagResolverresolve failure fails the whole reconcile tick. It means the Label Registry is unreachable — an infrastructure fault — so the desired Composite Resource cannot be rendered faithfully. The tick must fail and be retried. - A skip does not fail the tick. A skip is a per-label mismatch against an immutable provider constraint — a key too long, a reserved prefix, the count cap — and a retry would never resolve it. Failing the tick on a skip would wedge the entire resource on one un-mappable label. Instead the skip is surfaced as the Warn breadcrumb above and the render proceeds with the retained, well-formed tags.
The end-to-end mechanism
Propagation runs as four stages on the broker's render path: resolve, transform, stamp, and carry.
Resolve
The broker reaches the Label Registry through the consumer-defined LabelTagResolver port (ports.go). Resolve takes the whole ProvisionedResource aggregate and returns a PropagatingLabelSet for the labelable object `ObjectRef{Kind:"resource", ID: resource.ResourceID()}`. The port takes the aggregate — rather than being keyed by the broker's own ProvisionedResourceID — because labels are attached in the Label Registry to the tenancy Resource, not to the broker's aggregate; passing the aggregate lets the composition-root adapter read resource.ResourceID() itself and build the labels ObjectRef, so the broker never carries the Label Registry's ObjectRef vocabulary.
The production adapter is brokerLabelTagResolver in provisioning_broker_factory_prod.go. It is an anti-corruption layer: it wraps the Label Registry's EffectiveCloudTagLabelSet read, and it decodes each JSON-encoded registry value into a bare string before the set crosses the port seam, so the transformer never sees JSON quotes. The adapter lives at the composition root because the depguard cross-context rule forbids any internal/provisioning/** package from importing internal/labels — the composition root is the one place allowed to bridge the two bounded contexts.
Transform
render.CloudTags(provider, set) (render/tags.go) is the pure transformer. It returns (tags, skipped) — the tag map for the labels that mapped cleanly, and the slice of SkippedLabel records for the rest. Both return values are always non-nil; an empty input set yields an empty tag map and an empty skipped slice.
Stamp
render.WithCloudTags(tags) (render/xr.go) is a functional option on render.XR that writes the tag map onto the rendered Composite Resource's spec.cloudTags. An empty or nil map is a no-op: spec.cloudTags is left absent rather than written as an empty object, so a Composition's patch sees "no tags" instead of an empty map.
The tags are carried on spec.cloudTags — a map field — rather than on metadata.labels, because a cloud-tag key carries : and / (for example plexsphere:platform/env), which a Kubernetes label key forbids. A spec map key is unconstrained, so spec.cloudTags preserves the exact tag-key shape the per-provider tables require.
Carry
For spec.cloudTags to survive the API server's structural-schema pruning, the five blueprint xrd.yaml files in the catalog each declare spec.cloudTags as an object with string additionalProperties. Each blueprint's Composition then carries a FromCompositeFieldPath patch that copies spec.cloudTags verbatim onto the provider's own tag field: spec.forProvider.tags on AWS, spec.forProvider.labels on Hetzner Cloud, the server metadata field on OpenStack, and the conservative target the generic blueprint pins. A single verbatim-copy patch is the only mechanism that scales to a dynamic, operator-defined label set — the Composition does not need to know which keys exist.
The reconcile loop ties the four stages together in renderObjects (reconcile/apply.go): it calls resolveCloudTags to resolve and transform the set, then passes the result to render.XR through render.WithCloudTags. Because the resolve runs on the apply path — not in the observe step — every re-apply tick re-reads the current label set, so an operator re-labelling the tenancy Resource sees the new tags Server-Side-Applied on the next tick with no phase change needed to trigger it.