Skip to main content

Customising the request template

The Portal Controller turns a Promise's API into a Backstage Template—the request template a developer fills in to ask for a resource. Most of what you want that template to do, you write into the CRD in spec.api.

Where the API cannot express it, bind the Promise to a PortalCustomization and patch the request template after the Portal Controller's generator has built it.

The examples on this page are fragments of one postgresql Promise. Each one is a property of spec, which is the block highlighted below:

postgresql Promise
apiVersion: platform.kratix.io/v1alpha1
kind: Promise
metadata:
name: postgresql
spec:
api:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: postgresqls.marketplace.kratix.io
spec:
group: marketplace.kratix.io
names:
kind: postgresql
plural: postgresqls
singular: postgresql
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
title: Instance size
# every example on this page goes here

Behaviour that comes from your API

Everything in this section is a change to the Promise itself. No PortalCustomization is involved: the generator reads these keywords from the CRD and the request template carries them through to Backstage.

Required fields

List the property in required on the object that holds it. Backstage marks it with an asterisk and will not move on to the next page until it has a value.

spec:
type: object
required:
- size
properties:
size:
type: string
Two required fields marked with asterisks, one showing a browser validation message reading Please fill out this field
A required field blocks the page until it has a value

enum gives the developer a dropdown, and default preselects one of the values.

size:
type: string
title: Instance size
description: How much CPU and memory the database gets.
enum: [small, medium, large]
default: small
The Instance size dropdown open, listing small, medium and large, with small highlighted
Opening the field lists exactly the enum values, with the default highlighted

title is the field label and description is the helper text underneath it. Set a title on nested properties too, so they get a label of their own.

Immutable fields

Freeze a field with a CEL validation rule on the CRD:

region:
type: string
title: Region
description: Region the database is created in.
enum: [eu-west-1, us-east-1]
x-kubernetes-validations:
- rule: self == oldSelf
message: region is immutable

The API server now rejects any update that changes region, and the generator marks the property so the SKE frontend renders it read-only on the Manage tab. It stays editable on the create form, where there is nothing yet to preserve.

The Manage tab of a PostgreSQL resource, with every field prefilled from the live resource and the Region field greyed out
Region is locked on the Manage tab while the rest of the form stays editable

Mutually exclusive options

oneOf lets a developer configure one of several things rather than all of them. Each option lists the properties it needs, and Backstage renders a radio button per option, showing only the fields that belong to the option selected.

backups:
type: object
title: Backups
description: Where backups are shipped.
oneOf:
- required: [s3]
- required: [gcs]
properties:
expireAfterDays:
type: integer
title: Expire after (days)
default: 30
s3:
type: object
title: Amazon S3
properties:
bucket:
type: string
title: Bucket
roleArn:
type: string
title: Role ARN
gcs:
type: object
title: Google Cloud Storage
properties:
bucket:
type: string
title: Bucket
serviceAccount:
type: string
title: Service account
The Backups page with the S3 option selected, showing Expire after, Bucket and Role ARN fields
With S3 selected, the form asks for a bucket and a role ARN
The same Backups page with the Gcs option selected, showing Expire after, Bucket and Service account fields instead
Switching to Gcs replaces those with a bucket and a service account. Expire after belongs to neither option, so it stays put

Backstage labels each radio button after the property its option requires, which is where "S3" and "Gcs" come from. A property no option requires, like expireAfterDays, is shared and appears whichever option is selected.

Give every option at least one property in required. An option without one is an error, and the Promise fails to sync. The developer has to pick an option, so the field itself becomes required.

Validation

Backstage checks pattern, the length keywords and the range keywords in the browser, so the developer sees the problem before they submit.

teamEmail:
type: string
title: Team email
description: Who to page when this database misbehaves.
pattern: '^[^@]+@example\.com$'
minLength: 5
maxLength: 64
replicas:
type: integer
title: Replicas
minimum: 1
maximum: 5

The generator appends Must match regular expression '<pattern>' to the description of any property that has a pattern. Backstage turns anything in a description that looks like a URL or an email address into a link, so a pattern containing either comes out badly:

The Team email field, whose helper text shows the regular expression with part of it turned into a hyperlink
Part of ^[^@]+@example.com$ has been linkified, leaving the pattern hard to read

For anything longer than a simple pattern, replace this with a validation message of your own.

Lists and nested objects

An array renders as a list the developer adds items to. A nested object renders as a group of fields under its own heading.

extensions:
type: array
title: Extensions
description: Postgres extensions to enable.
maxItems: 5
items:
type: string
maintenance:
type: object
title: Maintenance window
properties:
dayOfWeek:
type: string
title: Day of week
enum: [sunday, monday, tuesday]
default: sunday
hourUTC:
type: integer
title: Hour (UTC)
minimum: 0
maximum: 23
default: 2
The Maintenance window group with Day of week and Hour fields, above an Extensions list holding two entries
A nested object becomes a titled group, and an array becomes a list with add, reorder and remove controls

Reference

CRD schemaEffect on the form
titleField label
descriptionHelper text below the field
requiredField is mandatory and marked with an asterisk
defaultField is prefilled with the value
enumDropdown of the listed values
pattern, minLength, maxLengthString validation. The pattern is also appended to the description
minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOfNumeric validation
formatPassed through to Backstage, for example email
type: array, minItems, maxItemsA list the developer adds items to
type: object with propertiesA group of nested fields
oneOf with required per optionRadio buttons showing only the selected option's fields
x-kubernetes-validations with self == oldSelfRead-only on the Manage tab, editable on create

Behaviour you add with a PortalCustomization

A PortalCustomization runs after the request template is generated and patches it in place. Use one when what you want belongs to the template rather than to your API, such as layout and wording, or when Backstage can do something your CRD has no way to ask for.

