Template Standards and Guidelines
This document defines the standards and structure that all Crossplane templates in the Open Service Portal must follow.
Repository Naming
All template repositories must follow the pattern: template-<resource-type>
Examples:
template-namespace- Kubernetes namespace provisioningtemplate-dns-record- DNS record managementtemplate-cloudflare-dnsrecord- Cloudflare DNS integration
Required Directory Structure
Every template MUST have this exact structure:
template-<name>/
├── .github/
│ └── workflows/
│ └── release.yaml # GitHub Actions release workflow
├── configuration/
│ ├── README.md # Package constraints documentation
│ ├── crossplane.yaml # Crossplane package metadata
│ ├── xrd.yaml # Composite Resource Definition
│ └── composition.yaml # Composition implementation
├── examples/
│ ├── basic-<resource>.yaml # Basic usage example
│ └── <advanced>.yaml # Advanced examples with features
├── .gitignore # Must include *.xpkg
├── kustomization.yaml # Resource bundling
├── rbac.yaml # RBAC permissions for Crossplane
├── mise.toml # Tool management
└── README.md # User documentation
XRD Requirements
API Version and Metadata
---
apiVersion: apiextensions.crossplane.io/v2 # MUST use v2
kind: CompositeResourceDefinition
metadata:
name: <resources>.openportal.dev # MUST use openportal.dev domain
labels:
terasky.backstage.io/generate-form: "true" # REQUIRED for Backstage
openportal.dev/version: "dev" # REQUIRED placeholder - CI/CD replaces with release version
annotations:
crossplane.io/version: "v2.0"
backstage.io/source-location: "url:https://github.com/open-service-portal/template-<name>"
openportal.dev/tags: "tag1,tag2" # Comma-separated tags
openportal.dev/description: "Brief description"
openportal.dev/icon: "icon-name" # Optional icon
Version Label Management
IMPORTANT: All XRD files must include the openportal.dev/version: "dev" label as a placeholder.
Why this pattern exists:
- Development identification - "dev" clearly marks unreleased versions in the cluster
- CI/CD automation - GitHub Actions replaces "dev" with the actual version during release
- Avoids YAML corruption - Version labels are only added to XRDs, not crossplane.yaml (which contains multi-line strings that yq can corrupt)
Implementation in GitHub Actions:
# In release.yaml workflow
- name: Build Configuration package
run: |
# Add version label to XRD only (crossplane.yaml has multi-line strings that yq corrupts)
yq -i '.metadata.labels."openportal.dev/version" = env(VERSION)' configuration/xrd.yaml
# Build the .xpkg file
crossplane xpkg build \
--package-root=configuration/ \
--package-file=configuration-<template-name>.xpkg
Note: Never use yq to modify crossplane.yaml files as they often contain multi-line strings in annotations that yq will corrupt. If labels are needed in crossplane.yaml, add them manually with static values.
Spec Requirements
spec:
scope: Namespaced # MUST be Namespaced for v2 XRs
group: openportal.dev # MUST use openportal.dev
names:
kind: <ResourceName> # NO 'X' prefix (e.g., Namespace, not XNamespace)
plural: <resources>
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
required:
- spec
properties:
spec:
type: object
# Define your resource properties here
Composition Requirements
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: <resources>.openportal.dev
labels:
crossplane.io/xrd: <resources>.openportal.dev
spec:
compositeTypeRef:
apiVersion: openportal.dev/v1alpha1
kind: <ResourceName>
# Use Pipeline mode for composition functions
mode: Pipeline
pipeline:
- step: <step-name>
functionRef:
name: function-go-templating # Or other functions
input:
# Function configuration
Namespaced XRs and Object Resources
Critical Requirements for Namespaced XRs
Since all XRs use scope: Namespaced in Crossplane v2, special care must be taken when using provider-kubernetes Object resources.
IMPORTANT DISCOVERY: Provider-kubernetes has TWO Object APIs:
kubernetes.crossplane.io/v1alpha2- cluster-scoped (CANNOT be used with namespaced XRs)kubernetes.m.crossplane.io/v1alpha1- namespace-scoped (CAN be used with namespaced XRs)
The Solution: Use Namespace-Scoped Object API
# CORRECT: Use the namespace-scoped v1alpha1 API
apiVersion: kubernetes.m.crossplane.io/v1alpha1 # Note the .m. in the API group!
kind: Object
metadata:
name: {{ $xrName }}-deployment
namespace: {{ $xrNamespace }} # REQUIRED: Object must be in XR's namespace
spec:
forProvider:
manifest:
# Your Kubernetes resource here
providerConfigRef:
kind: ClusterProviderConfig # Use cluster-wide config
name: kubernetes-provider
Important Guidelines:
-
Use the correct Object API version
- Use
kubernetes.m.crossplane.io/v1alpha1(namespace-scoped) - NOT
kubernetes.crossplane.io/v1alpha2(cluster-scoped) - The
.m.stands for "managed" and indicates namespace-scoped resources
- Use
-
Add namespace to Object metadata
- Object resources need
metadata.namespace: {{ $xrNamespace }} - This places the Object resource itself in the XR's namespace
- Object resources need
-
Use ClusterProviderConfig in providerConfigRef
- The v1alpha1 API requires
kind: ClusterProviderConfig - This references the cluster-wide configuration set up by
setup-cluster.sh - Do NOT use
kind: ProviderConfigunless you have namespace-specific configs
- The v1alpha1 API requires
-
Do NOT create namespaces from namespaced XRs
- The XR already exists in a namespace
- Deploy all resources to the XR's namespace
- Use
{{ .observed.composite.resource.metadata.namespace }}in templates
-
Resource naming must be unique
- Include XR name in resource names:
name: {{ $xrName }}-deployment - This prevents conflicts when multiple XRs exist in the same namespace
- Include XR name in resource names:
Example: Complete Working Pattern
# In your go-templating function:
{{- $xrName := .observed.composite.resource.metadata.name }}
{{- $xrNamespace := .observed.composite.resource.metadata.namespace }}
apiVersion: kubernetes.m.crossplane.io/v1alpha1 # Namespace-scoped API
kind: Object
metadata:
name: {{ $xrName }}-service
namespace: {{ $xrNamespace }} # Object in XR's namespace
spec:
forProvider:
manifest:
apiVersion: v1
kind: Service
metadata:
name: {{ $xrName }} # Unique name
namespace: {{ $xrNamespace }} # Service in XR's namespace
providerConfigRef:
kind: ClusterProviderConfig # Use cluster-wide config
name: kubernetes-provider
Provider Configuration
The setup script (scripts/setup-cluster.sh) creates a ClusterProviderConfig:
apiVersion: kubernetes.m.crossplane.io/v1alpha1
kind: ClusterProviderConfig
metadata:
name: kubernetes-provider
spec:
credentials:
source: InjectedIdentity
This cluster-wide configuration is used by all templates. You don't need to create namespace-specific ProviderConfigs unless you have special multi-tenancy requirements.
For more details, see Crossplane PR #6588 which enforces that namespaced XRs cannot create cluster-scoped resources.
Configuration Package (crossplane.yaml)
apiVersion: meta.pkg.crossplane.io/v1
kind: Configuration
metadata:
name: configuration-<resource>
annotations:
meta.crossplane.io/maintainer: Open Service Portal Team
meta.crossplane.io/source: github.com/open-service-portal/template-<name>
meta.crossplane.io/license: Apache-2.0
meta.crossplane.io/description: |
Description of what this template provides
meta.crossplane.io/readme: |
Detailed README content
spec:
crossplane:
version: ">=v1.14.0"
dependsOn:
- provider: xpkg.upbound.io/crossplane-contrib/provider-kubernetes
version: ">=v0.13.0"
Kustomization File
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Only include the Crossplane resources, not the examples
resources:
- configuration/xrd.yaml
- configuration/composition.yaml
- rbac.yaml
# Note: examples/*.yaml are intentionally NOT included
RBAC File
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: crossplane-<resource>-resources
labels:
rbac.crossplane.io/aggregate-to-crossplane: "true"
app.kubernetes.io/component: crossplane
app.kubernetes.io/part-of: <resource>-template
rules:
# Define permissions for resources your composition creates
- apiGroups:
- ""
resources:
- <kubernetes-resources>
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
GitHub Actions Release Workflow
All templates MUST include the standardized release workflow at .github/workflows/release.yaml.
Workflow Features
- Triggers: Version tags (v*..) or manual dispatch
- Multi-platform builds: linux/amd64, linux/arm64
- Registry: GitHub Container Registry (ghcr.io)
- Automatic versioning: Tags packages with version
- GitHub Release: Creates release with artifacts
- Catalog update: Auto-generates catalog entry (artifact)
Required Configuration
-
Package naming convention:
env:
REGISTRY: ghcr.io
PACKAGE_NAME: open-service-portal/configuration-<template-name> -
Configuration structure:
template-<name>/
├── configuration/
│ ├── crossplane.yaml # Package metadata
│ ├── xrd.yaml # XRD definition
│ └── composition.yaml # Composition
└── .github/
└── workflows/
└── release.yaml # Standard workflow -
Package file naming:
- Build output:
configuration-<template-name>.xpkg - Registry path:
ghcr.io/open-service-portal/configuration-<template-name>
- Build output:
Workflow Template
Copy the standard workflow and replace placeholders:
TEMPLATE_NAME_HERE→configuration-<your-template>TEMPLATE_TITLE_HERE→ Human-readable nameTEMPLATE_DESCRIPTION_HERE→ Brief descriptionTEMPLATE_FEATURES_HERE→ List of features
Release Process
-
Create version tag:
git tag v1.0.0
git push origin v1.0.0 -
Workflow automatically:
- Builds Configuration package from
configuration/directory - Pushes to
ghcr.io/open-service-portal/configuration-<name>:v1.0.0 - Also tags as
:latest(if not pre-release) - Creates GitHub release with
.xpkgfile - Generates
catalog-entry.yamlartifact
- Builds Configuration package from
-
Manual installation:
kubectl apply -f - <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
name: configuration-<name>
namespace: crossplane-system
spec:
package: ghcr.io/open-service-portal/configuration-<name>:v1.0.0
EOF
Supporting Files
.gitignore
# Build artifacts
*.xpkg
mise.toml
[tools]
crossplane-cli = "latest"
configuration/README.md
# Configuration package contents
> Including YAML files that aren't Compositions or CompositeResourceDefinitions isn't supported.
> — [*Crossplane docs*](https://docs.crossplane.io/latest/packages/configurations/#build-the-package)
Examples Structure
Provide at least:
basic-<resource>.yaml- Minimal configuration<resource>-with-<feature>.yaml- Advanced features
Example format:
# Example: Brief description
apiVersion: openportal.dev/v1alpha1
kind: <ResourceName>
metadata:
name: example-name
namespace: default # XR can be created in any namespace
spec:
# Minimal required fields
name: example
# Additional fields with comments
Documentation Requirements
Main README.md
Must include:
- Overview - What the template provides
- Components - List of files and their purpose
- Usage - Basic and advanced examples
- Features - Key capabilities
- Installation - How to apply via catalog
- Parameters - Table of all spec fields
- Restaurant Analogy - Explain using restaurant metaphor
Versioning and Releases
- Use semantic versioning:
v<major>.<minor>.<patch> - Create annotated tags with detailed release notes
- GitHub Actions automatically:
- Builds Configuration package
- Pushes to ghcr.io
- Creates GitHub release
- Opens PR to update catalog
Testing Checklist
Before releasing a template:
- XRD uses
apiextensions.crossplane.io/v2 - Group is
openportal.dev - Scope is
Namespaced - No 'X' prefix in kind name
- Has
terasky.backstage.io/generate-formlabel - Has
openportal.dev/version: "dev"label (placeholder for CI/CD) - Has
backstage.io/source-locationannotation - Composition uses Pipeline mode
- Object resources use
kubernetes.m.crossplane.io/v1alpha1API (namespace-scoped) - Object resources have
metadata.namespace: {{ $xrNamespace }} - providerConfigRef uses
kind: ClusterProviderConfig - References
kubernetes-providerClusterProviderConfig - No namespace creation from namespaced XRs
- Resources use XR's namespace via template variable
- Resource names include XR name for uniqueness
- All required files present
- RBAC permissions are minimal but sufficient
- Examples work when applied
- README is complete with restaurant analogy
- Release workflow is configured
Common Mistakes to Avoid
- ❌ Using
platform.ioinstead ofopenportal.dev - ❌ Using v1 API instead of v2
- ❌ Missing
scope: Namespaced - ❌ Using 'X' prefix in resource names
- ❌ Missing kustomization.yaml
- ❌ Missing rbac.yaml
- ❌ Including examples in kustomization.yaml
- ❌ Missing backstage annotations
- ❌ Using
kubernetes.crossplane.io/v1alpha2Object API (cluster-scoped) with namespaced XRs - ❌ Missing
metadata.namespaceon Object resources - ❌ Missing
kind: ClusterProviderConfigin providerConfigRef - ❌ Creating namespaces from namespaced XRs
- ❌ Not using unique resource names (missing XR name prefix)
Reference Templates
Use these as examples:
template-dns-record- Simple resource creationtemplate-namespace- Complex with quotas and policiestemplate-whoami- Application deploymenttemplate-cloudflare-dnsrecord- External provider integration