Trait Handlers
OAM Built-in Trait Handlers
Package traits implements oam.TraitHandler for most built-in trait types, plus one
oam.TraitLoweringRule (expose, see below). A trait decorates or augments a
component — adding networking, security, storage, scaling, or operational behavior.
Handlers are listed by TraitHandlers() in pkg/oam/builtin/registry and registered
with the transformer (in pkg/cmd/kurel, as by any consumer) via
RegisterBuiltinTrait(type, handler); each implements CanHandle + Apply. expose
is registered separately, via RegisterBuiltinTraitLowering (TraitLoweringRules()
in pkg/oam/builtin/registry) — it lowers into a terminal ingress or httproute trait rather
than building a resource itself, so it is never also present in the dispatchable
trait-handler map (a lowerable type and a dispatchable handler type are mutually
exclusive by construction; see the OAM model’s
Lowering section for the general mechanism). Some traits are capability-aware
(CapabilityRequired) and draw platform choices (issuer, gateway, secret store) from
the ClusterProfile — this applies to both dispatchable handlers and lowering rules.
Every built-in trait handler also implements oam.PropertySchemaProvider
(PropertySchema()), declaring a constrained schema for its user-facing properties so
the downstream runtime can validate them before invocation. This includes the platform-reserved keys a
handler reads from merged properties (e.g. networkPolicy, allowedHostnameWildcard,
controllerType, the ingress trait’s platformAnnotations). Some deeply nested or K8s-adjacent shapes are kept shallow/open
(additionalProperties) rather than modeled field-by-field, but strictness-sensitive traits are
closed: the rbac rule object
enumerates its fields and sets additionalProperties: false (unknown keys rejected), matching the
downstream single-owner adoption of these builtins. prune-protection, topology-spread and
force-replace accept no properties of their own and so declare an empty schema (the
engine-owned scope, legal on every trait, is still accepted); for prune-protection and
force-replace any other authored key is a build error. Every property (including nested object fields and
array item schemas at every depth) carries a Description, surfaced in the downstream runtime’s generated Handler
API Reference.
Every built-in trait handler, and the expose rule, implements oam.ContractDescriber
(go-kure/launcher#789): family is the trait type, version is builtin.ContractVersion
(v1alpha1), and RequiredCapabilityKeys is the trait type for the two whose
CapabilityRequired returns true (certificate, expose) and empty for the rest. The
expose rule also implements oam.LoweringTargetDeclarer: it lowers into the ingress and
httproute traits, so Transformer.Seal, which every transform runs first, refuses a
registry that holds ExposeRule without both, with registry incomplete: lowering rule trait/expose@v1alpha1 lowers into trait type "httproute", which is not registered.
Breaking library change: a consumer that registered ExposeRule with one of the two
handlers only now fails at the first transform.
Capability-injected fields are not marked Required in a handler’s schema, because
they are supplied by capability rendering (validated in ValidateAndApplyDefaults), not by
the OAM author — e.g. expose.controllerType and the parent certificate.issuerRef are
optional in the user-facing schema (though issuerRef.name stays required when issuerRef
is present). Marking a capability-injected field user-required would make a consumer’s schema
preflight reject every valid use of the trait.
Trait catalog
Networking
type | Produces | Key properties |
|---|---|---|
ingress | Ingress | rules[] (host, paths[]), ingressClassName, tls[], annotations; platformAnnotations and managedTLS are platform-reserved (see below) |
httproute | Gateway API HTTPRoute | rules[] (matches/backendRefs/filters/timeouts), hostnames[], annotations; parentRefs[] optional — synthesized from the gatewayName/gatewayNamespace capability when omitted |
expose | Ingress or HTTPRoute | rules[], hostnames[] — controller chosen by ClusterProfile (controllerType) |
networkpolicy | NetworkPolicy | ingress[]/egress[] (from/to, ports), name (optional; the policy’s name, default <component>-allow) |
cilium-networkpolicy | CiliumNetworkPolicy | name, endpointSelector (required), ingress/egress (raw Cilium rules, at least one rule between them — decoded strictly, and a field the CRD requires must be authored where its parent is, see below) |
Security
type | Produces | Key properties |
|---|---|---|
certificate | cert-manager Certificate | secretName, dnsNames[], duration, renewBefore, privateKey (algorithm/size/encoding/rotationPolicy) (issuer from ClusterProfile) |
rbac | Role/RoleBinding (+ClusterRole/Binding) | rules[] (apiGroups/resources/verbs), clusterWide, name (optional). The binding subject is the account the component’s pods run as, via oam.ServiceAccountNamer: an authored serviceAccountName, or a webservice/worker’s generated account. A pod kind (deployment, statefulset, daemonset, job, cronjob) without serviceAccountName generates no account (go-kure/launcher#702), so rbac on it is refused (rbac: component "x" runs as no ServiceAccount of its own; set serviceAccountName to the existing ServiceAccount the rules are granted to) rather than bound to an account that does not exist. A component that runs no pods keeps the component name as the subject, except a serviceaccount component given an objectName: the subject is the account under that name. The objects are named after the component unless name is authored: the one name names the Role, the RoleBinding and, with clusterWide, the ClusterRole and the ClusterRoleBinding, and is the roleRef.name of both bindings (go-kure/launcher#787; see Conventions). It names neither the subject nor the app label, which stay the component’s. The role, rolebinding, clusterrole and clusterrolebinding kind components (go-kure/launcher#790; ../components/README.md) are for the grants this trait does not write: a role several bindings share, a binding to a role that already exists, a grant to a user or a group, an aggregated ClusterRole; a kind component’s object and this trait’s of one kind and name are refused as a name collision. |
external-secret | ESO ExternalSecret (+ optional envFrom / volume mount) | secretName, data[]/dataFrom[], refreshInterval, envFrom, mountPath (store from ClusterProfile or provider) |
secret | Secret | A Secret the document carries (go-kure/launcher#786), the secret kind’s twin. name (required, a DNS-1123 subdomain), stringData (string values only), data (base64; a key may not also appear in stringData), type, immutable. No mount and no envFrom: a workload reads it by name. Every entry is emitted under data, never stringData. Not encrypted, and refusable by policy — see “The secret trait” below. |
security-context | (modifies PodSpec) | psaLevel (restricted|baseline|privileged), optional: runAsNonRoot, allowPrivilegeEscalation, readOnlyRootFilesystem, runAsUser, runAsGroup, fsGroup. Applies to the pod spec of a Deployment, StatefulSet, DaemonSet, ReplicaSet, ReplicationController, Job, CronJob, or a pod component’s Pod. On a component that generates none of these, psaLevel alone is accepted as a declaration and any other property is refused (see Pod-spec traits on a component without a workload). On a pod whose component set os.name: windows only the Windows-legal subset is written (see below). |
Storage
type | Produces | Key properties |
|---|---|---|
pvc | PersistentVolumeClaim | The persistentvolumeclaim kind’s twin: every claim field below is parsed, defaulted and built by the kind’s own code, so both build the same claim; the trait adds only the claim’s name, the owner’s app label, namespace and bundle (go-kure/launcher#741). name (a DNS-1123 subdomain), size (optional; policy default storageSize; the effective size must be a positive quantity — zero or negative fails the build, as ValidatePersistentVolumeClaimSpec would refuse the claim), storageClassName (a DNS-1123 subdomain; an authored "" requests no class, i.e. no dynamic provisioning, and is emitted as storageClassName: ""; unset or null takes the ClusterProfile pvc capability’s storageClassName, as the kind’s does (go-kure/launcher#742), else the cluster default. Pre-GA output change (go-kure/launcher#702): "" used to be treated as unset), accessModes[] (ReadWriteOncePod must be the only mode), volumeMode (optional Filesystem|Block; omitted leaves the claim’s mode unset, which the apiserver defaults to Filesystem; any other value or type fails the build), and the kind’s selector, dataSourceRef, volumeName and volumeAttributesClassName, each with the kind’s rules (go-kure/launcher#790; pkg/oam/builtin/components/README.md, the persistentvolumeclaim entry). volumeName has no policy check and no capability gate: a claim that names a PersistentVolume binds to that volume only, with no dynamic provisioning (the volume must still match the claim’s class, size, access modes and volume mode), so whoever may author the trait may ask for any PersistentVolume of the cluster by name. dataSource and a whole resources are refused with the kind’s reason for each: the trait answers the document check’s UnsupportedFieldHint with the kind’s, so the refusal of either key ends with ; <reason> in a document as it does for the kind (go-kure/launcher#790; pkg/oam/README.md, “A reason on a refused key”) (policy: maxStorageSize). Pre-GA tightening (go-kure/launcher#741): a name or storageClassName that is not a DNS-1123 subdomain, and ReadWriteOncePod combined with another mode, used to build and are now refused, as the kind refuses them; a zero or negative size is refused when the trait is applied instead of at generation |
volsync | VolSync ReplicationSource | name, sourcePVC, schedule, copyMethod, storageClassName, volumeSnapshotClassName, retain.{daily,weekly,monthly} (class fields also supplied via capability rendering; injection is copyMethod-aware) |
Configuration & scaling
type | Produces | Key properties |
|---|---|---|
configmap | ConfigMap (+ optional volume mount) | The configmap kind’s twin: data, binaryData and immutable are parsed and the ConfigMap built by the kind’s own code, so both build the same ConfigMap and refuse the same input; the trait adds only the ConfigMap’s name, the owner’s app label, namespace and bundle, and the mount (go-kure/launcher#741). name, mountPath (mounts into a Deployment, StatefulSet, DaemonSet, ReplicaSet, ReplicationController, Job, CronJob, or a pod component’s Pod; a component that generates none of them is refused at generation, see Pod-spec traits on a component without a workload), data (string values only), binaryData (base64; a key may not also appear in data), immutable. The data values and decoded binaryData values may total at most 1,048,576 bytes, the API server’s ConfigMap limit; more is refused at build time. Keys must be valid ConfigMap keys (alphanumerics, -, _, ., at most 253 characters, not . or .. or starting with ..); an invalid key is refused at build time, the first in sorted order. Pre-GA tightening (go-kure/launcher#741): a number or boolean data value used to be stringified and is now refused, as the kind refuses it; quote it. |
topology-spread | (modifies the Deployment’s PodSpec) | (no properties; an authored engine-owned scope is accepted; a capability rendering carries no keys). Stamps launcher’s default topology spread constraints — the ones webservice and worker apply from topologySpread — onto every typed Deployment the component generates (one a launcher kind builds, or one decoded from a manifests source or a helmtemplate chart render), from its post-policy spec.replicas: none at 1 replica, a hostname spread from 2, a zone spread added from 3. Refuses a Deployment that already carries constraints or whose selector is not matchLabels alone, and a component with no typed Deployment (a pod component, for one: the trait is Deployment-only); a Deployment passed through as raw, unstructured output (passthrough) is not inspected (see below). |
scaler | HorizontalPodAutoscaler (+ optional PDB) | minReplicas, maxReplicas (both optional; policy defaults scalerMinReplicas/scalerMaxReplicas, policy cap maxReplicas), cpuUtilization, memoryUtilization, enablePDB, hpaName and pdbName (optional; the objects’ names, default <component>-hpa and <component>-pdb; pdbName without enablePDB: true names no object and is refused). Admitted on webservice, worker and deployment only. On any of them with a non-RWX claim (the claims that cap the component at one replica, see the components README’s “Non-RWX volumes”), an effective maxReplicas above 1 fails the build, naming the trait and the claim: the HPA would otherwise scale the Deployment past the one pod the claim allows. |
Operational (FluxCD)
type | Effect | Key properties |
|---|---|---|
prune-protection | Sets the PruneProtection delivery intent on the component’s applications (stack.Application.Delivery): their objects stay in the cluster when they are removed from the source. Writes nothing on the objects; kure’s Flux workflow turns the intent into kustomize.toolkit.fluxcd.io/prune: disabled on each of them. | (no properties) |
force-replace | Sets the ForceReplace delivery intent: an object whose update fails on an immutable field (a job’s pod template) may be deleted and recreated. Writes nothing on the objects; kure’s Flux workflow turns the intent into kustomize.toolkit.fluxcd.io/force: enabled on each of them. Replacing a Job re-runs it and stops any run in progress. Replacing a PersistentVolumeClaim or PersistentVolume can lose its data, so each one under the intent gets a build warning. Opt-in: without the trait launcher states no intent. | (no properties) |
fluxcd-patches and fluxcd-postbuild are not built in. They set spec.patches and
spec.postBuild of the Flux Kustomization that delivers a bundle, and launcher sets no Flux
delivery field on the bundles it returns (go-kure/launcher#781; see docs/delivery-scope.md).
A document using one fails the transform with no handler for trait type "fluxcd-patches" (on component "<name>"): it configures delivery, which launcher leaves to the consumer that delivers the application; a consumer that delivers through Flux registers its own handler. Such a consumer registers its
own trait handler (RegisterTrait) and applies the result to the delivery objects it generates.
prune-protection and force-replace cover every object the component owns, by a delivery
intent rather than by an annotation (go-kure/launcher#782). Each sets its field of
stack.DeliveryIntent on the component’s application and on every sub-application its other
traits append to the bundle — the applications of pvc, configmap, secret, volsync,
certificate, ingress, httproute, rbac, scaler, networkpolicy, cilium-networkpolicy
and external-secret. Trait order does not matter: both implement oam.SubApplicationDecorator,
and the engine applies them to the component’s sub-applications in a last build step, after every
trait of every component has run. A sibling group is delivered as one application, and that
application takes each intent that any of its members has: a lowering rule that forwards the
trait to one member only still covers the whole group, its sub-applications included.
The NetworkPolicies the engine synthesizes (default-deny, inbound and egress allows) are not
covered: they belong to no component’s traits and are regenerated on every build, so pruning
them stays correct. Neither is another component’s sub-application.
The traits write nothing on the objects launcher returns. The workflow that delivers an
application reads its intent. kure’s Flux workflow annotates everything the application’s layout
holds, where it integrates a layout (CreateLayoutWithResources, IntegrateWithLayout): what
the application generates, wherever a layout augmenter places it (a helmtemplate component’s
hook groups, which its Generate returns and its augmenter moves into child layouts), what an
augmenter adds outside Generate and, as options.annotations, the configMapGenerators of
those layouts. kure refuses a set intent where it cannot map it: its Flux
GenerateFromCluster, which returns none of the application’s objects, and a workflow with no
mapping. Under prune protection each content change of a
generator-built ConfigMap leaves the old hash-named ConfigMap behind: kustomize names such a
ConfigMap with a hash of its content, so a change makes a new object, and the previous one, no
longer in the source, is kept. That is the base library’s behaviour (kure’s pkg/stack/fluxcd
maps the intent, in applyDeliveryIntents), not launcher’s; no built-in component adds a
generator. A consumer that applies launcher’s objects without such a workflow reads
Application.Delivery itself, or the two traits have no effect.
force-replace covers claims too — a component’s volumes claims and the pvc
sub-application’s (volsync generates none; it backs up an existing claim): where the
delivery workflow maps the intent, a claim whose immutable field changes is deleted and
recreated, losing its data unless its volume is retained, so Transformer.WarnForcedVolumes
(which kurel build runs) warns once per PersistentVolume and PersistentVolumeClaim under
the intent: in those conditional terms when the intent is its only reason, as force-applied
when the object also carries the Flux force annotation or its bundle sets Force
(go-kure/launcher#720; see the pkg/oam README).
Capability-aware traits
These require (or optionally use) a ClusterProfile capability, so the platform —
not the app — chooses the implementation:
expose (a
TraitLoweringRule, not a dispatchable handler — see above) →controllerType(ingress vs gateway) + gateway/ingress details. On the ingress path, expose is platform-managed for TLS: it derivesspec.tls[]from the rule hosts under a deterministic<component>-tlssecret and emits thecert-manager.io/cluster-issuerannotation from thecertManagerClusterIssuercapability field (empty ⇒ managed TLS disabled). Users do not author the TLS block on the expose trait (use the low-levelingresstrait for full TLS control), but may authorsecretNameto override just the managed secret’s name (still ingress-only, hosts stay rule-derived, and it requires the cluster-issuer capability; asecretNameon the gateway path or without managed TLS is aValidationError). This lets a component carry several expose ingress traits (distinctname/scope) each naming its own cert secret. The managed TLS entry (go-kure/launcher#787). The rule does not write the managed entry into the emittedingresstrait’stls[]: it reaches that trait in the platform-reservedmanagedTLSproperty (hosts, andsecretNamewhen authored), which the trait adds after its owntls[]entries. Its Secret’s name is resolved under theNaminghook roletls-secret(pkg/oam/README.md): the exposesecretNamewhen authored, else the hook’s answer, else<component>-tlsas before. A rendering of theingresscapability may supplymanagedTLSas well: it is consumer-supplied, never author-supplied. Authored on aningresstrait it is refused (ErrPlatformReserved). Aningresstrait’s owntls[].secretNameis used as written, and no hook is asked for it. Both paths validate user hostnames against theallowedHostnameWildcardcapability field (empty ⇒ no validation); a violation is aValidationError. Both paths accept a barehostnames: [...]shorthand whenrulesis absent, each expanding it the way its own controller expects: ingress gets one rule per host withpath: /+ the component service port and dropshostnames; gateway gets a single catch-all rule backed by the component service and keepshostnameson the route, which is where a Gateway API route matches them (supplyrulesfor finer control; both together keeprulesfor routing while all hosts are still wildcard-validated). Platform-defaultssl-redirect/force-ssl-redirectcome from thesslRedirect/forceSslRedirectcapability fields (author-overridable via the same inline properties). External-auth (oauth2-proxy): authoringallowedGroups: [...]on an ingress expose emits the nginxauth-url/auth-signin/auth-response-headersannotations from the capability’sauthURL/authSigninURL/authResponseHeaders(authSigninURLis override-able inline;authURLmust be a bare base URL).allowedGroupsmust be non-empty, and the capability must supplyauthURLor the trait is rejected. The annotations expose writes (go-kure/launcher#790). On the ingress path the rule writes up to six annotations:cert-manager.io/cluster-issuer, and undernginx.ingress.kubernetes.io/the keysssl-redirect,force-ssl-redirect,auth-url,auth-signinandauth-response-headers. It does not write them into the authoredannotations: they reach the emittedingresstrait in that trait’s platform-reservedplatformAnnotationsproperty, and the authored map is left as written. An authored annotation of one of these keys must hold the value the rule writes; another value is aValidationErroronannotations.<key>that names where the rule’s value comes from (annotation nginx.ingress.kubernetes.io/ssl-redirect is platform-managed by the expose trait and cannot be overridden: the trait writes "true" from the sslRedirect property (its own, else the capability's default); remove the annotation). Without the property or capability value that makes the rule write a key, an annotation of that key is an authored one and is left as written.platformAnnotationsis not a property ofexpose: authored there it is aValidationError, on either path. Breaking for documents: a rawssl-redirect,force-ssl-redirect,auth-url,auth-signinorauth-response-headersannotation that contradicts the trait’s value was overridden silently and is now refused. A contradictingcluster-issueralready was when the authored value was a string; one that is no string (a number, a boolean) was overridden silently and is now refused too. An authored value that is not null is compared by its text, as theingresstrait writes it:ssl-redirect: truesays the same as"true". A null states no value: it is not refused and the trait’s value is written, as before. Remove the annotation, or set the typed property (sslRedirect,forceSslRedirect,authSigninURL) to the value meant.certificate →
issuerRef(cert-manager issuer/cluster-issuer). The Secret cert-manager writes is named by the requiredsecretNameand claimed under kindSecretwith noNamingrole: a required name leaves no default for a hook to replace, and what refers to the Secret (a secret volume, aningresstrait’stls[]entry) is authored apart and would not follow a rename. A second owner of that Secret in the document (anothercertificate, or the Secret anexternal-secretproduces) is refused with both named.external-secret →
secretStoreRef(or the inlineprovidershorthand).data[]entries derive by absence: a bare- secretKey: FOOdefaultsremoteRef.keyto"<namespace>/<secretName>"andremoteRef.propertytosecretKey; author anyremoteReffield to override. Because absence is meaningful, unknown keys in an entry or itsremoteRefare rejected (naming the supported fields) rather than silently ignored. See External Secret Shorthand.The produced Secret’s name is resolved under the
Naminghook roleexternal-secret(go-kure/launcher#787,pkg/oam/README.md):targetSecretNamewhen authored, else the hook’s answer, elsesecretName. It is resolved before anything reads it, so the ExternalSecret’starget.name, theenvFromsecretRef and the secret volume below all carry it. A reference to the produced Secret the author wrote elsewhere does not follow a rename. Undertarget.creationPolicy: MergeorNoneESO creates no Secret: the name refers to one that exists apart, and is used as written, neither resolved nor claimed. The ExternalSecret itself is named by the requiredsecretNameand claimed with no role.The produced Secret is otherwise emit-only — nothing references it unless the trait is told to. Set
envFrom: trueand/ormountPath: <path>to inject it into the component’s workload (Deployment, StatefulSet, DaemonSet, ReplicaSet, ReplicationController, Job, CronJob, or apodcomponent’s Pod):envFromwholesale-injects the Secret into the first container viaenvFrom[].secretRef, andmountPathmounts it as a volume on the first container at that path. Both may be set together. On a component that generates no such workload either is refused at generation (see Pod-spec traits on a component without a workload).envFromcannot be combined with the top-levelremoteRefshorthand: the shorthand derives its singledata[]entry’ssecretKeyfromsecretName, which is a Secret name, not a valid environment variable name — author explicitdata[]entries with their ownsecretKeyvalues instead. WhenenvFromis set, every authoreddata[].secretKey(and anytarget.template.datakey) must satisfy Kubernetes’IsEnvVarName, since it becomes an env var name in the container;dataFrom[]keys are exempt from this check because they are extract/find queries resolved by ESO at runtime, so the keys they ultimately produce aren’t known at render time. WhenmountPathis set, the produced Secret’s name (as resolved, below) must be a valid DNS-1123 label, because it becomes the injected volume’s name; a dotted or otherwise non-label-safe name is rejected at render time rather than producing an invalid Volume. AmountPathalready used by another decorator’s volume (e.g.configmap) is also rejected at render time — even when the two volumes have different names, Kubernetes requires everyVolumeMount.mountPathin a container to be unique. The same applies, for bothexternal-secretandconfigmap, to a path the workload’s main container already uses as thedevicePathof a raw block volume (volumeMode: Block): Kubernetes refuses adevicePaththat is also amountPathin the same container. Both traits also refuse a volume name the main container already attaches as a raw block device — astatefulsetBlock claim template has no pod volume of its own, so the ordinary volume-name check would miss it — because Kubernetes refuses a volume listed under bothvolumeMountsandvolumeDevices. A filesystem claim template has no pod volume either, only a main-container mount, so both traits refuse a volume named like one too: the StatefulSet controller replaces a pod volume named like a claim template with the claim, and the ConfigMap or Secret would never be mounted.
Ingress implicit backend ports
An ingress path with no backend, or with a backend naming the component’s own
Service, routes to the component’s implicit backend: the one Service port the component
exposes. A path may address it by number (port) or by name (portName), and either
must be that port. Any other number is refused (cannot route implicit backend to port N), and so is any other name (cannot route implicit backend to port "name"), at build
time instead of building an Ingress whose backend port cannot resolve
(go-kure/launcher#545). The name each kind’s Service gives that port:
| Component kind | Implicit backend port name |
|---|---|
webservice | http |
service | the first port’s own name (so a later port’s name is refused too) |
A backend naming a different Service is explicit and its port/portName is not
checked. A component that exposes no Service port — deployment, statefulset and
daemonset (which dropped port and its Service in go-kure/launcher#690), or another
kind that generates no Service such as helmrelease — has no implicit
backend unless the trait sets servicePort (and optionally serviceName); that
trait-level port carries no name, so a portName is not checked against it. A
port-less headless service (clusterIP: None with no ports,
go-kure/launcher#690) is the exception: its Service knows it has no port, so a
trait-level servicePort on it is refused (servicePort may not be set on component "db": its Service has no ports to route to) rather than routed to a
port the Service lacks.
NetworkPolicy nulls: null, empty and absent
In a networkpolicy peer (ingress[].from[] / egress[].to[]), podSelector,
namespaceSelector and ipBlock are optional objects where absent and empty are
opposite answers, because that is what networking.k8s.io/v1 means by them: an
empty namespaceSelector ({}) matches every namespace, while an absent
one does not constrain namespaces at all — which, for a peer that also sets
podSelector, leaves it scoped to the policy’s own namespace (k8s.io/api
networking/v1/types.go:199-222).
The “own namespace” reading belongs to that pairing, not to the null on its own. A
peer with no selector and no ipBlock is not a narrow peer: it names no peer
at all, and the type’s own summary is that “only certain combinations of fields
are allowed” (types.go:197-198). The API server refuses it, so the parser does
too (see Peer shape below). Read a null selector as absence first,
then ask what the peer has left.
An explicit null is absence, following the contract oam.IsNullValue carries
(see pkg/oam) — so
namespaceSelector: with no value is the same as omitting the key, and never the
same as namespaceSelector: {}:
This holds for a typed nil too — an uninitialized Go map from a lowering rule
that constructs peer properties directly, which a plain type assertion would accept
as an authored empty object and silently widen to every namespace
(go-kure/launcher#430). A null ipBlock is likewise absent rather than an
ipBlock: 'cidr' is required error; a present ipBlock still requires cidr.
A value of the wrong type is the third answer, and it is an error rather than a
silent drop — namespaceSelector: prod, or a matchLabels that is not a mapping,
is rejected as …: expected object, got string. Discarding it left the selector
allocated with no labels, and an empty selector matches every namespace, so a
malformed constraint used to widen the peer to the maximum at render time.
An unrecognized key is the same answer for the same reason, at every object
depth this parser reads: the rule (from/to, ports), the peer
(podSelector, namespaceSelector, ipBlock), the selector (matchLabels),
the ipBlock (cidr, except) and a ports item (port, protocol). Each is
rejected as …: unsupported key "…", naming the lexicographically first offender
so the diagnostic is the same on every run:
matchExpressions is the case worth spelling out: it is a real
metav1.LabelSelector field, so a document written against Kubernetes’ own schema
used to parse into a selector with no labels — which matches every namespace.
A constraint this parser cannot honour must fail, never widen. endPort on a
ports item is the same case: a real NetworkPolicyPort field this parser does
not implement, which accepted silently would render a single port where a range
was authored.
The trait’s own top-level property map — ingress and egress — is closed one
layer further out, by the engine’s schema check rather than by this parser, so a
typo’d egres is reported as unsupported field "egres" rather than dropped along
with the whole direction it carried. kurel build runs that check over the
authored document before transform (pkg/cmd/kurel/build.go), and the lowering
fixpoint runs it over every emitted trait (pkg/oam/lowering.go). A consumer that
calls NetworkPolicyHandler.Apply directly, without either, is the gap tracked in
go-kure/launcher#394.
The same applies one depth further down, to a matchLabels value. env: with
no value is rejected as …matchLabels: "env" has no value, and a composite
value — a mapping or a list — as …matchLabels: "env" must be a string, number or boolean, got …. Both used to reach %v and render <nil>, map[a:1] or [x y]
into a label the API server then refuses, one layer away from the cause. A scalar
still renders as before — port: 8080 is the label value "8080", and booleans
and every numeric kind a YAML or JSON decoder produces render the same way — but
the result must then pass the content check described under “Label and CIDR
content” below.
The peer envelope itself is the one place in this section where a null is an error, not absence:
A peer sits in a list, so “absent” has no meaning for it — dropping the element
would silently shrink the rule. This is what an untyped nil in that position
always did; the typed nil now agrees with it instead of being accepted as a peer
that names no source at all. An authored - {} is present but names no peer
either, and is rejected by the shape rule under Peer shape.
The same holds one level up, for an element of the ingress/egress rule
list, where it matters more: a rule with neither from/to nor ports matches
all sources on all ports, so a silently-accepted null rule widens the
policy to allow-all rather than narrowing it.
A rule’s from/to and ports are read the same way, and for the same reason:
each is a constraint, so a mistyped one used to be discarded and leave the
rule matching everything on that axis. A null is absence (an absent from is
the authored allow-all), an authored [] is that same value written down, and a
wrong-typed value is an error:
Parsing stops at the first rule that fails, so this document reports the
ingress[1] error and never reaches ingress[2]; the index in each diagnostic is
the offending rule’s own position in the list it was authored in.
ipBlock.except behaves the same, one level down, and is the clearest case of the
class: except is an exclusion, so a dropped one renders a block strictly
wider than the document authored. A mistyped cidr now reports itself as mistyped
(…ipBlock.cidr: expected string, got int) instead of as missing.
A ports item is the last depth. port is an int-or-string union, published
as Types: [integer, string] (go-kure/launcher#383) like the rolling-update
maxUnavailable/maxSurge knobs of the workload components, so a value of any
other type — port: true — is rejected by property validation before the
parser sees it. It is a declared property (not left to
AdditionalProperties), so the schema closes this key set too and a protcol
typo is rejected before ever reaching the parser (go-kure/launcher#440 round
3). Both of its fields carry the same rule as everything above:
A wrong-typed protocol was discarded, and an absent protocol means TCP, so
protocol: [UDP] rendered a policy permitting TCP and denying the UDP the document
asked for. A numeric port went through a bare int32(…) conversion, which
truncates a fractional value and is implementation-defined outside int32 range:
80.9 rendered port 80, and 4294967376 also rendered port 80. A null or absent
protocol still means TCP, and every integer, float, or string kind a decoder
or a lowering rule can produce is accepted — including a named Go numeric or
string type, matching matchLabels’ reach (go-kure/launcher#440 rounds 2-3):
a named-port string and a protocol assembled as a corev1.Protocol both
parse the same as their builtin-typed equivalents.
Label and CIDR content
Everything above checks type and nullity. A well-typed value is also checked for content, against the validators the API server applies to a NetworkPolicy peer’s labels and CIDRs, so a label or CIDR that renders is one the cluster admits (go-kure/launcher#469). The combination of fields a peer sets is a separate check, under Peer shape. The content rules:
- A
matchLabelskey must be a qualified name (app.kubernetes.io/name), and the rendered value a valid label value: empty, or at most 63 characters of alphanumerics,-,_and., starting and ending with an alphanumeric. A number is checked as rendered, so1000000.0("1e+06"),-1and.inf("+Inf") are rejected, and the diagnostic names the type they were rendered from. ipBlock.cidrand eachexceptentry must be a CIDR, and each exception a strict subset ofcidr: inside it, with a longer prefix, in the same address family. The check follows the pinned Kubernetes minor, where strict CIDR validation is on by default (it is from 1.36), so10.0.0.1/8(bits set past the prefix),010.0.0.0/8and an IPv4-mapped IPv6 CIDR are rejected as well. Non-canonical IPv6 text such as upper-case hex is still accepted, as it is upstream.
Each of these used to render, and fail only when the manifest was applied.
Peer shape
A peer must name something, and an ipBlock stands alone. These are the two
structural rules the API server applies to a peer (ValidateNetworkPolicyPeer),
and the parser applies them in the same words, after treating a null
podSelector, namespaceSelector or ipBlock as absent (go-kure/launcher#470).
Each row is shown as if authored on its own:
An empty selector ({}) counts as set, so - podSelector: {} is a peer. A null
selector does not, so an ipBlock beside a podSelector: with no value is still
legal. Each rejected shape is one where every value is legal on its own, so the
per-field checks above could not see it; both used to render and fail only when
the manifest was applied.
Null ingress / egress
The trait’s two top-level rule keys are optional individually and required
jointly — at least one of 'ingress' or 'egress' must be specified. A null reads
as absence before that requirement is evaluated, so it cannot satisfy it:
An authored empty list is a different value and still satisfies the requirement:
ingress: [] means “select this component’s pods and permit no ingress”, which is a
default-deny somebody asked for. A typed nil ingress — an uninitialized Go
slice from a lowering rule that builds trait properties directly — used to produce
exactly that policy without anyone asking, because it satisfied the []any
assertion with ok=true and a nil slice. An authored ingress: with no value
never reached that point: it failed the same assertion and reported 'ingress' must be an array, a mistyped-key diagnostic for a key that is absent. The two shapes now
agree, and a genuinely non-null value of the wrong type keeps that
'ingress' must be an array diagnostic for itself.
policyTypes follows key presence
The rendered spec.policyTypes lists a direction when its key is present in the
document, not when it has rules. In networking.k8s.io/v1 a set policyTypes is
authoritative: a direction missing from it is not isolated at all. Per direction:
| document | policyTypes | rules for that direction |
|---|---|---|
| key absent | not listed | none |
| key null | not listed (null is absence, above) | none |
key [] | listed | none — deny all for that direction |
| key with rules | listed | the rules |
This used to follow rule count, so the document above rendered policyTypes: [Egress] and left ingress completely open — the opposite of what ingress: []
says. A single ingress: [] with no egress hid the defect: it rendered no
policyTypes at all, and the API server defaults that to Ingress. It now renders
policyTypes: [Ingress] explicitly, which enforces the same thing. The same
defaulting made a single egress: [] isolate ingress and leave egress open; it
now renders policyTypes: [Egress] (go-kure/launcher#467).
Auto-synthesized NetworkPolicy
Every integer property is read through oam.IntegerValue, so any Go integer kind
(a plain int32/int64 or an unsigned kind from a lowering rule or a Go caller)
renders the same as the int a YAML literal decodes to. The value is then
range-checked against its target and refused if it does not fit, never truncated or
wrapped. For routing ports this covers the trait-level servicePort, an ingress
rules[].paths[].port, and an httproute rules[].backendRefs[].port: each must be
1–65535, and an error names the field. A present-but-invalid path or backendRef
port used to fall back to the component’s port (ingress) or render as-is
(httproute); it is now an error. externalAuth.forwardBody.maxSize must fit uint16.
Routing traits (ingress/httproute/expose) can surface platform-reserved
networkPolicy.trafficSources, which the OAM layer collects to synthesize a
matching NetworkPolicy (see pkg/oam/netpol).
When a routing trait’s backendRefs (httproute) or path backend (ingress) names a separate
in-bundle backend Service rather than the exposing component’s own, the synthesized
{comp}-allow-ingress-traffic allow is retargeted onto the backend component’s pods + the
backendRef port — so router→backend traffic is allowed under a namespace default-deny. The backend
Service name is resolved to a sibling OAM component in the same bundle (a component’s Service name
is its BackendServiceName() when it declares one, else its component name); a backendRef that
resolves to no in-bundle component is left authored. Resolution assumes the sibling’s Service
port equals its container port, which holds for all builtin components (e.g. webservice sets
TargetPort == Port).
The same holds for a trait-level serviceName (with servicePort) that names a Service the
routing component does not own: every path/backendRef that names no backend of its own routes there,
and synthesis treats it as an external backend. The allow is retargeted onto the component that owns
that Service, on servicePort, and the routing component gets none. When no component owns it, it
takes the bare-external-Service path below and — since a trait-level Service carries no
backendSelector — stays authored. “Own” is decided from the component alone (its
BackendServiceName(), else its component name), so servicePort without serviceName, or a
serviceName equal to the component’s own Service, keeps the allow on the routing component.
A backend that names a bare external Service (no owning OAM component in the bundle) cannot be
resolved to a selector by name. To synthesize an allow for it, add an explicit authorable
backendSelector (matchLabels only) beside the backend reference —
rules[].paths[].backendSelector (ingress/expose) or rules[].backendRefs[].backendSelector
(httproute):
This emits a {service}-allow-ingress-traffic policy in the router’s namespace selecting the
backend’s pods on the backend ports. Without a backendSelector, an external backend stays authored
(no selector is ever inferred from the Service name). A backendSelector on a self/implicit backend
is rejected (it could never take effect), and a backendSelector on a ref that resolves to a
sibling component is ignored (component-label retargeting wins). Same-namespace only.
Null trafficSources and matchLabels
networkPolicy.trafficSources is required once networkPolicy is present, and an
authored trafficSources: [] is the deliberate way to switch synthesis off. A null
is not []: it is rejected (expected array, got null), whatever its Go shape. A
typed nil list from a lowering rule used to take the [] opt-out path and disable
synthesis with no diagnostic, where an authored null was already an error
(go-kure/launcher#468).
matchLabels is likewise required in every matchLabels-only selector parsed here — a
traffic source’s podSelector and a routing trait’s backendSelector. A null
matchLabels is rejected (expected object, got null). In a podSelector a typed nil
map used to pass the presence check and render an empty selector, which matches every
pod; a backendSelector already refused it, as an empty matchLabels.
Extending
Custom traits implement oam.TraitHandler (CanHandle + Apply), optionally
CapabilityAware + ValidateAndApplyDefaults for capability validation. A trait that
needs to expand into one or more OTHER traits (or components/policies) before any
handler runs — rather than building a resource itself — implements
oam.TraitLoweringRule (TraitType + LowerTrait) instead, registered via
RegisterTraitLowering; expose (expose_rule.go, above) is the built-in example.
See the OAM model’s Lowering
section for the full mechanism (registration, the fixpoint, and
PropertySchemaProvider/CapabilityAware enforcement). Work a lowering rule must
do after the environment policy, which no author should write, is not a trait: the
rule attaches a post-policy step to the component it emits
(oam.Component.AfterPolicy, in the same Lowering section).
See pkg.go.dev
for the full config-field reference, the OAM model
for the interfaces, and examples/ for runnable applications.
Pod-spec traits on a component without a workload
Three traits write to a pod spec: security-context, a configmap with a
mountPath, and an external-secret with envFrom or a mountPath. All three
read one list of workloads (workloadPodSpec, workload_target.go): a
Deployment, StatefulSet, DaemonSet, ReplicaSet, ReplicationController, Job,
CronJob or Pod, as a typed object. That is one a launcher kind builds, a
manifests source yields or a helmtemplate chart renders.
A component may generate none of these: a service, a helmrelease (Helm
creates the pods in the cluster), a custom resource a controller turns into
pods, a podtemplate (stored, never run), a ReplicationController without a
template, and every passthrough object. A Deployment passed through is one
of them too: passthrough emits its object as written, unstructured, and no
trait looks into it.
On such a component (go-kure/launcher#794, item 14):
- A
configmaptrait’smountPathand anexternal-secrettrait’senvFromandmountPathare refused at generation. security-contextis refused when it carries any ofrunAsNonRoot,allowPrivilegeEscalation,readOnlyRootFilesystem,runAsUser,runAsGrouporfsGroup: each is a write to a pod spec, and there is none.security-contextwithpsaLevelalone is accepted and writes nothing.psaLevelon such a component is a declaration only: the build writes no security context for it, and whoever applies admission labels reads the level from the document.
The refusal names the trait, the component and the properties that nothing would apply, for example:
A component that generates a workload next to other objects (a manifests
source holding a Deployment and a Service, a chart) is not refused. Each trait
applies to every workload it reads and leaves the component’s other objects as
they are, an object it does not read included.
Pre-GA tightening (go-kure/launcher#794, item 14): a document that set one
of the six pod-spec properties of security-context on a component without a
workload used to build, with the property applied to nothing, and is now
refused. psaLevel alone builds as before. The two mount refusals existed
already; only their wording changed.
The security-context trait on a Windows pod
security-context writes its profile at Generate time, after the component
has already produced the pod template, so it is the last writer of every
SecurityContext in the pod. When that template declares os.name: windows
(the pod-level os property on any workload kind), Kubernetes rejects a pod
carrying seccompProfile, capabilities, allowPrivilegeEscalation,
readOnlyRootFilesystem, runAsUser, runAsGroup or fsGroup, so the trait
writes only what Windows accepts: a non-nil pod and container context carrying
runAsNonRoot alone. The context stays non-nil so a downstream nil-only
backfill still skips it. The component’s own os validation cannot cover this
— it runs before the decorator — and upstream Pod Security Admission skips the
same Linux-only controls for Windows pods. An override the author set
explicitly that Windows forbids is an error rather than a silent drop, so a
Windows workload asking for fsGroup fails at Generate instead of emitting a
manifest the API server refuses.
The topology-spread trait
topology-spread makes launcher’s default spread opinion available on a kind
that carries none of its own. webservice and worker apply that opinion from
their topologySpread property; deployment deliberately does not, and until
this trait the only way to spread a deployment was to author the raw
topologySpreadConstraints. The trait and the two role kinds share one
definition, components.BuildTopologySpreadConstraints, so the output is
identical. worker and webservice go further and apply their opinion through
this very trait: their component lowering rules (components.WorkerRule and
components.WebserviceRule, go-kure/launcher#280) emit a deployment and,
unless topologySpread: false, attach a synthesized topology-spread in front
of its authored traits, so it is the innermost decorator — where the former
handlers applied the constraints. An authored topology-spread on either
therefore meets the “no merging” rule below exactly as it did before.
| effective replicas | constraints |
|---|---|
| 1 (or unset) | none |
| 2 | kubernetes.io/hostname, maxSkew: 1, DoNotSchedule |
| 3 or more | the above, plus topology.kubernetes.io/zone, maxSkew: 1, ScheduleAnyway |
Each constraint’s labelSelector is a copy of the Deployment’s own
spec.selector.matchLabels (app: <component> for deployment, webservice
and worker; whatever the manifest declares for a manifests Deployment).
The replica count is the one the generated Deployment carries, which is the
count after the environment policy ran: the transformer applies the policy
to the component before any trait, and the decorator reads the object at
Generate time. A document that authors no replicas under a policy
defaulting to 3 therefore gets both constraints, the same as a worker would.
A scaler HPA does not change the count the trait sees, as it does not for the
role kinds.
The trait is strict in five ways, each an error at build time:
- No properties. Any key under the trait is refused by name, whether
authored or merged in from a
ClusterProfilecapability rendering. The exception is the engine-owned keys every trait accepts when authored — todayscope, which selects thetopology-spread.<scope>capability binding: they are read by the transform engine, not by the trait, and are let through. - No rendering. A
topology-spreadcapability in theClusterProfilecarries no rendering: any key there,scopeincluded, fails profile evaluation, naming the key and the capability. A renderedscopeis merged in after the engine chose the binding, so it could select nothing. - No merging. A Deployment that already has
topologySpreadConstraintsis refused rather than merged, whatever put them there: the raw property ondeployment, or thetopologySpreaddefault ofwebservice/workerat 2 or more replicas. SettopologySpread: falseon a role kind to use the trait instead. At one replica the role kinds produce no constraints, so there is nothing to conflict with. - A typed Deployment is required. The trait acts on the typed Deployment
objects a component’s
Generatereturns: the onedeployment,webserviceorworkerbuilds, and anyapps/v1Deployment in amanifestssource or in the render of ahelmtemplatechart, which is decoded into that type. A component whose output contains none (statefulset,daemonset, amanifestssource or a chart without a Deployment, …) fails, rather than carrying a trait that does nothing. A Deployment passed through as raw, unstructured output — apassthroughobject — is not inspected, as for the other Deployment-decorating traits, so such a component fails the same way. - A
matchLabelsselector is required. A Deployment whose selector is missing, has nomatchLabels, or also carriesmatchExpressionsis refused, since the spread selector could not select exactly its pods. The check runs at every replica count, including 1 and unset where no constraint would be written, so a document does not build in one environment and fail in another whose policy raises the count.deployment,webserviceandworkeralways select onapp: <component>; amanifestsDeployment can declare any selector, and is refused when it does not meet this rule.
Decorator forwarding for layout-augmenting components
A component config that also implements kure’s layout.LayoutAugmenter (e.g. any helmtemplate)
can carry any trait. wrapIfAugmenter
(decorator.go) is the shared construction-site helper every trait decorator calls: if the
wrapped inner config implements layout.LayoutAugmenter, it returns an augmentingDecorator
wrapping the trait-specific decorator instead of the plain one, so the wrapper itself also
satisfies layout.LayoutAugmenter and forwards AugmentLayout straight through to the inner
config. Without this forward, kure’s layout walker — which keys a structural decision off
layout.LayoutAugmenter’s mere presence on the concrete config it walks — would never see the
capability on a decorated (trait-carrying) config, silently losing the augmenter’s layout-level
effect (the hook-group repartitioning) the moment any trait is added.
An inner config that also implements kure’s layout.LayoutIntentAugmenter keeps that too:
wrapIfAugmenter then returns an intentAugmentingDecorator, which forwards WantsOwnLayout.
The walker treats an absent WantsOwnLayout as “wants its own layout”, so without the forward an
augmenter that answers false (stay in the parent’s layout) would get a child layout as soon as a
trait decorated it. The method is present only when the inner has it, like AugmentLayout. No
built-in component config implements LayoutIntentAugmenter; this keeps a registered handler’s
config, or a future built-in’s, placed where it asks.
The forward is all augmentingDecorator does there. A decorator’s own processing never sees the
resources the augmenter adds: those are created inside AugmentLayout, after every decorator’s
Generate has returned. No decorator needs to: configmap, external-secret,
security-context and topology-spread rewrite the workload their inner Generate returns and
have nothing to do for an augmenter-added resource. prune-protection and force-replace, which
did annotate those resources through a post-augment hook, are no longer decorators: they set a
delivery intent on the application, and the delivering workflow covers what the layout holds
(go-kure/launcher#782). The hook is removed with them.
Every trait decorator also embeds decoratorBase, which forwards the optional
interfaces a component config may implement — stack.Validator,
fluxNamespaceSettable, fluxNamespaceReader, servicePortProvider,
serviceBackendNamer, servicePortNamer, oam.ServiceAccountNamer, nonRWXClaimer,
serviceRoutingTargeter, podTemplateLabeler, identityPortMapper and oam.ComponentNamed —
so a decorated config keeps answering them. The oam.ComponentNamed forward keeps a decorated
config attributed to its component (see “Component attribution” below). The NetworkPolicy
synthesis collectors of the routing traits are deliberately not forwarded: they are contracts of
trait sub-applications, which no built-in decorator wraps, and
forwarding them would make every decorated component look like a router. The ServiceAccountNamer forward is what keeps the
rbac row above true once a second trait is present: without it a workload
that authored serviceAccountName would stop reporting its account, and that
it runs pods, as soon as any trait wrapped it, and rbac would silently bind
the component name instead. A config that is not a ServiceAccountNamer
forwards ("", false): no account, and no pods. The nonRWXClaimer forward does the same for the scaler row: a
decorating trait declared before scaler must not hide the claim that caps
maxReplicas at 1. The serviceRoutingTargeter forward keeps a decorated service
component’s synthesized ingress allow on its selector pods rather than on the component
label, which none of its pods carry. The podTemplateLabeler and identityPortMapper forwards
keep a sibling group able to tell a service member routing to its own deployment member’s pods
(see the sibling group section of pkg/oam/README.md) when a trait wraps either member. The servicePortNamer forward keeps an ingress path’s
portName on a decorated component held to its Service port’s name (the first port, on a
service component), the same rule a port number is held to. A config that implements none of them gets the zero
answer (nil, 0, "", false), which every reader treats as “not set”.
augmentingDecorator also forwards oam.LayoutAugmentationCoverage’s
GenerateCoversAugmentLayout() bool — the interface kurel build’s guard consults before
rejecting a LayoutAugmenter config it cannot walk (see the OAM model
README). Unlike the AugmentLayout
forward, this one is unconditional: it type-asserts the inner augmenter to
oam.LayoutAugmentationCoverage and returns false if that assertion fails, rather than omitting
the method. AugmentLayout’s forward is conditional because kure’s walker treats the method’s
presence as a structural signal; GenerateCoversAugmentLayout’s presence carries no such
meaning, and the guard already treats “method absent” and “method returns false” identically —
so a conditional forward here would only reintroduce the exact silent-loss failure mode the method
exists to prevent, this time for any trait-decorated delivery: template component (e.g. one
carrying security-context), which would otherwise be wrongly rejected by kurel build.
Trait objects under a Flux namespace
Under TransformContext.FluxNamespace a trait’s objects stay in the application namespace,
except a configmap trait’s ConfigMap, a secret trait’s Secret, or the Secret an external-secret trait’s ExternalSecret
or a certificate trait’s Certificate writes, when the component’s own Flux object reads it by
name from the namespace that object moves to: a helmrelease’s valuesFrom, a helmrepository’s
secretRef or certSecretRef and the like. That one moves with it — the ExternalSecret or
Certificate moves, and with it the Secret it writes; the remote key an ExternalSecret defaults from
the application namespace does not change (go-kure/launcher#740). The default
ClusterSecretStore serves it there unchanged; a namespaced store (secretStoreRef.kind: SecretStore) is resolved in the ExternalSecret’s own namespace, so a store of that name must
exist in the Flux namespace too. Likewise a Certificate’s ClusterIssuer serves it anywhere, while
a namespaced Issuer must exist in the Flux namespace. The transform cannot see which stores or
issuers a cluster has, so it does not check this. The four sub-application
configs name their object through FluxNamespaceInput() (kind, name string); the component configs
report what they read through FluxNamespaceReads(), which every decorator forwards. See the
pkg/oam README for the full rule.
The helm component’s valuesMode: configMap relies on this: its rule appends a synthesized
configmap trait holding the values to the helmrelease it lowers to, and names that ConfigMap
in valuesFrom, so it follows the HelmRelease (go-kure/launcher#702). Synthesized, the trait goes
through the same configmap checks as an authored one (key validity, the size limit), and its
ConfigMap is emitted after the HelmRelease. Its secretValues works the same way through a
synthesized secret trait (go-kure/launcher#786), appended after the configmap one.
The secret trait
secret (go-kure/launcher#786) emits one Secret, owned by the component: its app label, the
application namespace (or the Flux namespace, when the component’s Flux object reads it by name,
see above), and the component’s pruning and replacement traits.
stringDatavalues are strings anddatavalues base64; both are emitted underdata, which is how the API stores them, so a manifest diff shows no plain text. A key in both is refused: the API server would letstringDatawin silently.nameis the Secret’smetadata.nameand must be a DNS-1123 subdomain; anything else is refused when the trait is applied.- Keys must be valid Secret keys (the ConfigMap key rule), and the decoded values may total at most 1,048,576 bytes, the API server’s Secret limit.
typeis the Secret type, emitted as written (unset isOpaqueon the cluster); the trait does not check that the keys suit the type.immutableis emitted as written.- No refusal repeats a value: a message names the key and the type of what is wrong, and a base64 failure is reported without the decoder’s text.
Security note. The Secret is in the build output in clear form; base64 is an encoding, not
encryption. The output is therefore as sensitive as the input document, wherever it is written.
Encrypting it is left to the consumer. To keep a value out of both, use external-secret, or
reference a Secret created out of band.
Policy. A policy may forbid explicit secrets through the optional interface
oam.ExplicitSecretPolicy (AllowExplicitSecrets() bool). The trait’s sub-application asks it
in ApplyPolicy and, on false, fails the transform with a ViolationError naming the
component, of class oam.RefusalExplicitSecret: secret "creds": the environment policy forbids explicit secrets; reference a Secret created out of band instead. The default is to allow: a policy that does not implement the
interface, NoopPolicy included, permits the trait. A consumer that must forbid Secrets in
documents has to implement it. The secret kind component, the helm component’s
secretValues and the helmtemplate kind’s are held to the same answer, and so is a core
Secret the passthrough or manifests component carries.
The secret kind’s twin. components.ParseSecretProperties and
components.GenerateSecret are the parse and generate pair, placed in the components package:
the secret kind component (go-kure/launcher#790) runs the same two, as the configmap kind
and trait share theirs. The trait’s schema is the kind’s plus name, so the same properties
give the same Secret and the same refusals on both paths; only the ownership fields differ (the
name, the labels, the namespace, the bundle). The trait names its Secret under no role and
claims the name, so a secret component and a secret trait that name one Secret are refused
in Transform, with both named.
Component attribution
Every trait sub-app config exposes the OAM component it was emitted for via
ComponentName() string (the oam.ComponentNamed interface) — always the component
name, never the sub-app or K8s Service name. Consumers use it to stamp per-resource
provenance (the derived <domain>/component label) without re-deriving the component
from sub-app names, which several handlers author from properties rather than
<component>-<suffix>. prune-protection and force-replace leave a sub-app’s config as it
is (they set a delivery intent on its application), so it answers directly. The routing traits’ existing TargetComponentName() (used by
auto-NetworkPolicy synthesis) delegates to the same accessor; auto-synthesized
NetworkPolicies target that <domain>/component label by default (domain from
TransformContext.Domain, library default gokure.dev;
TransformContext.ComponentLabelKey-overridable). The accessor returns the raw name;
the label and selector value is oam.ComponentLabelValue of it, which differs from
the name only past 63 characters (see Conventions). The transform puts that label on
every object a trait’s sub-application generates, with its component’s value
(go-kure/launcher#788); after Transform the sub-application’s config is the ownership
wrapper, so a caller reaches a trait config’s concrete type through oam.UnwrapConfig.
The object kind policy
A trait’s objects are held to the object kind policy (oam.ObjectKindPolicy,
go-kure/launcher#922) like every other producer’s: the ownership wrapper checks each
object its sub-application generates and each object its layout augmenter adds. A policy
whose allowed kinds are listed and leave out Role or RoleBinding (an empty list allows
every kind; a Kind of * matches its whole group), or whose forbidden kinds match either,
refuses rbac, and one that disallows cluster-scoped objects refuses rbac with
clusterWide (its ClusterRole and
ClusterRoleBinding); ingress, httproute, certificate, scaler, the network policy
traits and the rest are held to the kinds they write the same way. A refusal names the
owning component, class object-kind. A policy without the interface changes nothing.
The rules: pkg/oam/README.md and docs/delivery-scope.md §5.4.
Raw Cilium rules are decoded strictly
cilium-networkpolicy passes endpointSelector, ingress and egress through to
Cilium’s api.Rule as opaque shapes, so the trait schema cannot validate them. They are
decoded with DisallowUnknownFields, through the shared builtin.DecodeStrictJSON: a property the linked Cilium API version does not
recognise makes the build fail, naming the rejected field.
This is deliberate. Lenient decoding silently widened policies whenever Cilium removed
an API field — v1.20 dropped kafka, l7proto and l7 from api.L7Rules, so a rule
carrying them would have rendered as L4-only with no error, producing a policy more
permissive than authored. Failing the build is the safe direction for a policy generator.
Practical consequence: a package pinned to rule shapes from an older Cilium release will fail to build after a Cilium major bump rather than quietly losing its L7 constraints. Rewrite the affected rules to the shapes the new API supports.
Selectors are checked too. encoding/json does not propagate DisallowUnknownFields
into a type that defines its own UnmarshalJSON, and Cilium’s EndpointSelector is one: it
drops a key it does not know. A selector whose only key is misspelt (matchLabel for
matchLabels) would then build as the empty selector, which matches every endpoint of the
namespace. The trait therefore refuses an unknown key inside a selector by its path, for
example unknown field "ingress[0].fromEndpoints[1].matchLabel", at every position of the
three properties the trait takes (endpointSelector, ingress, egress) that holds one:
endpointSelector, fromEndpoints, toEndpoints, fromNodes, toNodes and a CIDR
entry’s cidrGroupSelector. The same check covers the one other such type those properties
reach, an ICMP field (icmps[].fields[], which declares family and type). The
positions are found from the Cilium types by builtin.UnknownCiliumKeyPath, which knows
every type under a Cilium rule that decodes itself, a rule label and the deny rules
included; the trait takes neither. A test fails when a Cilium bump adds such a type and it
is not checked.
Breaking: a cilium-networkpolicy trait whose selector holds an unknown key built
before, with a selector wider than written, and is now refused.
An ICMP field needs its type. Cilium’s own decoding of icmps[].fields[] panics when
type is absent or null. The build refuses such a rule with an error that names the Cilium
rule type and the panic (the decoder of api.Rule panicked on this value: …); it does not
name the field’s position, so look for an icmps field without a type. Before this the
build crashed on that document.
Required fields. Cilium’s rule type writes some fields the CiliumNetworkPolicy CRD
requires whether or not they were authored, so a rule that left one out built, and its
policy carried a value the document does not hold: a match expression without its
operator was emitted as operator: "", an authentication: {} as mode: "", a
terminatingTLS: {} as secret: null. The API server refuses each of these, and nothing
in the build showed the omission. The trait refuses such a field where its parent is
authored, by its path (cilium-networkpolicy "api-edge": ingress[0].authentication.mode: required (…)), under the three properties it takes:
- the
keyandoperatorof a match expression, in every selector:endpointSelector,fromEndpoints,toEndpoints,fromNodes,toNodes, a CIDR entry’scidrGroupSelector, ak8sServiceSelector’sselector; - a
k8sServiceSelector’sselector; - an
authentication’smode; - a port’s
listenername, itsenvoyConfigand that configuration’sname; - the
secretof a port’sterminatingTLSandoriginatingTLS, and that Secret’sname; - an HTTP header match’s
name, and thenameof itssecretwhere one is authored.
Two more are optional to the API and required here, a listener’s priority and the
kind of its envoyConfig: the type writes an unauthored one as 0 and as the empty
string, and the API refuses both (a priority is 1 to 100, a kind is CiliumEnvoyConfig
or CiliumClusterwideEnvoyConfig).
A field is required only where its parent is authored: a rule with no authentication
needs no mode. A null is absence, so operator: with no value is a field left out, and
a null entry of a list (matchExpressions: [null]) is an entry with nothing authored in
it. An authored empty value is not checked: it is the API server’s to refuse. The type
of an icmps field is required too and is refused earlier, by Cilium’s own decoding
(above).
Field names match case-insensitively in the decode, so a rule can write one field in two
spellings (fromEndpoints and FromEndpoints); the decode then keeps one of them, or
merges the two. The trait does not refuse that, but each spelling is held to the list,
and the refusal names the one that leaves the field out
(ingress[0].FromEndpoints[0].matchExpressions[0].operator).
The list is the cilium-networkpolicy kind’s, cut to what the trait publishes
(requiredfields.CiliumTraitRule in the internal pkg/oam/internal/requiredfields), and
TestCiliumNetworkPolicyTrait_RequiredMatchCRD holds it to the CiliumNetworkPolicy CRD
of the linked Cilium module: it fails when the CRD requires a field under a published
property that the list lacks.
Breaking: a cilium-networkpolicy trait that leaves one of these fields out built
before, into a policy the API server refuses, and is now refused at build time.
No literal secret is checked. An HTTP header match’s value is written into the
policy as authored and is not refused, since a header value cannot be told from a
credential. A header match’s secret refers to a Secret by name and keeps the value out
of the document.
Null or empty endpointSelector / egress / ingress
A null is absence, whatever its Go shape — the same contract as the
networkpolicy trait above. The trait refuses at build time, by name, every document
that would render a CiliumNetworkPolicy the cluster rejects on apply
(go-kure/launcher#468):
egressandingressare required jointly, and must carry at least one rule between them (at least one of 'egress' or 'ingress' must be specified with at least one rule). Cilium renders both asomitemptylists, so a null and an empty list alike produce no rule key, and the CRD (anyOfingress/ingressDeny/egress/egressDeny) rejects that.egress: []alone, oregress: []beside a nullingress, is therefore an error. An empty or null key beside a direction that does carry a rule is simply absent.endpointSelectoris required. The trait synthesizes no default selector and exposes nonodeSelector, so an omitted selector would render a policy the CRD rejects (oneOfendpointSelector/nodeSelector). A null is refused the same way as an omitted key. To select every endpoint, authorendpointSelector: {}. A typed nil — an uninitialized Go map from a lowering rule — used to be emitted asendpointSelector: null, which Cilium decodes to an empty selector matching every endpoint, so a null silently became select-all.
Typed nils in the other trait parsers
A typed nil (map[string]any(nil), []any(nil)) is what an uninitialized Go map
or slice produces when a lowering rule or a Go-API caller assigns it into a property.
A bare v.(map[string]any) succeeds on it, so it used to read as an authored empty
value where an untyped null (key: with no value in a document) did not. Since
go-kure/launcher#465, the parsers of httproute, ingress, external-secret,
rbac, certificate, pvc and expose, and the
shared networkPolicy.trafficSources parser, give a typed nil the answer an untyped one
gets. The rule is the untyped answer, whatever it is:
- Refused, with the untyped message: a list entry (
httprouteparentRefs[],rules[],matches[],headers[],backendRefs[],filters[]and header-modifier entries;ingressrules[],paths[],tls[];external-secretdata[]anddataFrom[];rbacrules[];trafficSources[]), and a required or typed-when-present block (thehttproutefilter blocks and theirbackendRefs,ingress/httproutebackendSelector,data[].remoteRef,certificateissuerRef,rbacapiGroups/resources/verbs,networkPolicy,podSelector). Before, a typed nil there became a catch-all rule, a/path, an empty TLS block, or an empty value that let a valid sibling carry the document. - Absent: an optional block (
httprouteannotations,backendRefs,timeouts, a match or redirect/rewritepath, mirrorfraction,externalAuthgrpc/http/forwardBody;ingressannotations;external-secretremoteRef,dataFrom[].extract/find/find.tags,target.templateand itsdata;pvcaccessModes, which takes theReadWriteOncedefault;exposeannotations, which used to panic whensslRedirectwrote into it).
Each remaining comma-ok assertion in these files is safe for one of three reasons:
- a required list’s
len == 0check fires first with the same message for both shapes (httprouteparentRefs/rules,ingressrules/paths,rbacrules,certificatednsNames); - the value is filtered through
oam.IsNullValuefirst (thenetworkpolicytrait parser vianonNullObject/nonNullArray,trafficSources,backendSelector); - a nil map is only read by key and a nil list only ranged, so both shapes parse the
same (
certificateprivateKey,external-secrettarget,exposerules[], and the optional string lists).
TestTypedNilSweep and TestTypedNilSweepFollowUp pin each fixed site against the
untyped answer.
Null entries in a string map
Four properties are maps whose values are written as strings: ingress and httproute
annotations, and external-secret target.template.data and dataFrom[].find.tags.
An entry authored with a null value, typed or untyped, is absent: it is left out of
the object (go-kure/launcher#790). Pre-GA output change: such an entry used to be
written as the text <nil>, or map[] for a typed nil map. Any other value is still
written as its text (8080, true).
A map holding only null entries reads as an empty one, so a find with such tags and
no name is refused as one with neither. An ingress annotation the platform also sets
takes the platform’s value, as before (see platformAnnotations above). An entry
authored as null under a key the consumer reserves (ReservedMetadataKeys) is absent
like any other, so it is not refused; the same key with a value is.
TestNullStringMapValue_IsAbsence pins the four maps at their parsers, a typed nil
included, TestBuild_NullStringMapValueIsAbsent in the output of kurel build, and
TestReservedMetadataKeys_NullEntryIsAbsent the reserved key.
Conventions
Handlers use k8s.io/api constants for well-known Kubernetes enum values (access
modes, restart policies, etc.) rather than string literals — never re-define values
that already exist upstream.
A refusal by the environment policy that a trait’s sub-application raises in ApplyPolicy
is an oam.PolicyRefusal with a class, which a consumer reads from
oam.ViolationError.Class (go-kure/launcher#849; pkg/oam/README.md, “Refusal classes”):
oam.RefusalExplicitSecret for the secret trait, oam.RefusalReplicaMaximum for a
scaler over the policy’s maxReplicas, and oam.RefusalStorageMaximum for a pvc over
its maxStorageSize. The violation names the component the trait is on. A trait type the
policy forbids, does not allow, or requires and does not find is refused by the transform,
with oam.RefusalTraitCapability.
A trait that generates several objects gives each one its own label map, never one map
shared between them. These maps leave the package on objects the caller owns and edits,
so a shared map turns a label added to the Role into a label on the RoleBinding, or one
added to the HPA into a label on the PDB. The same rule and the reason behind it are in
the Conventions section of the component handlers’ README
(pkg/oam/builtin/components/README.md).
Every app label and app selector a trait generates to identify its component — on
the configmap, secret, pvc, rbac, scaler, ingress, httproute, networkpolicy and
external-secret objects, and the PodDisruptionBudget and NetworkPolicy podSelector
that pick the component’s pods — is valued at oam.ComponentLabelValue(<component>),
through the unexported componentLabels (labels.go); external-secret calls the
function directly. Authored selectors, such as a networkpolicy peer’s podSelector,
are emitted as written. It is
the same function the component handlers label their pods with, so a trait’s selector
matches them. A component name may be up to 253 characters and a label value at most
63; the function returns a name of 63 characters or fewer unchanged and projects a
longer one onto a prefix of at most 52 characters plus a 10-hex-character digest
(go-kure/launcher#572). A trait on a component whose type accepts a longer name (a
passthrough, for one) reaches the projection.
The object names a trait generates by default follow the same shortening rule at 253
characters (oam.ShortenNameWithSuffix, go-kure/launcher#793): the scaler HPA and PDB
(<component>-hpa, <component>-pdb), the networkpolicy trait’s policy
(<component>-allow), the ingress Ingress and the httproute HTTPRoute
(<component>-ingress, <component>-httproute, each followed by -<scope> when the trait
sets one), the managed TLS Secret default (<component>-tls), the volsync
ReplicationSource (<sourcePVC>-backup) and the volsync default repository Secret name
(<component>-volsync-secret). A name that fits is unchanged; a longer one keeps a prefix of
the name before the suffix, a -, 10 hex characters of its sha256, and the suffix whole.
An authored name is used as written or refused: it is never shortened and never changed, and
one that cannot be the name of its object fails the transform with the property in the error
(go-kure/launcher#787). The check is the DNS-1123 subdomain rule every one of these objects
can be named by (at most 253 characters, lower-case alphanumerics, - and ., starting and ending
with an alphanumeric). An authored empty string is refused too. For an optional override
(name on a routing trait, on rbac, on networkpolicy and on volsync, hpaName, pdbName,
targetSecretName, repository) it is not a way to ask for the
default: leaving the property out, or null, gets that. The expose secretName is optional
too and gets its default when left out. The other names are required.
The rbac name is held to the same subdomain rule, which is stricter than the cluster’s
own rule for the RBAC kinds: the cluster also accepts a name such as web:reader, and
launcher refuses it. The name also becomes a file name and a kustomization.yaml entry in
the written tree, and a colon has not been shown to be safe there. An author who needs such
a name cannot write it today.
The names of the scaler, rbac, networkpolicy, ingress and httproute objects and
the volsync ReplicationSource are resolved under a name role (pkg/oam/README.md, “Name roles and the Naming hook”): the
author’s property, else the consumer’s TransformContext.Naming hook, else the default. The
ingress and httproute traits an expose trait lowers to are resolved the same way.
Transform keeps those names apart and refuses two that name one object, naming both: an
hpaName equal to another component’s HPA, two scaler traits on one component, two
ingress traits under one name, on one component or two, or a trait and the kind component
of the same kind whose object carries the trait’s name (an ingress trait and an ingress
component).
The CiliumNetworkPolicy has no name role: it is author-named, and the hook is not asked, by
design. The cilium-networkpolicy name is required, and the trait claims it
((*Trait).ClaimGeneratedObjectName), so Transform refuses a second owner of that object
with both named, as under a role. The certificate trait’s Secret and the external-secret
trait’s ExternalSecret are claimed by their required secretName too, in the
namespace they land in (go-kure/launcher#787), so a certificate trait and an
external-secret trait that write the same Secret are refused in Transform. The configmap,
secret and pvc traits have no role either, by design: each name is required. Each trait
claims its object’s name ((*Trait).ClaimGeneratedObjectName), the ConfigMap’s and the
Secret’s in the namespace they land in, the claim’s in the application namespace, so a second
owner of the object, a second trait or a kind component, is refused in Transform with both
named. A trait a lowering rule emits adds no claim of the name of an object it generates when
its component’s rule already resolved and claimed that name under a role, as the helm rule
does for its values ConfigMap and Secret and a role component’s rule for its volume claims: the
object is that rule’s. The Secret a certificate trait has cert-manager write is not one the
trait generates, so a rule-emitted certificate trait’s secretName is always claimed as an
authored trait’s.
Every other authored name in the
table below is checked as a name, not against the other objects of the document:
oam.GenerateApplications and oam.CheckInDocumentCollisions report those (kurel build
runs both; see pkg/oam/README.md, “Nothing above compares the applications inside one
document”).
Every trait’s sub-application name is resolved under the sub-application role, so the hook
can rename it. That name is not the object’s: the configmap, secret, ingress,
httproute and volsync objects keep their own name when their sub-application is renamed.
Two applications of one name in one bundle are refused, so a configmap and a pvc trait of
one name on one component, or a trait sub-application named after another component, fail
the transform (pkg/oam/README.md, “Name roles and the Naming hook”; go-kure/launcher#787).
| Trait | Property | What it names |
|---|---|---|
ingress, httproute | name | The Ingress or HTTPRoute. |
expose | name | The Ingress or HTTPRoute the trait lowers to; checked by that trait. |
expose | secretName | The managed TLS Secret (a ValidationError on the component). |
certificate | secretName | The Certificate and the Secret it writes. |
external-secret | secretName, targetSecretName | The ExternalSecret, and the Secret it produces. |
configmap | name | The ConfigMap. |
cilium-networkpolicy | name | The CiliumNetworkPolicy. |
pvc | name | The PersistentVolumeClaim. |
volsync | name | The ReplicationSource. Without it the name is <sourcePVC>-backup, else the hook’s (role volsync-replicationsource). Two volsync traits backing up one claim in one namespace need a name on one of them. |
volsync | repository, sourcePVC | The repository Secret, which launcher only references and never generates (no role, by design), and the claim to back up (which also starts the default ReplicationSource name). |
scaler | hpaName, pdbName | The HorizontalPodAutoscaler, and the PodDisruptionBudget (pdbName needs enablePDB: true). Neither names the Deployment the HPA scales: scaleTargetRef names the component’s object, its objectName where it has one. |
rbac | name | The Role, the RoleBinding and, with clusterWide, the ClusterRole and the ClusterRoleBinding: one name for all four, and the roleRef.name of both bindings. The ClusterRole and the ClusterRoleBinding are claimed with no namespace (oam.NameSpec.ClusterScoped), so a lowering rule’s cluster-scoped object of the same kind and name is refused with both named. |
networkpolicy | name | The NetworkPolicy. |
A kind component’s objectName renames its object and nothing a trait names: every default
above is still built from the component name, as are the labels and selectors a trait writes.
What a trait writes to the component’s own object follows the object name: the scaler
trait’s scaleTargetRef, a routing trait’s own backend Service, and the rbac subject on a
serviceaccount component. A trait handler reads that name with
(*Trait).ComponentObjectName(): the component name where the object carries it, and empty
on a trait built outside a transform.
A routing trait’s scope (on ingress, httproute, and expose, which hands its own on) is
a part of a generated name, not a name: its length is never
refused, because the name it ends is shortened, but a character an object name cannot hold is,
checked on the name as built so the digest cannot hide it. Beside an authored name the scope
is in no name and is not checked. A property that only refers to an
object launcher never names (issuerRef.name, a gatewayName, an ingress tls[].secretName)
is passed through as written.
The same holds for the traffic sources a routing trait retains (TrafficSources()):
NetworkPolicy synthesis in pkg/oam gives every emitted peer, and every synthesized
policy’s spec.podSelector, its own deep copy of the source or backend selector. So a
label a caller stamps on one generated NetworkPolicy never reaches another policy
built from the same source, or the trait configuration (go-kure/launcher#396).
Since go-kure/launcher#361 these handlers build against kure’s release-1 builder
contract (go-kure/kure ≥ v0.2.0-beta.11), under which a Create<Kind>
constructor returns TypeMeta plus metadata.name/metadata.namespace and nothing
else — no labels, no annotations, no defaults. Every other field on a generated
object is therefore set by the trait itself, either as a direct field assignment
(np.Spec.PodSelector, rb.RoleRef, crb.RoleRef, es.Spec.Target,
cm.Labels) or through the remaining typed sugar kure still exposes for
appending to a list (AddRoleRule, AddRoleBindingSubject,
AddNetworkPolicyPolicyType, AddConfigMapData, AddLabel). Two practical
consequences: the Annotations = nil lines in configmap, networkpolicy,
rbac and scaler (both the HPA and the PDB) no longer strip anything a
constructor stamped and are now defensive only;
and a new trait that emits a selector-bearing kind must write that selector
itself, because nothing upstream of it will. CreateIngress is the one
constructor in this package whose signature changed with the contract — it no
longer takes an ingress class, so ingress writes spec.ingressClassName via
SetIngressClassName only when one was authored, leaving it nil otherwise
(unchanged output: the old call passed the class in and then nil’d the field back
out when it was empty).
kure v0.2.0-beta.13 (release 2 of the builder contract) retired the
config-struct layer the remaining four traits still went through
(certmanager.Certificate, cilium.CiliumNetworkPolicy,
externalsecrets.ExternalSecret, volsync.ReplicationSource and their *Config
types). certificate, cilium-networkpolicy, external-secret and volsync now
call the generated CreateCertificate / CreateCiliumNetworkPolicy /
CreateExternalSecret / CreateReplicationSource and set the spec themselves —
through AddCertificateDNSName, SetCertificateDuration,
SetCertificateRenewBefore and SetCiliumNetworkPolicySpec, or by assigning
es.Spec.SecretStoreRef and the upstream ReplicationSourceResticSpec /
ReplicationSourceTriggerSpec directly. None of those layers injected a value, so
the emitted manifests are unchanged.