Skip to main content

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.

Upgrading Portal Patch

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​

FieldTypeUsed byMeaning
target.kindstringallSelects the document whose top-level kind matches. Mutually exclusive with target.file.
target.filestringallSelects a single-document file by bare filename. Mutually exclusive with target.kind.
target.listobject or listallSelects an item, or a chain of nested items, within the document.
opstringallmerge (default), remove, insertBefore, insertAfter, singleSpecPage, cortexEntityProjection, hideNamespaceManifest.
patchmapmerge, insertBefore, insertAfterThe changes to merge, or the item to insert. Required for merge, insertBefore and insertAfter, rejected for every other operation.
fieldstringcortexEntityProjection, hideNamespaceManifestDot-separated path to a string field inside the selected item.
sourceActionstringcortexEntityProjectionThe Cortex action producing the output.
outputKeystringcortexEntityProjectionThe output key on that action.
attributestringcortexEntityProjectionThe entity attribute to project.
defaultNamespacestringhideNamespaceManifestNamespace 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 patch replaces the list in the document, it does not append to it.
  • A null value 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.

note

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.

Submission manifest only

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.

Push delivery mode only

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:

MessageCause
target.kind=X matches no documentThe document is not in the file, often the wrong lane
target.kind=X is ambiguous: matches N documentsMore than one document has that kind
list selector key=K value=V matches no itemThe item is absent, renamed, or the wrong delivery mode
list selector key=K value=V is ambiguousMore than one item matches
target.list path "P": "S" not foundA path segment does not exist
missing or empty patchA merge recipe with no patch
op: insertBefore requires target.listAn 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 matchesThe inserted item duplicates the anchor's own selector key and value
unknown op "X"Misspelled operation
field "F" has no ... placeholderThe 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​

OperationBackstageCortex
mergeyesyes
removeyesyes
insertBeforeyesyes
insertAfteryesyes
singleSpecPageyesno
hideNamespaceManifestyes, push mode onlyno
cortexEntityProjectionnoyes