Each example below is a complete PortalCustomization. The change itself is a Portal Patch recipe, one entry under args, applied to the documents the generator wrote. A Promise binds to a single PortalCustomization, so to make more than one of these changes, add each recipe as another args entry rather than creating a second resource. Customization covers binding a Promise to a customization.

Put every recipe here in spec.promise.configure. The request template is generated once per Promise, not once per request.

Add a description to the template

By default a request form opens straight onto its fields, with nothing to say what the resource is. A description on a page of the template's spec.parameters fills that gap: Backstage renders it as Markdown above that page's fields, both when the resource is requested and when it is updated.

apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: postgresql-request-template
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.0
args:
- |
target:
kind: Template
list:
path: spec.parameters
key: title
value: "Postgresql Instance Metadata"
patch:
description: |
## What this template creates

A managed database for your service, backed up nightly and reachable only
from inside the cluster.

- One database instance, sized for the load you expect
- A connection secret published alongside your component
- Nightly backups, kept for 30 days

**Before you start**, check [choosing a driver](https://example.com/docs).

Postgresql Instance Metadata is the title the generator gives the first page, built from your CRD's kind. Substitute your own, or the selector matches nothing and the sync fails.

The first page of the request form, opening with a heading, a paragraph, a bulleted list and a link before the Name and Namespace fields
The description renders as Markdown above the page's fields

Field order

Every spec field shares one page, titled "Specification", unless the portal turns that off with --single-spec-page=false. ui:order sets the order the fields appear in on it:

apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: postgresql-request-template
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.0
args:
- |
target:
kind: Template
list:
path: spec.parameters
key: title
value: Specification
patch:
properties:
spec:
ui:order: [size, storageGB, region, replicas, "*"]
warning

ui:order has to account for every property on the page. Miss one and Backstage replaces the form with uiSchema order list does not contain properties '<name>' and a dump of the raw schema. The "*" above catches everything not named before it; without it, every property has to appear in the list.

Instance size, Storage (GB), Region and Replicas appearing in that order on the form
The four named fields, in the order ui:order names them. Everything else follows under "*"

Show a field only for certain choices

Storage size is only worth asking about on a large instance. To hide storageGB until the developer picks large, take it out of properties and describe it in a dependencies block instead:

apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: postgresql-request-template
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.0
args:
- |
target:
kind: Template
list:
path: spec.parameters
key: title
value: Specification
patch:
properties:
spec:
properties:
storageGB: null
dependencies:
size:
oneOf:
- properties:
size:
enum: [small]
- properties:
size:
enum: [medium]
- properties:
size:
enum: [large]
storageGB:
type: integer
title: Storage (GB)
description: Provisioned storage. Only configurable on large instances.
required: [storageGB]

Setting storageGB to null removes it from the form. The dependencies block is keyed on size, the field whose value determines what appears, with one branch for each value. The large branch puts storageGB back and makes it mandatory, while small and medium add nothing, so it stays hidden.

Every value needs a branch, even the ones that add nothing. Miss one and the form refuses to submit with 'Specification' must match exactly one schema in oneOf, which tells a developer nothing they can act on.

The field still has to exist on your CRD. This recipe only changes when it is visible, and the API server strips anything the CRD does not declare. Leave its name in ui:order too: a name matching nothing is ignored, and it puts the revealed field where you want it instead of at the end of the page.

Instance size set to small, with Region following it directly and no Storage field
On small, Storage (GB) is not on the form at all
Instance size set to large, with a required Storage (GB) field between it and Region
On large, it appears where ui:order puts it, and is required
A hidden field still sends its value

Backstage does not clear a value when the field that revealed it disappears. Pick large, type a storage size, change back to small, and the request carries both size: small and the storage size you typed.

Add the same rule to the CRD, so the API server rejects a request that breaks it:

spec:
type: object
x-kubernetes-validations:
- rule: "self.size == 'large' || !has(self.storageGB)"
message: storageGB may only be set when size is large

Better validation messages

errorMessage replaces what Backstage says when a keyword fails, keyed by the keyword that failed. Pair it with ui:help to tell the developer what you want before they get it wrong, and trim the description so the raw pattern is no longer in it:

apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: postgresql-request-template
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.0
args:
- |
target:
kind: Template
list:
path: spec.parameters
key: title
value: Specification
patch:
properties:
spec:
properties:
teamEmail:
description: Who to page when this database misbehaves.
ui:help: Use a team alias, not a personal address.
errorMessage:
pattern: must be an @example.com address
The Team email field holding an invalid address, showing the message must be an @example.com address and the help text below it
The custom message replaces the regular expression error, with ui:help underneath it

Change how a field is rendered

Any ui: key Backstage understands can be patched onto a property. ui:widget picks a different widget for the same data, so an enum of two values can be radio buttons rather than a dropdown:

apiVersion: platform.syntasso.io/v1alpha1
kind: PortalCustomization
metadata:
name: postgresql-request-template
spec:
portalType: backstage
promise:
configure:
containers:
- name: portal-patch
image: ghcr.io/syntasso/portal-patch:v0.8.0
args:
- |
target:
kind: Template
list:
path: spec.parameters
key: title
value: Specification
patch:
properties:
spec:
properties:
region:
ui:widget: radio
The Region field rendered as two radio buttons, eu-west-1 and us-east-1, instead of a dropdown
The same enum as radio buttons rather than a dropdown

Backstage's ui:options examples list the widgets available. A widget that does not suit the property's type renders nothing at all rather than reporting an error.

To narrow one dropdown by what was chosen in another, the SKE frontend ships a ScopedEntityPicker that you select the same way, with ui:field.