Portal Patch recipes
Portal Patch is an optional configure-stage image that edits the adapter's generated documents
using declarative recipes. It is independently versioned from the controller. This reference
uses ghcr.io/syntasso/portal-patch:v0.8.2; pin a released tag and review
release notes before changing it.
How it runs
Declare Portal Patch in a PortalCustomization configure lane. This complete resource changes
the Promise Component's owner; it requires an installed Portal Controller and a configured
Backstage instance:
apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: standard-ownership
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.2
args:
- |
target:
kind: Component
patch:
spec:
owner: platform-team
Apply the resource, then add this label to the Promise's manifest. This fragment shows the binding only; use an instance suffix if more than one Backstage instance is configured:
metadata:
labels:
kratix.io/portal-customization: standard-ownership
The label opts the Promise in without a separate portal-backstage label. Verify the Promise's
binding and sync conditions, then inspect the published Component's spec.owner. Resource
Components remain unchanged because this example declares no resource lane.
See PortalCustomization for the full pipeline and binding contract.
Recipes are container arguments
Each argument is one complete recipe. There are no recipe flags or configuration file. Recipes for a file apply in argument order, against the result of the previous recipe. They are not order-independent: a rename or removal can change what a later selector matches.
The image tag is the grammar version
Pin the helper image separately from the controller. Do not use latest.
Check release notes and revalidate recipes before changing the image tag.
Recipe schema
A recipe is a single YAML document passed as one container argument. Every recipe selects a target and then applies an operation to it.
target:
kind: Component # or: file: workflow-create.yaml
list: # optional, selects an item within the document
path: spec.parameters
key: title
value: Size
op: merge # optional, defaults to merge
patch: # required for merge, insertBefore and insertAfter
spec:
owner: platform-team
Top-level fields
| Field | Type | Used by | Meaning |
|---|---|---|---|
target.kind | string | all | Selects the document whose top-level kind matches. Mutually exclusive with target.file. |
target.file | string | all | Selects a single-document file by bare filename. Mutually exclusive with target.kind. |
target.list | object or list | all | Selects an item, or a chain of nested items, within the document. |
op | string | all | merge (default), remove, insertBefore, insertAfter, singleSpecPage, cortexEntityProjection, hideNamespaceManifest. |
patch | map | merge, insertBefore, insertAfter | The changes to merge, or the item to insert. Required for merge, insertBefore and insertAfter, rejected for every other operation. |
field | string | cortexEntityProjection, hideNamespaceManifest | Dot-separated path to a string field inside the selected item. |
sourceAction | string | cortexEntityProjection | The Cortex action producing the output. |
outputKey | string | cortexEntityProjection | The output key on that action. |
attribute | string | cortexEntityProjection | The entity attribute to project. |
defaultNamespace | string | hideNamespaceManifest | Namespace to substitute. Defaults to default. |
Exactly one of target.kind or target.file is required. Recipes are validated in full before
any file is read, so a malformed recipe fails before anything is touched.
Selecting a target
Which file
Recipes resolve against /kratix/output. target.kind always reads catalog-info.yaml, which is
what the Backstage generate stage writes. target.file names a sibling file in the same
directory, which is how Cortex documents are addressed:
target:
file: workflow-create.yaml
target.file must be a bare filename. A value containing a path separator is rejected.
Which document
A Backstage catalog-info.yaml holds more than one document, the Component and the Template, so
target.kind picks between them:
target:
kind: Template
The kind must match exactly one document. Matching none, or more than one, is an error.
A file selected with target.file must contain exactly one document, which is true of every
Cortex document the adapter writes.
Which node inside the document
Without target.list, the operation applies at the document root. With it, the operation applies
to a selected list item. Items are selected by key, never by index, so recipes keep working
when the generate stage reorders a list:
target:
kind: Template
list:
path: spec.parameters # dot-separated path to a list
key: title # a field on each item
value: Size # the value to match
All three of path, key and value are required. The selector must match exactly one item.
To reach an item nested inside another item, give a chain. The first path resolves from the
document root, and each later path resolves inside the item the previous selector chose:
target:
file: workflow-create.yaml
list:
- path: actions
key: slug
value: user-input
- path: schema.inputs
key: key
value: app
The operation applies to the item the last selector names; every selector before it only
descends. For insertBefore and insertAfter, that last item is the anchor, and the new item
joins the list holding it.
Operations
merge (default)
Recursively merges patch into the target.
target:
kind: Component
patch:
spec:
owner: platform-team
lifecycle: production
metadata:
tags: [tier-1, platform-owned]
Merge semantics:
- Scalars replace the value at the same key.
- Maps merge key by key, at any depth.
- Lists replace wholesale. A list in
patchreplaces the list in the document, it does not append to it. - A
nullvalue deletes the key. This is how you remove a field.
target:
kind: Template
list:
path: spec.parameters
key: title
value: "MyKind Instance Metadata"
patch:
properties:
objnamespace: null # remove the namespace field from the form
required: [objname] # replaces the existing required list
With target.list, the patch applies to the selected item, not to the document root.
remove
Deletes the entire list item the selector resolves to. Requires target.list and rejects patch.
target:
kind: Template
list:
path: spec.parameters
key: title
value: Size
op: remove
To remove a field within an item rather than the item itself, use a merge with a null value.
insertBefore and insertAfter
Adds patch to the selected list as a new item, immediately before or after the item the selector
resolved to. The two operations differ in nothing else. Both require target.list, naming the
anchor the new item sits beside, and a patch, which is the item to insert.
target:
kind: Template
list:
path: spec.steps
key: id
value: ske-configure-resource
op: insertAfter
patch:
id: notify
name: Notify the team
action: ske:notify
input:
message: a resource was requested
This insertion example assumes your Backstage backend registers a ske:notify action.
Choose an action your backend provides; inserting an unknown action does not install it.
The inserted item must not duplicate the anchor selector's key and value.
singleSpecPage
Backstage only. Collapses a Template's per-property specification pages into one page titled
"Specification". Rejects target.list and patch.
A single page is the default, so this recipe only matters on a portal that set
--single-spec-page=false.
target:
kind: Template
op: singleSpecPage
Only pages with a properties.spec wrapper are combined. Metadata, repository selection
and other non-specification pages are preserved in order. If no specification page exists,
the recipe succeeds without changing the Template.
hideNamespaceManifest
Backstage only. Replaces the namespace placeholder in a generated step's manifest with a fixed
namespace, so the resource always lands in that namespace. Requires target.list and field, and
rejects patch.
For generated Templates, use
config.requestNamespace with from: fixed.
It sets the form, submission and request-tracking namespace together.
This recipe changes only the selected step's manifest. It does not update the form or
the record-request step's manifest. Different namespaces can break request tracking.
Use config.requestNamespace for a consistent namespace policy.
target:
kind: Template
list:
path: spec.steps
key: id
value: ske-configure-resource
op: hideNamespaceManifest
field: input.manifest
defaultNamespace: platform-team
defaultNamespace must be a valid Kubernetes namespace name, at most 63 characters, and defaults
to default when omitted.
For hand-written Templates, keep the form, submission and tracking manifests consistent.
This recipe targets the ske-configure-resource step, which only exists in push
delivery mode. Under pull-request mode the selector matches
nothing, and Portal Patch fails the batch, which aborts the sync and ships nothing.
If a Promise's portal uses pull-request mode, do not use this recipe.
cortexEntityProjection
Cortex only. Rewrites a placeholder in a Cortex workflow so it projects one attribute of a
picked entity, rather than the raw value. Requires target.list, field, sourceAction,
outputKey and attribute, and rejects patch.
target:
file: workflow-create.yaml
list:
- path: actions
key: slug
value: commit-file
op: cortexEntityProjection
field: schema.content
sourceAction: user-input
outputKey: app
attribute: name
This turns a placeholder reading actions.user-input.outputs.app into
actions.user-input.outputs.app.name. Only the named output is affected, so a similarly-named
output such as appVersion is left alone.
outputKey is the key as it appears on the workflow input. A top-level property app is app; a
nested property spec.database.name is database-name; the identity fields are objname and
objnamespace.
sourceAction, outputKey and attribute may only contain letters, digits, hyphens and
underscores.
Failure behaviour
Recipe parsing, selection and application errors abort the batch before files are written.
Portal Patch exits non-zero, and the failed configure init container prevents the writer
from running. A missing catalog-info.yaml is the one exception: recipes for that file
are logged and skipped. Other missing files are errors.
This guarantee concerns recipe failures, not a filesystem transaction: an I/O error while writing results also fails the container but can occur after another file has been written. The writer still does not run after that failure.
Errors
Recipe failures exit non-zero, report the offending target or operation on stderr, and write no files. The common ones:
| Message | Cause |
|---|---|
target.kind=X matches no document | The document is not in the file, often the wrong lane |
target.kind=X is ambiguous: matches N documents | More than one document has that kind |
list selector key=K value=V matches no item | The item is absent, renamed, or the wrong delivery mode |
list selector key=K value=V is ambiguous | More than one item matches |
target.list path "P": "S" not found | A path segment does not exist |
missing or empty patch | A merge recipe with no patch |
op: insertBefore requires target.list | An insert with no anchor |
op: insertAfter requires a patch (the item to insert) | An insert with nothing to insert |
patch sets K: "V", the same key and value the anchor selector matches | The inserted item duplicates the anchor's own selector key and value |
unknown op "X" | Misspelled operation |
field "F" has no ... placeholder | The field exists but does not contain the expected placeholder |
Recipe application is all-or-nothing. If any recipe in the batch fails, the files are left exactly as the generate stage wrote them.
Choosing between a recipe and your own container
Use recipes for known fields and list items: owners, tags, titles, links, form fields and workflow inputs. Use a container script when values depend on the parent object, an external service or new files. Both approaches use the same configure lane and can be composed in order. See the script example.
Which operations apply to which portal
| Operation | Backstage | Cortex |
|---|---|---|
merge | yes | yes |
remove | yes | yes |
insertBefore | yes | yes |
insertAfter | yes | yes |
singleSpecPage | yes | no |
hideNamespaceManifest | yes, push mode only | no |
cortexEntityProjection | no | yes |
