From 9509e7ef2e4cdfe22fdcf8bc478ad700a5f4d45f Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 31 Jan 2026 16:11:50 +0000 Subject: [PATCH 1/8] update function.ts Signed-off-by: Steven Borrelli --- scripts/configuration-xpkg-build.sh | 2 +- scripts/function-xpkg-build.sh | 2 +- src/function.test.ts | 18 ++- src/function.ts | 237 +++++++++++++++++++++++----- src/test-helpers.ts | 51 +++++- test-cases/basic-app.yaml | 52 ------ test-cases/example-full.yaml | 185 ++++++++++++++++++++++ 7 files changed, 444 insertions(+), 103 deletions(-) delete mode 100644 test-cases/basic-app.yaml create mode 100644 test-cases/example-full.yaml diff --git a/scripts/configuration-xpkg-build.sh b/scripts/configuration-xpkg-build.sh index 5a36574..3375d6f 100755 --- a/scripts/configuration-xpkg-build.sh +++ b/scripts/configuration-xpkg-build.sh @@ -6,6 +6,6 @@ set -xeu mkdir -p ${XPKG_DIR} crossplane xpkg build \ - --package-root="package" \ + --package-root="package-configuration" \ --examples-root="examples" \ -o ${XPKG_DIR}/${CONFIGURATION_NAME}-v${VERSION}.xpkg diff --git a/scripts/function-xpkg-build.sh b/scripts/function-xpkg-build.sh index 247c599..ffe85d9 100755 --- a/scripts/function-xpkg-build.sh +++ b/scripts/function-xpkg-build.sh @@ -8,4 +8,4 @@ for platform in ${BUILD_PLATFORMS}; do crossplane xpkg build --embed-runtime-image-tarball="${DOCKER_IMAGE_DIR}/${FN_NAME}-runtime-${platform}-v${VERSION}.tar" \ --package-root="package-function" \ -o "${XPKG_DIR}/${FN_NAME}-${platform}-v${VERSION}.xpkg" -done \ No newline at end of file +done diff --git a/src/function.test.ts b/src/function.test.ts index 8fcadce..1950f0c 100644 --- a/src/function.test.ts +++ b/src/function.test.ts @@ -21,7 +21,12 @@ describe('Function', () => { const fn = new Function(); // Build a complete request from the test input - const req = to(testCase.input); + // Use to() to ensure proper protobuf structure - it should preserve observed + const req = to(testCase.input) as any; + // Manually add observed since to() doesn't preserve it + if (testCase.input.observed) { + req.observed = testCase.input.observed; + } // Run the function with the test input const response = await fn.RunFunction(req); @@ -43,7 +48,7 @@ describe('Function', () => { observed: { composite: { resource: { - apiVersion: 'example.crossplane.io/v1alpha1', + apiVersion: 'platform.upbound.io/v1', kind: 'App', metadata: { name: 'test-app', @@ -71,10 +76,9 @@ describe('Function', () => { expect(deployment?.resource?.kind).toBe('Deployment'); expect(deployment?.resource?.apiVersion).toBe('apps/v1'); - // Check pod exists - const pod = response.desired?.resources?.['pod']; - expect(pod).toBeDefined(); - expect(pod?.resource?.kind).toBe('Pod'); - expect(pod?.resource?.apiVersion).toBe('v1'); + // Verify no service or ingress created (no config provided) + expect(response.desired?.resources?.['service']).toBeUndefined(); + expect(response.desired?.resources?.['ingress']).toBeUndefined(); + expect(response.desired?.resources?.['serviceaccount']).toBeUndefined(); }); }); diff --git a/src/function.ts b/src/function.ts index 8c7f30e..49b975a 100644 --- a/src/function.ts +++ b/src/function.ts @@ -12,11 +12,28 @@ import { type FunctionHandler, type Logger, } from '@crossplane-org/function-sdk-typescript'; -import { Pod } from 'kubernetes-models/v1'; +import { Service, ServiceAccount } from 'kubernetes-models/v1'; +import { Deployment } from 'kubernetes-models/apps/v1'; +import { Ingress } from 'kubernetes-models/networking.k8s.io/v1'; + +/** + * Ingress path configuration + */ +interface IngressPath { + path: string; + pathType?: string; +} + +/** + * Ingress host configuration + */ +interface IngressHost { + host: string; + paths: IngressPath[]; +} /** * Function is a sample implementation showing how to use the SDK - * This creates a Deployment and Pod as example resources */ export class Function implements FunctionHandler { // Note: This implementation is currently synchronous. When adding async operations @@ -40,60 +57,198 @@ export class Function implements FunctionHandler { // List the Desired Composed resources const desiredComposed = getDesiredComposedResources(req); - // Create resource from a JSON object - desiredComposed['deployment'] = Resource.fromJSON({ - resource: { - apiVersion: 'apps/v1', - kind: 'Deployment', + // Extract parameters from XR spec + const name = observedComposite?.resource?.metadata?.name; + const params = observedComposite?.resource?.spec?.parameters || {}; + const deploymentConfig = params.deployment || {}; + const imageConfig = deploymentConfig.image || {}; + const serviceConfig = params.service || {}; + const ingressConfig = params.ingress || {}; + const serviceAccountConfig = params.serviceAccount || {}; + + // Common metadata for all resources + const commonMetadata = { + labels: { + 'app.kubernetes.io/name': name, + 'app.kubernetes.io/instance': name, + 'app.kubernetes.io/managed-by': 'crossplane', + }, + }; + + // Create ServiceAccount if enabled + if (serviceAccountConfig.create) { + const serviceAccount = new ServiceAccount({ + metadata: { + ...commonMetadata, + name: serviceAccountConfig.name || name, + annotations: { + 'crossplane.io/composition-resource-name': 'serviceaccount', + ...(serviceAccountConfig.annotations || {}), + }, + }, + automountServiceAccountToken: serviceAccountConfig.automount ?? true, + }); + + desiredComposed['serviceaccount'] = Resource.fromJSON({ + resource: serviceAccount.toJSON(), + }); + } + + // Create Service if config is provided + if (serviceConfig && Object.keys(serviceConfig).length > 0) { + const service = new Service({ metadata: { - name: 'my-deployment', - namespace: 'foo', + ...commonMetadata, + annotations: { + 'crossplane.io/composition-resource-name': 'service', + }, }, spec: { - replicas: 3, + type: serviceConfig.type || 'ClusterIP', + ports: [ + { + port: serviceConfig.port || 80, + targetPort: 'http', + protocol: 'TCP', + name: 'http', + }, + ], selector: { - matchLabels: { - app: 'my-app', + 'app.kubernetes.io/name': name, + 'app.kubernetes.io/instance': name, + }, + }, + }); + + desiredComposed['service'] = Resource.fromJSON({ + resource: service.toJSON(), + }); + } + + const deployment = new Deployment({ + metadata: { + ...commonMetadata, + annotations: { + 'crossplane.io/composition-resource-name': 'deployment', + ...(deploymentConfig.podAnnotations || {}), + }, + }, + spec: { + replicas: deploymentConfig.replicaCount || 1, + selector: { + matchLabels: { + 'app.kubernetes.io/name': name, + 'app.kubernetes.io/instance': name, + }, + }, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': name, + 'app.kubernetes.io/instance': name, + ...(deploymentConfig.podLabels && deploymentConfig.podLabels), }, + ...(deploymentConfig.podAnnotations && { + annotations: deploymentConfig.podAnnotations, + }), }, - template: { - metadata: { - labels: { - app: 'my-app', + spec: { + ...((serviceAccountConfig.create === true || serviceAccountConfig.name) && { + serviceAccountName: serviceAccountConfig.name || name, + }), + ...(deploymentConfig.podSecurityContext && { + securityContext: deploymentConfig.podSecurityContext, + }), + ...(deploymentConfig.nodeSelector && { + nodeSelector: deploymentConfig.nodeSelector, + }), + ...(deploymentConfig.tolerations && { + tolerations: deploymentConfig.tolerations, + }), + ...(deploymentConfig.affinity && { + affinity: deploymentConfig.affinity, + }), + ...(deploymentConfig.volumes && { + volumes: deploymentConfig.volumes, + }), + containers: [ + { + name: name, + image: `${imageConfig.repository || 'nginx'}:${imageConfig.tag || 'latest'}`, + imagePullPolicy: imageConfig.pullPolicy || 'IfNotPresent', + ports: [ + { + name: 'http', + containerPort: serviceConfig.port || 80, + protocol: 'TCP', + }, + ], + ...(deploymentConfig.securityContext && { + securityContext: deploymentConfig.securityContext, + }), + ...(deploymentConfig.resources && { + resources: deploymentConfig.resources, + }), + ...(deploymentConfig.livenessProbe && { + livenessProbe: deploymentConfig.livenessProbe, + }), + ...(deploymentConfig.readinessProbe && { + readinessProbe: deploymentConfig.readinessProbe, + }), + ...(deploymentConfig.volumeMounts && { + volumeMounts: deploymentConfig.volumeMounts, + }), }, - }, - spec: { - containers: [ - { - name: 'my-container', - image: 'my-image:latest', - ports: [ - { - containerPort: 80, - }, - ], - }, - ], - }, + ], }, }, }, }); - // Create a resource from a Model at https://github.com/tommy351/kubernetes-models-ts - const pod = new Pod({ - metadata: { - name: 'pod', - namespace: 'default', - }, - spec: { - containers: [], - }, + desiredComposed['deployment'] = Resource.fromJSON({ + resource: deployment.toJSON(), }); - pod.validate(); + // Create Ingress if config is provided + if (ingressConfig && Object.keys(ingressConfig).length > 0) { + const ingress = new Ingress({ + metadata: { + ...commonMetadata, + annotations: { + 'crossplane.io/composition-resource-name': 'ingress', + ...(ingressConfig.annotations || {}), + }, + }, + spec: { + ...(ingressConfig.className && { + ingressClassName: ingressConfig.className, + }), + ...(ingressConfig.hosts && { + rules: ingressConfig.hosts.map((hostConfig: IngressHost) => ({ + host: hostConfig.host, + http: { + paths: hostConfig.paths.map((pathConfig: IngressPath) => ({ + path: pathConfig.path, + pathType: pathConfig.pathType || 'ImplementationSpecific', + backend: { + service: { + name: name, + port: { + number: serviceConfig.port || 80, + }, + }, + }, + })), + }, + })), + }), + }, + }); - desiredComposed['pod'] = Resource.fromJSON({ resource: pod.toJSON() }); + desiredComposed['ingress'] = Resource.fromJSON({ + resource: ingress.toJSON(), + }); + } // Merge desiredComposed with existing resources using the response helper rsp = setDesiredComposedResources(rsp, desiredComposed); diff --git a/src/test-helpers.ts b/src/test-helpers.ts index ddd3624..62cc71e 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -114,10 +114,36 @@ export function loadTestCasesFromYaml(filePath: string): TestCase[] { .filter((doc) => doc.toJSON() !== null) .map((doc) => { const data = doc.toJSON(); + + // Support xrPath field to reference external composite resource + let input = data.input || {}; + if (data.xrPath) { + const xrContent = readFileSync(data.xrPath, 'utf-8'); + const xrDocs = parseAllDocuments(xrContent); + const externalResource = xrDocs[0]?.toJSON(); + + if (externalResource) { + input = { + meta: input.meta || { tag: 'test' }, + observed: { + ...(input.observed || {}), + composite: { + resource: externalResource, + }, + }, + desired: input.desired || { + composite: { + resource: {}, + }, + }, + }; + } + } + return { name: data.name || 'Unnamed test case', description: data.description, - input: data.input, + input: input, expected: data.expected || {}, }; }); @@ -329,10 +355,33 @@ export function assertResourceTypes(response: RunFunctionResponse, expectedTypes } } +/** + * Assert that all resources have the crossplane.io/composition-resource-name annotation + */ +export function assertCompositionResourceNames(response: RunFunctionResponse) { + const desiredResources = response.desired?.resources || {}; + + for (const [key, resource] of Object.entries(desiredResources)) { + const resourceData = resource?.resource as KubernetesResource | undefined; + const metadata = resourceData?.metadata as KubernetesMetadata | undefined; + const compositionResourceName = + metadata?.annotations?.['crossplane.io/composition-resource-name']; + + if (!compositionResourceName) { + throw new Error( + `Resource '${key}' is missing required annotation 'crossplane.io/composition-resource-name'` + ); + } + } +} + /** * Run all assertions for a test case */ export function assertTestCase(response: RunFunctionResponse, testCase: TestCase) { + // Always assert that all resources have composition-resource-name annotation + assertCompositionResourceNames(response); + if (testCase.expected.resources) { assertResources(response, testCase.expected.resources); } diff --git a/test-cases/basic-app.yaml b/test-cases/basic-app.yaml deleted file mode 100644 index d0f79bf..0000000 --- a/test-cases/basic-app.yaml +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: Basic Application Resources -description: Test basic Deployment and Pod creation - -input: - observed: - composite: - resource: - apiVersion: example.crossplane.io/v1alpha1 - kind: App - metadata: - name: test-app - namespace: default - spec: - parameters: - name: test-app - -expected: - resourceCount: 2 - resourceTypes: - - Deployment - - Pod - - resources: - deployment: - kind: Deployment - apiVersion: apps/v1 - metadata: - namespace: foo - spec: - replicas: 3 - selector: - matchLabels: - app: my-app - template: - metadata: - labels: - app: my-app - spec: - containers: - - name: my-container - image: my-image:latest - ports: - - containerPort: 80 - - pod: - kind: Pod - apiVersion: v1 - metadata: - namespace: default - spec: - containers: [] diff --git a/test-cases/example-full.yaml b/test-cases/example-full.yaml new file mode 100644 index 0000000..35c4188 --- /dev/null +++ b/test-cases/example-full.yaml @@ -0,0 +1,185 @@ +--- +name: Full Example +description: Test most input fields + +xrPath: examples/app/example-full.yaml + +expected: + resourceCount: 4 + resourceTypes: + - ServiceAccount + - Service + - Deployment + - Ingress + + resources: + serviceaccount: + kind: ServiceAccount + apiVersion: v1 + metadata: + name: my-service-account + labels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: serviceaccount + eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/my-role" + automountServiceAccountToken: true + + service: + kind: Service + apiVersion: v1 + metadata: + labels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: service + spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + + deployment: + kind: Deployment + apiVersion: apps/v1 + metadata: + labels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: deployment + prometheus.io/scrape: "true" + prometheus.io/port: "80" + spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + template: + metadata: + labels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + environment: production + tier: frontend + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "80" + spec: + serviceAccountName: my-service-account + securityContext: + fsGroup: 2000 + nodeSelector: + kubernetes.io/arch: amd64 + node-type: compute + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gpu" + effect: "NoSchedule" + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + volumes: + - name: config-volume + configMap: + name: my-config + - name: secret-volume + secret: + secretName: my-secret + optional: false + containers: + - name: example-full + image: nginx:1.21 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + volumeMounts: + - name: config-volume + mountPath: "/etc/config" + readOnly: true + - name: secret-volume + mountPath: "/etc/secrets" + readOnly: true + + ingress: + kind: Ingress + apiVersion: networking.k8s.io/v1 + metadata: + labels: + app.kubernetes.io/name: example-full + app.kubernetes.io/instance: example-full + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: ingress + kubernetes.io/ingress.class: nginx + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + spec: + ingressClassName: nginx + rules: + - host: chart-example.local + http: + paths: + - path: / + pathType: ImplementationSpecific + backend: + service: + name: example-full + port: + number: 80 + - host: api.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: example-full + port: + number: 80 From afc8f159ac15128d4330b6e1dafc780e69628fea Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 31 Jan 2026 17:04:38 +0000 Subject: [PATCH 2/8] update testing and rendering locations Signed-off-by: Steven Borrelli --- README.md | 322 ++++++++++++-- examples/apps/example-full.yaml | 142 ++++++ examples/functions.yaml | 18 + .../apis/apps/definition.yaml | 415 +++++++++--------- test-cases/README.md | 62 ++- test-cases/example-full.yaml | 2 +- 6 files changed, 716 insertions(+), 245 deletions(-) create mode 100644 examples/apps/example-full.yaml create mode 100644 examples/functions.yaml diff --git a/README.md b/README.md index 9726430..0f1da5d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,53 @@ -# Crossplane Function Template - TypeScript - -A template for building Crossplane composition functions in TypeScript using the [@crossplane-org/function-sdk-typescript](https://github.com/upbound/function-sdk-typescript). +# Crossplane Function Template - TypeScript + +This repository is a template for building Crossplane composition functions in TypeScript using the [@crossplane-org/function-sdk-typescript](https://github.com/upbound/function-sdk-typescript). + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Project Structure](#project-structure) +- [Installation](#installation) +- [Development](#development) + - [Build TypeScript](#build-typescript) + - [Type Checking](#type-checking) + - [Testing](#testing) + - [Linting and Formatting](#linting-and-formatting) + - [Running Locally](#running-locally) + - [Available CLI Options](#available-cli-options) +- [Building and Packaging](#building-and-packaging) + - [Local Docker Build](#local-docker-build) + - [Build the Crossplane Function Package](#build-the-crossplane-function-package) + - [Update the Function Package Metadata](#update-the-function-package-metadata) + - [Building the Function Package](#building-the-function-package) + - [Configuration Package](#configuration-package) + - [Updating the `crossplane.yaml` File](#updating-the-crossplaneyaml-file) + - [Build the Configuration Package](#build-the-configuration-package) +- [Implementation Guide](#implementation-guide) + - [Creating Your Function](#creating-your-function) + - [Key SDK Functions](#key-sdk-functions) + - [Example: Creating a Resource](#example-creating-a-resource) + - [Using Kubernetes Models](#using-kubernetes-models) + - [Testing Your Function](#testing-your-function) +- [TypeScript Configuration](#typescript-configuration) +- [GitHub Actions](#github-actions) + - [CI Workflow (ci.yaml)](#ci-workflow-ciyaml) + - [Tag Workflow (tag.yml)](#tag-workflow-tagyml) +- [Dependencies](#dependencies) + - [Production Dependencies](#production-dependencies) + - [Dev Dependencies](#dev-dependencies) +- [Notes](#notes) +- [Troubleshooting](#troubleshooting) + - [TypeScript Compilation Errors](#typescript-compilation-errors) + - [Test Failures](#test-failures) + - [Docker Build Failures](#docker-build-failures) +- [License](#license) +- [Author](#author) ## Overview -This template provides a starting point for developing Crossplane functions that can transform, validate, and generate Kubernetes resources within Crossplane compositions. The example function creates sample Deployment and Pod resources. +This template provides a full Typescript project for developing Crossplane functions that can transform, validate, and generate Kubernetes resources within Crossplane compositions. + +The initial [src/function.ts](src/function.ts) creates sample Deployment, Ingress, Service, and ServiceAccount resources and can be customized to +create any type of Kubernetes resource. ## Prerequisites @@ -17,25 +60,40 @@ This template provides a starting point for developing Crossplane functions that ```text . -├── src/ # Source files -│ ├── function.ts # Main function implementation -│ ├── function.test.ts # Function tests -│ ├── test-helpers.ts # Test utilities for loading YAML test cases -│ └── main.ts # Entry point and server setup -├── test-cases/ # YAML-based test cases -│ └── basic-app.yaml # Example test case -├── scripts/ # Build and deployment scripts +├── src/ # Source files +│ ├── function.ts # Main function implementation +│ ├── function.test.ts # Function tests +│ ├── test-helpers.ts # Test utilities for loading YAML test cases +│ └── main.ts # Entry point and server setup +├── test-cases/ # YAML-based test cases +│ ├── README.md # Test case documentation +│ └── example-full.yaml # Example test case +├── examples/ # Example Crossplane resources +│ ├── app/ # Example application resources +│ └── functions.yaml # Function pipeline configuration +├── scripts/ # Build and deployment scripts │ ├── function-docker-build.sh │ ├── function-xpkg-build.sh │ ├── function-xpkg-push.sh │ ├── configuration-xpkg-build.sh │ └── configuration-xpkg-push.sh -├── package.json # Dependencies and scripts -├── tsconfig.json # TypeScript configuration -├── jest.config.js # Jest test configuration -├── eslint.config.js # ESLint configuration -├── .prettierrc.json # Prettier configuration -└── Dockerfile # Container image definition +├── package-configuration/ # Configuration package metadata +│ ├── apis # Crossplane Composition Files +│ │ └── apps # Directory for the Kubernetes App Kind +│ │ ├── composition.yaml # Crossplane Composition Pipeline Definition +│ │ └── definition.yaml # Crossplane CompositeResourceDefinition +│ └── crossplane.yaml # Configuration package manifest +├── package-function/ # Function package metadata +│ └── crossplane.yaml # Function package manifest +├── dist/ # Compiled JavaScript output (generated) +├── _build/ # Build artifacts (generated) +├── package.json # Dependencies and scripts +├── tsconfig.json # TypeScript configuration +├── tsconfig.eslint.json # TypeScript ESLint configuration +├── jest.config.js # Jest test configuration +├── eslint.config.js # ESLint configuration +├── env # Environment variables for build scripts +└── Dockerfile # Container image definition ``` ## Installation @@ -81,7 +139,7 @@ Run tests using Jest: npm test ``` -Tests are written in [src/function.test.ts](src/function.test.ts) and use YAML-based test cases from the [test-cases/](test-cases/) directory. +TypeScript tests are written in [src/function.test.ts](src/function.test.ts). YAML-based test cases can be created using the [test-cases/](test-cases/) directory. See [test-cases/README.md](test-cases/README.md) for more information on creating tests. Example Crossplane resources for testing are provided in the [examples/](examples/) directory. ### Linting and Formatting @@ -111,6 +169,12 @@ npm run local node dist/main.js --insecure --debug ``` +Once the function is running locally, `crossplane render` can be used to render examples: + +```shell +crossplane render examples/app/example-full.yaml package-configuration/apis/apps/composition.yaml examples/functions.yaml +``` + ### Available CLI Options - `--address` - Address to listen for gRPC connections (default: `0.0.0.0:9443`) @@ -120,31 +184,94 @@ node dist/main.js --insecure --debug ## Building and Packaging -### Docker Build +[Crossplane Packages](https://docs.crossplane.io/master/packages/) are used to deploy +the Function and and dependencies to a Crossplane environment. Each of the package types serves a distinct purpose: + +- Configuration Packages contain the API (Composite Resource Definition) and Composition (Pipeline Steps). Configuration packages can pull in other packages types as dependencies. +- The Function Package contains the TypeScript files bundled in a runnable Node Docker container. +- Optional Packages that contain support for managing external APIs like GCP, AWS, and Azure. + +```mermaid +flowchart LR + subgraph CP [Configuration Package] + XRD(CompositeResourceDefinition XRD) + C(Composition) + end + CP -->|dependsOn| FP + subgraph FP [Function Package] + ED[Embedded Docker image] + end + CP -->|dependsOn| Providers + subgraph Providers [Optional Providers] + AWS[provider-upjet-aws] + SQL[provider-sql] + end +``` + +### Local Docker Build + +This template repository includes [Github Actions](#github-actions) for building and pushing the images. -Build the container image: +Scripts are provided that can also be run via npm +to build and publish packages. + +To build the docker image run: ```bash npm run function-docker-build -# or -docker build -t function-template-typescript . +``` + +The images will be saved as `tar` files that can be +packed into a Function Package: + +```shell +tree _build/docker_images +_build/docker_images +├── function-template-typescript-function-runtime-amd64-v0.1.0.tar +└── function-template-typescript-function-runtime-arm64-v0.1.0.tar ``` The Dockerfile uses a multi-stage build: -1. **Build stage**: Uses `node:25` to install dependencies and compile TypeScript -2. **Runtime stage**: Uses `gcr.io/distroless/nodejs24-debian12` for a minimal, secure runtime +1. **Build stage**: Uses `node:24` (LTS) to install dependencies and compile TypeScript +2. **Runtime stage**: Uses `gcr.io/distroless/nodejs24-debian12` for a minimal, secure runtime that includes the compiled TypeScript source. Refer to the [`scripts`](./scripts/) directory for examples of multi-platform builds. -### Package as Crossplane Function +### Build the Crossplane Function Package + +Now that runnable Docker images have been generated, they +can be embedded into a Function package. + +#### Update the Function Package Metadata + +First Update the Function Package [`crossplane.yaml`](package-function/crossplane.yaml) to the name of the Function. + +Update the `metadata.name` and `metadata.annotations` in the `crossplane.yaml` file. + +#### Building the Function Package Build the function as a Crossplane package (xpkg): ```bash # Build the function package npm run function-xpkg-build +``` + +Function packages will be generated for arm64 and amd64 in +the `_build/xpkg` directory: +```shell +$ tree _build/xpkg +_build/xpkg +├── function-template-typescript-function-amd64-v0.1.0.xpkg +└── function-template-typescript-function-arm64-v0.1.0.xpkg +``` + +These packages can be pushed to any Docker Registry using `crossplane xpkg push`. Update the `XPKG_REPO` in the [env](env) +file to change the target repository. + +```shell # Push to a registry npm run function-xpkg-push @@ -154,17 +281,82 @@ npm run function-build-all ### Configuration Package -Build a Crossplane configuration package: +With the Function package created, the Configuration +Package can be generated. This package will install the Function +Package as a dependency. + +#### Updating the `crossplane.yaml` File + +First Update the Configuration Package [`crossplane.yaml`](package-configuration/crossplane.yaml) to the name of the Configuration. + +Update the `metadata.name` and `metadata.annotations` in the `crossplane.yaml` file. + +Next update the `spec.dependsOn` field to include the function Docker image and any other dependencies, like [function-auto-ready](https://github.com/crossplane-contrib/function-auto-ready). + +```yaml +spec: + dependsOn: + - apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.upbound.io/crossplane-contrib/function-auto-ready + version: '>=v0.6.0' + # Make this match your function + - apiVersion: pkg.crossplane.io/v1 + kind: Function + package: xpkg.upbound.io/crossplane/function-template-typescript-function + version: '>=v0.1.0' +``` + +A Crossplane Composition requires a `CompositeResourceDefinition` (XRD) and `Composite`. These +are located in the [package-configuration/apis](package-configuration/apis) directory. + +Since the Kind in the template function is an `App`, we create a subdirectory `apps`. + +- [package-configuration/apis/apps/definition.yaml](package-configuration/apis/apps/definition.yaml) contains the XRD definition. +- [package-configuration/apis/apps/composition.yaml](package-configuration/apis/apps/composition.yaml) contains the Composition pipeline. + +Update the `composition.yaml` file to have the functionRef of the first pipeline step to refer to the name +of the function once it is installed. Crossplane creates a function name of `-`, +so `xpkg.upbound.io/upbound/function-template-typescript-function` would have a `functionRef.name` of +`upbound-function-template-typescript-function`. + +Update the value with the name that represents the Docker registry and image where the function was pushed. + +```yaml + - functionRef: + name: upbound-function-template-typescript-function + step: app +``` + +#### Build the Configuration Package + +Build the Crossplane configuration package: ```bash # Build configuration package npm run configuration-xpkg-build +``` + +The `_build/xpkg` directory will contain the multi-platform function +images and the Configuration package image: +```shell +tree _build/xpkg +_build/xpkg +├── function-template-typescript-function-amd64-v0.1.0.xpkg +├── function-template-typescript-function-arm64-v0.1.0.xpkg +└── function-template-typescript-v0.1.0.xpkg +``` + +Push this package to a Docker registry: + +```shell # Push configuration package npm run configuration-xpkg-push ``` -All build scripts are located in the [scripts/](scripts/) directory and can be customized for your needs. +Local build scripts are located in the [scripts/](scripts/) directory and can be customized. Common settings are contained +in the [`env`](env) file. ## Implementation Guide @@ -254,7 +446,7 @@ Create YAML test cases in the [test-cases/](test-cases/) directory. Each test ca - Input: The observed composite resource and context - Expected: Resource counts, types, and validation rules -See [test-cases/basic-app.yaml](test-cases/basic-app.yaml) for an example. Tests use [src/test-helpers.ts](src/test-helpers.ts) to load and validate YAML test cases. +See [test-cases/example-full.yaml](test-cases/example-full.yaml) for an example. Tests use [src/test-helpers.ts](src/test-helpers.ts) to load and validate YAML test cases. ## TypeScript Configuration @@ -267,6 +459,78 @@ This template uses strict TypeScript settings: The SDK directory is excluded from compilation to avoid conflicts with different TypeScript settings. +## GitHub Actions + +This project includes automated CI/CD workflows in the [.github/workflows/](.github/workflows/) directory: + +### CI Workflow ([ci.yaml](.github/workflows/ci.yaml)) + +The main CI workflow runs automatically on: + +- Pushes to `main` or `release-*` branches +- Pull requests +- Manual dispatch with optional version override + +**Jobs:** + +1. **version** - Computes the package version + - Uses `npm pkg get version` from package.json + - Generates pseudo-version: `v{version}-{timestamp}-{git-sha}` (e.g., `v0.1.1-20231101115142-1091066df799`) + - Can be overridden with manual workflow dispatch input + +2. **lint** - Code quality checks + - Runs `npm run lint` using ESLint + - Validates code style and catches common errors + +3. **test** - Runs the test suite + - Executes `npm test` with Jest + - Validates function logic and YAML test cases + +4. **check-types** - TypeScript type checking + - Runs `npm run check-types` + - Ensures type safety without emitting files + +5. **build-configuration-package** - Builds the Crossplane configuration package + - Uses Crossplane CLI to build the configuration from [package-configuration/](package-configuration/) directory + - Uploads the configuration `.xpkg` as an artifact + +6. **build-function-packages** - Builds function packages for multiple architectures + - Builds Docker images for both `amd64` and `arm64` architectures + - Uses Docker Buildx with QEMU for cross-platform builds + - Leverages GitHub Actions cache for faster builds + - Embeds runtime images into Crossplane function packages (`.xpkg`) + - Uploads architecture-specific packages as artifacts + +7. **push** - Publishes packages to registries + - Downloads all built packages from previous jobs + - Pushes multi-platform function package to the configured OCI registry + - Pushes configuration package to the registry + - Only runs if `XPKG_ACCESS_ID` and `XPKG_TOKEN` secrets are configured + - Defaults to GitHub Container Registry (`ghcr.io`) + +**Configuration:** + +- Node.js 24 (LTS) +- Crossplane CLI (stable channel, current version) +- Can push to Upbound registry or any OCI-compatible registry + +### Tag Workflow ([tag.yml](.github/workflows/tag.yml)) + +Manual workflow for creating Git tags: + +- Triggered via workflow dispatch only +- Requires version (e.g., `v0.1.0`) and message inputs +- Creates an annotated Git tag using the provided information +- Useful for marking releases + +**Usage:** + +1. Go to Actions tab in GitHub +2. Select "Tag" workflow +3. Click "Run workflow" +4. Enter version and tag message +5. Confirm to create the tag + ## Dependencies ### Production Dependencies diff --git a/examples/apps/example-full.yaml b/examples/apps/example-full.yaml new file mode 100644 index 0000000..90e9e31 --- /dev/null +++ b/examples/apps/example-full.yaml @@ -0,0 +1,142 @@ +apiVersion: platform.upbound.io/v1 +kind: App +metadata: + name: example-full + namespace: test-namespace +spec: + parameters: + deployment: + replicaCount: 2 + + image: + repository: nginx + pullPolicy: IfNotPresent + tag: "1.21" + + podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "80" + podLabels: + environment: "production" + tier: "frontend" + + podSecurityContext: + fsGroup: 2000 + + securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + + # Additional volumes on the output Deployment definition. + volumes: + - name: config-volume + configMap: + name: my-config + - name: secret-volume + secret: + secretName: my-secret + optional: false + + # Additional volumeMounts on the output Deployment definition. + volumeMounts: + - name: config-volume + mountPath: "/etc/config" + readOnly: true + - name: secret-volume + mountPath: "/etc/secrets" + readOnly: true + + nodeSelector: + kubernetes.io/arch: amd64 + node-type: compute + + tolerations: + - key: "dedicated" + operator: "Equal" + value: "gpu" + effect: "NoSchedule" + + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: + - amd64 + + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + + imagePullSecrets: [] + nameOverride: "" + fullnameOverride: "" + + serviceAccount: + # Specifies whether a service account should be created (must be explicitly set to true) + create: true + # Automatically mount a ServiceAccount's API credentials? (defaults to true if not specified) + automount: true + # Annotations to add to the service account + annotations: + eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/my-role" + # The name of the service account to use (defaults to app name if not specified) + name: "my-service-account" + + # Service configuration - omit this entire section to skip creating a Service + service: + type: ClusterIP + port: 80 + + # Ingress configuration - omit this entire section to skip creating an Ingress + ingress: + className: "nginx" + annotations: + kubernetes.io/ingress.class: nginx + kubernetes.io/tls-acme: "true" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + - host: api.example.com + paths: + - path: /api + pathType: Prefix + tls: + - secretName: chart-example-tls + hosts: + - chart-example.local + - secretName: api-example-tls + hosts: + - api.example.com diff --git a/examples/functions.yaml b/examples/functions.yaml new file mode 100644 index 0000000..c2b77ae --- /dev/null +++ b/examples/functions.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: upbound-function-template-typescript-function + annotations: + # This tells crossplane beta render to connect to the function locally. + render.crossplane.io/runtime: Development +spec: + package: xpkg.upbound.io/upbound/function-template-typescript:v0.1.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: crossplane-contrib-function-auto-ready +spec: + # Use auto-ready that supports k8s resources + package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.6.0 diff --git a/package-configuration/apis/apps/definition.yaml b/package-configuration/apis/apps/definition.yaml index 8488db1..453b560 100644 --- a/package-configuration/apis/apps/definition.yaml +++ b/package-configuration/apis/apps/definition.yaml @@ -22,247 +22,236 @@ spec: properties: parameters: properties: - workloads: + deployment: properties: - app1: + replicaCount: + type: number + image: properties: - affinity: - properties: - nodeAffinity: - properties: - requiredDuringSchedulingIgnoredDuringExecution: - properties: - nodeSelectorTerms: - items: - properties: - matchExpressions: - items: - properties: - key: - type: string - operator: - type: string - values: - items: - type: string - type: array - type: object - type: array - type: object - type: array - type: object - type: object - type: object - autoscaling: - properties: - enabled: - type: boolean - maxReplicas: - type: number - minReplicas: - type: number - targetCPUUtilizationPercentage: - type: number - type: object - fullnameOverride: + repository: type: string - image: - properties: - pullPolicy: - type: string - repository: - type: string - tag: - type: string - type: object - imagePullSecrets: - items: - type: object - type: array - ingress: + pullPolicy: + type: string + tag: + type: string + type: object + podAnnotations: + additionalProperties: + type: string + type: object + podLabels: + additionalProperties: + type: string + type: object + podSecurityContext: + properties: + fsGroup: + type: integer + type: object + securityContext: + properties: + capabilities: properties: - annotations: - additionalProperties: - type: string - type: object - className: - type: string - enabled: - type: boolean - hosts: + drop: items: - properties: - host: - type: string - paths: - items: - properties: - path: - type: string - pathType: - type: string - type: object - type: array - type: object - type: array - tls: - items: - properties: - hosts: - items: - type: string - type: array - secretName: - type: string - type: object + type: string type: array type: object - livenessProbe: - properties: - httpGet: - properties: - path: - type: string - port: - type: string - type: object - initialDelaySeconds: - type: integer - periodSeconds: - type: integer - type: object - nameOverride: - type: string - namespace: - type: string - nodeSelector: - additionalProperties: - type: string - type: object - podAnnotations: + readOnlyRootFilesystem: + type: boolean + runAsNonRoot: + type: boolean + runAsUser: + type: integer + type: object + resources: + properties: + limits: additionalProperties: type: string type: object - podLabels: + requests: additionalProperties: type: string type: object - podSecurityContext: - properties: - fsGroup: - type: integer - type: object - providerConfigName: - description: Name of the ProviderConfig to use for Kubernetes resources - type: string - default: "default" - readinessProbe: - properties: - httpGet: - properties: - path: - type: string - port: - type: string - type: object - initialDelaySeconds: - type: integer - periodSeconds: - type: integer - type: object - replicaCount: - type: number - resources: - properties: - limits: - additionalProperties: - type: string - type: object - requests: - additionalProperties: - type: string - type: object - type: object - securityContext: - properties: - capabilities: - properties: - drop: - items: - type: string - type: array - type: object - readOnlyRootFilesystem: - type: boolean - runAsNonRoot: - type: boolean - runAsUser: - type: integer - type: object - service: + type: object + livenessProbe: + properties: + httpGet: properties: + path: + type: string port: - type: number - type: type: string type: object - serviceAccount: + initialDelaySeconds: + type: integer + periodSeconds: + type: integer + type: object + readinessProbe: + properties: + httpGet: properties: - annotations: - additionalProperties: - type: string - type: object - automount: - type: boolean - create: - type: boolean - name: + path: + type: string + port: type: string type: object - tolerations: - items: - properties: - effect: - type: string - key: - type: string - operator: - type: string - value: - type: string - type: object - type: array - volumeMounts: - items: + initialDelaySeconds: + type: integer + periodSeconds: + type: integer + type: object + volumes: + items: + properties: + name: + type: string + configMap: properties: - mountPath: - type: string name: type: string - readOnly: - type: boolean type: object - type: array - volumes: - items: + secret: properties: - configMap: - properties: - name: - type: string - type: object - name: + secretName: type: string - secret: - properties: - optional: - type: boolean - secretName: - type: string - type: object + optional: + type: boolean type: object - type: array + type: object + type: array + volumeMounts: + items: + properties: + name: + type: string + mountPath: + type: string + readOnly: + type: boolean + type: object + type: array + nodeSelector: + additionalProperties: + type: string type: object + tolerations: + items: + properties: + key: + type: string + operator: + type: string + value: + type: string + effect: + type: string + type: object + type: array + affinity: + properties: + nodeAffinity: + properties: + requiredDuringSchedulingIgnoredDuringExecution: + properties: + nodeSelectorTerms: + items: + properties: + matchExpressions: + items: + properties: + key: + type: string + operator: + type: string + values: + items: + type: string + type: array + type: object + type: array + type: object + type: array + type: object + type: object + type: object + autoscaling: + properties: + enabled: + type: boolean + minReplicas: + type: number + maxReplicas: + type: number + targetCPUUtilizationPercentage: + type: number + type: object + type: object + imagePullSecrets: + items: + type: object + type: array + nameOverride: + type: string + fullnameOverride: + type: string + serviceAccount: + properties: + create: + type: boolean + automount: + type: boolean + annotations: + additionalProperties: + type: string + type: object + name: + type: string + type: object + service: + properties: + type: + type: string + port: + type: number + type: object + ingress: + properties: + className: + type: string + annotations: + additionalProperties: + type: string + type: object + hosts: + items: + properties: + host: + type: string + paths: + items: + properties: + path: + type: string + pathType: + type: string + type: object + type: array + type: object + type: array + tls: + items: + properties: + secretName: + type: string + hosts: + items: + type: string + type: array + type: object + type: array type: object type: object type: object diff --git a/test-cases/README.md b/test-cases/README.md index 696212d..2a3d59f 100644 --- a/test-cases/README.md +++ b/test-cases/README.md @@ -20,7 +20,49 @@ npm test -- --watch Each test case file should define one or more test cases with the following structure: -### YAML Format +### Input Options + +Test cases support two ways to provide the composite resource input: + +1. **Using `xrPath`** (recommended for reusing example files): + - Reference an external file containing the composite resource + - Keeps test cases clean and allows reuse of example resources + +2. **Using `input.observed`** (inline definition): + - Define the composite resource directly in the test case + - Useful for small test cases or when you need full control + +### YAML Format with xrPath + +```yaml +--- +name: Test Case Name +description: Optional description of what this test validates + +# Load the composite resource from an external file +xrPath: examples/apps/example-full.yaml + +expected: + # Expected number of resources to be created + resourceCount: 4 + + # Expected resource types (partial list) + resourceTypes: + - Deployment + - Service + - ServiceAccount + - Ingress + + # Specific resource assertions (partial match) - map format + resources: + deployment: + kind: Deployment + apiVersion: apps/v1 + spec: + replicas: 2 +``` + +### YAML Format with Inline Input ```yaml --- @@ -88,7 +130,21 @@ expected: ready: true ``` -### JSON Format +### JSON Format with xrPath + +```json +{ + "name": "Test Case Name", + "description": "Optional description", + "xrPath": "examples/app/example-full.yaml", + "expected": { + "resourceCount": 4, + "resourceTypes": ["Deployment", "Service", "ServiceAccount", "Ingress"] + } +} +``` + +### JSON Format with Inline Input ```json { @@ -246,6 +302,8 @@ This simulates the scenario where: ### Tips +- **Use `xrPath`** to reference existing example files - this keeps tests clean and promotes reuse +- **Use inline `input`** for small test cases or when you need to test specific edge cases - Use partial matching to focus on the most important assertions - Group related test cases in the same file (multiple YAML documents or JSON array) - Use descriptive names and descriptions to document what each test validates diff --git a/test-cases/example-full.yaml b/test-cases/example-full.yaml index 35c4188..db43c22 100644 --- a/test-cases/example-full.yaml +++ b/test-cases/example-full.yaml @@ -2,7 +2,7 @@ name: Full Example description: Test most input fields -xrPath: examples/app/example-full.yaml +xrPath: examples/apps/example-full.yaml expected: resourceCount: 4 From 1e8c35c8c445b30e5d13eb5e1406b6188a90d4f9 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sun, 1 Feb 2026 21:35:44 -0500 Subject: [PATCH 3/8] remove unused fields Signed-off-by: Steven Borrelli --- README.md | 8 ++++---- examples/apps/example-full.yaml | 6 ------ src/function.test.ts | 7 ++++++- src/function.ts | 33 ++++++++++++++++++++++++++++++++- test-cases/README.md | 2 +- test-cases/example-full.yaml | 7 +++++++ 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0f1da5d..88b6389 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ create any type of Kubernetes resource. │ ├── README.md # Test case documentation │ └── example-full.yaml # Example test case ├── examples/ # Example Crossplane resources -│ ├── app/ # Example application resources +│ ├── apps/ # Example application resources │ └── functions.yaml # Function pipeline configuration ├── scripts/ # Build and deployment scripts │ ├── function-docker-build.sh @@ -323,9 +323,9 @@ so `xpkg.upbound.io/upbound/function-template-typescript-function` would have a Update the value with the name that represents the Docker registry and image where the function was pushed. ```yaml - - functionRef: - name: upbound-function-template-typescript-function - step: app +- functionRef: + name: upbound-function-template-typescript-function + step: app ``` #### Build the Configuration Package diff --git a/examples/apps/example-full.yaml b/examples/apps/example-full.yaml index 90e9e31..5ef3b78 100644 --- a/examples/apps/example-full.yaml +++ b/examples/apps/example-full.yaml @@ -91,12 +91,6 @@ spec: values: - amd64 - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - imagePullSecrets: [] nameOverride: "" fullnameOverride: "" diff --git a/src/function.test.ts b/src/function.test.ts index 1950f0c..a3096fd 100644 --- a/src/function.test.ts +++ b/src/function.test.ts @@ -60,10 +60,15 @@ describe('Function', () => { composite: { resource: {}, }, + resources: {}, }, }; - const req = to(baseInput); + const req = to(baseInput) as any; + if (baseInput.observed) { + req.observed = baseInput.observed; + } + const response = await fn.RunFunction(req); expect(response).toBeDefined(); diff --git a/src/function.ts b/src/function.ts index 49b975a..1f95cf2 100644 --- a/src/function.ts +++ b/src/function.ts @@ -32,6 +32,14 @@ interface IngressHost { paths: IngressPath[]; } +/** + * Ingress TLS configuration + */ +interface IngressTLS { + secretName: string; + hosts: string[]; +} + /** * Function is a sample implementation showing how to use the SDK */ @@ -59,6 +67,10 @@ export class Function implements FunctionHandler { // Extract parameters from XR spec const name = observedComposite?.resource?.metadata?.name; + if (!name) { + fatal(rsp, 'Composite resource name is required'); + return rsp; + } const params = observedComposite?.resource?.spec?.parameters || {}; const deploymentConfig = params.deployment || {}; const imageConfig = deploymentConfig.image || {}; @@ -93,7 +105,7 @@ export class Function implements FunctionHandler { resource: serviceAccount.toJSON(), }); } - + // Create Service if config is provided if (serviceConfig && Object.keys(serviceConfig).length > 0) { const service = new Service({ @@ -242,6 +254,25 @@ export class Function implements FunctionHandler { }, })), }), + ...(ingressConfig.tls && + ingressConfig.tls.length > 0 && { + tls: ingressConfig.tls + .map((tlsEntry: IngressTLS) => { + // Validate that hosts are present for each TLS entry + if (!tlsEntry.hosts || tlsEntry.hosts.length === 0) { + logger?.warn( + { secretName: tlsEntry.secretName }, + 'TLS entry has no hosts defined, skipping' + ); + return null; + } + return { + secretName: tlsEntry.secretName, + hosts: tlsEntry.hosts, + }; + }) + .filter((entry: IngressTLS | null) => entry !== null), + }), }, }); diff --git a/test-cases/README.md b/test-cases/README.md index 2a3d59f..2c5ea96 100644 --- a/test-cases/README.md +++ b/test-cases/README.md @@ -136,7 +136,7 @@ expected: { "name": "Test Case Name", "description": "Optional description", - "xrPath": "examples/app/example-full.yaml", + "xrPath": "examples/apps/example-full.yaml", "expected": { "resourceCount": 4, "resourceTypes": ["Deployment", "Service", "ServiceAccount", "Ingress"] diff --git a/test-cases/example-full.yaml b/test-cases/example-full.yaml index db43c22..0a95cf1 100644 --- a/test-cases/example-full.yaml +++ b/test-cases/example-full.yaml @@ -183,3 +183,10 @@ expected: name: example-full port: number: 80 + # tls: + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + # - secretName: api-example-tls + # hosts: + # - api.example.com From 290ad611ae93b555ffa79378c4e34ad653e94452 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 5 Feb 2026 12:13:56 -0600 Subject: [PATCH 4/8] initial readme updates Signed-off-by: Steven Borrelli --- README.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 88b6389..1dcc81e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ This repository is a template for building Crossplane composition functions in TypeScript using the [@crossplane-org/function-sdk-typescript](https://github.com/upbound/function-sdk-typescript). - [Overview](#overview) -- [Prerequisites](#prerequisites) +- [Installing the Package](#installing-the-package) +- [Development Prerequisites](#development-prerequisites) - [Project Structure](#project-structure) - [Installation](#installation) - [Development](#development) @@ -49,12 +50,43 @@ This template provides a full Typescript project for developing Crossplane funct The initial [src/function.ts](src/function.ts) creates sample Deployment, Ingress, Service, and ServiceAccount resources and can be customized to create any type of Kubernetes resource. -## Prerequisites +## Installing the Package + +The template is can be deployed as a Crossplane package using a manifest. +The Configuration package will install the function package, which contains a +Node docker image and the source code as a dependency. + +```yaml +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: configuration-template-typescript +spec: + package: index.docker.io/steve/function-template-typescript:v0.1.0-alpha.2 +``` + +Once installed, confirm that the package an depe + +```shell +crossplane beta trace con +figuration.pkg configuration-template-typescript +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/configuration-template-typescript v0.1.0-alpha.3 True True - HealthyPackageRevision +├─ ConfigurationRevision/configuration-template-typescript-93b73b00eb21 v0.1.0-alpha.3 - - Active +├─ Function/crossplane-contrib-function-auto-ready v0.6.0 True True - HealthyPackageRevision +│ └─ FunctionRevision/crossplane-contrib-function-auto-ready-59868730b9a9 v0.6.0 - - Active +└─ Function/steve-function-template-typescript-function v0.1.0-alpha.3 True True - HealthyPackageRevision + └─ FunctionRevision/steve-function-template-typescript-function-cd83fe939bc7 v0.1.0-alpha.3 - +``` + +## Development Prerequisites + +To develop Compositions using Typescript, the following is recommended: - Node.js 24 or later recommended. - npm -- Docker (for building container images) -- TypeScript 5+ or TypeScript 7 (tsgo) +- Docker (for building the Node container image) +- Both TypeScript 5+ and TypeScript 7 (tsgo) are supported. ## Project Structure From f25453f71a128b085aa8eb0e057339d826325c63 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 6 Feb 2026 09:07:07 -0600 Subject: [PATCH 5/8] update functionRef and example Signed-off-by: Steven Borrelli --- env | 4 +- examples/apps/example.yaml | 130 ++++++++++++++++++++++++++ package-configuration/crossplane.yaml | 2 +- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 examples/apps/example.yaml diff --git a/env b/env index c099df8..5f003f9 100644 --- a/env +++ b/env @@ -3,8 +3,8 @@ CONFIGURATION_NAME=$(npm pkg get name | tr -d '"') FN_NAME=${CONFIGURATION_NAME}-function # Change this to your docker repo -XPKG_REPO=ghcr.io/crossplane - +# XPKG_REPO=ghcr.io/crossplane +XPKG_REPO=xpkg.upbound.io/upbound BUILD_PLATFORMS="amd64 arm64" BUILD_DIR=_build DOCKER_IMAGE_DIR=${BUILD_DIR}/docker_images diff --git a/examples/apps/example.yaml b/examples/apps/example.yaml new file mode 100644 index 0000000..69efb14 --- /dev/null +++ b/examples/apps/example.yaml @@ -0,0 +1,130 @@ + +apiVersion: v1 +kind: Namespace +metadata: + name: example +--- +apiVersion: platform.upbound.io/v1 +kind: App +metadata: + name: hello-app + namespace: example +spec: + parameters: + deployment: + replicaCount: 1 + + image: + repository: gcr.io/google-samples/hello-app + pullPolicy: IfNotPresent + tag: "1.0" + + podAnnotations: + example.crossplane.io/managed-by: "function-template-typescript" + podLabels: + environment: "demo" + tier: "frontend" + + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + + # Customize the Deployment + # # Additional volumes on the output Deployment definition. + # volumes: + # - name: config-volume + # configMap: + # name: my-config + # - name: secret-volume + # secret: + # secretName: my-secret + # optional: false + + # # Additional volumeMounts on the output Deployment definition. + # volumeMounts: + # - name: config-volume + # mountPath: "/etc/config" + # readOnly: true + # - name: secret-volume + # mountPath: "/etc/secrets" + # readOnly: true + + # nodeSelector: + # kubernetes.io/arch: amd64 + # node-type: compute + + # tolerations: + # - key: "dedicated" + # operator: "Equal" + # value: "gpu" + # effect: "NoSchedule" + + # affinity: + # nodeAffinity: + # requiredDuringSchedulingIgnoredDuringExecution: + # nodeSelectorTerms: + # - matchExpressions: + # - key: kubernetes.io/arch + # operator: In + # values: + # - amd64 + + imagePullSecrets: [] + nameOverride: "" + fullnameOverride: "" + + serviceAccount: + # Specifies whether a service account should be created (must be explicitly set to true) + create: true + # Automatically mount a ServiceAccount's API credentials? (defaults to true if not specified) + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use (defaults to app name if not specified) + name: "my-service-account" + + # Service configuration - omit this entire section to skip creating a Service + service: + type: ClusterIP + port: 8080 + + # Ingress configuration - omit this entire section to skip creating an Ingress + # ingress: + # className: "nginx" + # annotations: + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + # cert-manager.io/cluster-issuer: "letsencrypt-prod" + # hosts: + # - host: chart-example.local + # paths: + # - path: / + # pathType: ImplementationSpecific + # - host: api.example.com + # paths: + # - path: /api + # pathType: Prefix + # tls: + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + # - secretName: api-example-tls + # hosts: + # - api.example.com diff --git a/package-configuration/crossplane.yaml b/package-configuration/crossplane.yaml index 0497eb5..2924438 100644 --- a/package-configuration/crossplane.yaml +++ b/package-configuration/crossplane.yaml @@ -30,5 +30,5 @@ spec: # Make this match your function - apiVersion: pkg.crossplane.io/v1 kind: Function - package: xpkg.upbound.io/crossplane/function-template-typescript-function + package: xpkg.upbound.io/upbound/function-template-typescript-function version: ">=v0.1.0" From ad1e4c271693250d3f520ea4862edc2cfb654080 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 6 Feb 2026 09:08:05 -0600 Subject: [PATCH 6/8] fix vuln from dependabot Signed-off-by: Steven Borrelli --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a15f70..a654f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -892,9 +892,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { From c3a3965619bb5a7e0024aa7643dda2978a37a7b5 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 6 Feb 2026 09:11:31 -0600 Subject: [PATCH 7/8] remove nonworking example Signed-off-by: Steven Borrelli --- examples/apps/example-full.yaml | 136 -------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 examples/apps/example-full.yaml diff --git a/examples/apps/example-full.yaml b/examples/apps/example-full.yaml deleted file mode 100644 index 5ef3b78..0000000 --- a/examples/apps/example-full.yaml +++ /dev/null @@ -1,136 +0,0 @@ -apiVersion: platform.upbound.io/v1 -kind: App -metadata: - name: example-full - namespace: test-namespace -spec: - parameters: - deployment: - replicaCount: 2 - - image: - repository: nginx - pullPolicy: IfNotPresent - tag: "1.21" - - podAnnotations: - prometheus.io/scrape: "true" - prometheus.io/port: "80" - podLabels: - environment: "production" - tier: "frontend" - - podSecurityContext: - fsGroup: 2000 - - securityContext: - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 1000 - - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - - # Additional volumes on the output Deployment definition. - volumes: - - name: config-volume - configMap: - name: my-config - - name: secret-volume - secret: - secretName: my-secret - optional: false - - # Additional volumeMounts on the output Deployment definition. - volumeMounts: - - name: config-volume - mountPath: "/etc/config" - readOnly: true - - name: secret-volume - mountPath: "/etc/secrets" - readOnly: true - - nodeSelector: - kubernetes.io/arch: amd64 - node-type: compute - - tolerations: - - key: "dedicated" - operator: "Equal" - value: "gpu" - effect: "NoSchedule" - - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: kubernetes.io/arch - operator: In - values: - - amd64 - - imagePullSecrets: [] - nameOverride: "" - fullnameOverride: "" - - serviceAccount: - # Specifies whether a service account should be created (must be explicitly set to true) - create: true - # Automatically mount a ServiceAccount's API credentials? (defaults to true if not specified) - automount: true - # Annotations to add to the service account - annotations: - eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/my-role" - # The name of the service account to use (defaults to app name if not specified) - name: "my-service-account" - - # Service configuration - omit this entire section to skip creating a Service - service: - type: ClusterIP - port: 80 - - # Ingress configuration - omit this entire section to skip creating an Ingress - ingress: - className: "nginx" - annotations: - kubernetes.io/ingress.class: nginx - kubernetes.io/tls-acme: "true" - cert-manager.io/cluster-issuer: "letsencrypt-prod" - hosts: - - host: chart-example.local - paths: - - path: / - pathType: ImplementationSpecific - - host: api.example.com - paths: - - path: /api - pathType: Prefix - tls: - - secretName: chart-example-tls - hosts: - - chart-example.local - - secretName: api-example-tls - hosts: - - api.example.com From 61881e40800f6b70016e6565412a99ecf302f429 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Fri, 6 Feb 2026 09:22:48 -0600 Subject: [PATCH 8/8] update test case Signed-off-by: Steven Borrelli --- README.md | 16 +-- examples/apps/example.yaml | 4 - examples/apps/ns.yaml | 5 + test-cases/example-full.yaml | 192 ----------------------------------- test-cases/example.yaml | 103 +++++++++++++++++++ 5 files changed, 116 insertions(+), 204 deletions(-) create mode 100644 examples/apps/ns.yaml delete mode 100644 test-cases/example-full.yaml create mode 100644 test-cases/example.yaml diff --git a/README.md b/README.md index 1dcc81e..65ecab5 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ kind: Configuration metadata: name: configuration-template-typescript spec: - package: index.docker.io/steve/function-template-typescript:v0.1.0-alpha.2 + package: xpkg.upbound.io/function-template-typescript:v0.1.0 ``` Once installed, confirm that the package an depe @@ -70,13 +70,13 @@ Once installed, confirm that the package an depe ```shell crossplane beta trace con figuration.pkg configuration-template-typescript -NAME VERSION INSTALLED HEALTHY STATE STATUS -Configuration/configuration-template-typescript v0.1.0-alpha.3 True True - HealthyPackageRevision -├─ ConfigurationRevision/configuration-template-typescript-93b73b00eb21 v0.1.0-alpha.3 - - Active -├─ Function/crossplane-contrib-function-auto-ready v0.6.0 True True - HealthyPackageRevision -│ └─ FunctionRevision/crossplane-contrib-function-auto-ready-59868730b9a9 v0.6.0 - - Active -└─ Function/steve-function-template-typescript-function v0.1.0-alpha.3 True True - HealthyPackageRevision - └─ FunctionRevision/steve-function-template-typescript-function-cd83fe939bc7 v0.1.0-alpha.3 - +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/configuration-template-typescript v0.1.0 True True - HealthyPackageRevision +├─ ConfigurationRevision/configuration-template-typescript-93b73b00eb21 v0.1.0 - - Active +├─ Function/crossplane-contrib-function-auto-ready v0.6.0 True True - HealthyPackageRevision +│ └─ FunctionRevision/crossplane-contrib-function-auto-ready-59868730b9a9 v0.6.0 - - Active +└─ Function/upbound-function-template-typescript-function v0.1.0 True True - HealthyPackageRevision + └─ FunctionRevision/upbound-function-template-typescript-function-cd83fe939bc7 v0.1.0 - ``` ## Development Prerequisites diff --git a/examples/apps/example.yaml b/examples/apps/example.yaml index 69efb14..94cfb52 100644 --- a/examples/apps/example.yaml +++ b/examples/apps/example.yaml @@ -1,8 +1,4 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: example --- apiVersion: platform.upbound.io/v1 kind: App diff --git a/examples/apps/ns.yaml b/examples/apps/ns.yaml new file mode 100644 index 0000000..c556ff1 --- /dev/null +++ b/examples/apps/ns.yaml @@ -0,0 +1,5 @@ + +apiVersion: v1 +kind: Namespace +metadata: + name: example diff --git a/test-cases/example-full.yaml b/test-cases/example-full.yaml deleted file mode 100644 index 0a95cf1..0000000 --- a/test-cases/example-full.yaml +++ /dev/null @@ -1,192 +0,0 @@ ---- -name: Full Example -description: Test most input fields - -xrPath: examples/apps/example-full.yaml - -expected: - resourceCount: 4 - resourceTypes: - - ServiceAccount - - Service - - Deployment - - Ingress - - resources: - serviceaccount: - kind: ServiceAccount - apiVersion: v1 - metadata: - name: my-service-account - labels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - app.kubernetes.io/managed-by: crossplane - annotations: - crossplane.io/composition-resource-name: serviceaccount - eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/my-role" - automountServiceAccountToken: true - - service: - kind: Service - apiVersion: v1 - metadata: - labels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - app.kubernetes.io/managed-by: crossplane - annotations: - crossplane.io/composition-resource-name: service - spec: - type: ClusterIP - ports: - - port: 80 - targetPort: http - protocol: TCP - name: http - selector: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - - deployment: - kind: Deployment - apiVersion: apps/v1 - metadata: - labels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - app.kubernetes.io/managed-by: crossplane - annotations: - crossplane.io/composition-resource-name: deployment - prometheus.io/scrape: "true" - prometheus.io/port: "80" - spec: - replicas: 2 - selector: - matchLabels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - template: - metadata: - labels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - environment: production - tier: frontend - annotations: - prometheus.io/scrape: "true" - prometheus.io/port: "80" - spec: - serviceAccountName: my-service-account - securityContext: - fsGroup: 2000 - nodeSelector: - kubernetes.io/arch: amd64 - node-type: compute - tolerations: - - key: "dedicated" - operator: "Equal" - value: "gpu" - effect: "NoSchedule" - affinity: - nodeAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - nodeSelectorTerms: - - matchExpressions: - - key: kubernetes.io/arch - operator: In - values: - - amd64 - volumes: - - name: config-volume - configMap: - name: my-config - - name: secret-volume - secret: - secretName: my-secret - optional: false - containers: - - name: example-full - image: nginx:1.21 - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - securityContext: - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 1000 - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - livenessProbe: - httpGet: - path: /healthz - port: http - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /ready - port: http - initialDelaySeconds: 5 - periodSeconds: 5 - volumeMounts: - - name: config-volume - mountPath: "/etc/config" - readOnly: true - - name: secret-volume - mountPath: "/etc/secrets" - readOnly: true - - ingress: - kind: Ingress - apiVersion: networking.k8s.io/v1 - metadata: - labels: - app.kubernetes.io/name: example-full - app.kubernetes.io/instance: example-full - app.kubernetes.io/managed-by: crossplane - annotations: - crossplane.io/composition-resource-name: ingress - kubernetes.io/ingress.class: nginx - kubernetes.io/tls-acme: "true" - cert-manager.io/cluster-issuer: "letsencrypt-prod" - spec: - ingressClassName: nginx - rules: - - host: chart-example.local - http: - paths: - - path: / - pathType: ImplementationSpecific - backend: - service: - name: example-full - port: - number: 80 - - host: api.example.com - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: example-full - port: - number: 80 - # tls: - # - secretName: chart-example-tls - # hosts: - # - chart-example.local - # - secretName: api-example-tls - # hosts: - # - api.example.com diff --git a/test-cases/example.yaml b/test-cases/example.yaml new file mode 100644 index 0000000..4465e65 --- /dev/null +++ b/test-cases/example.yaml @@ -0,0 +1,103 @@ +--- +name: Full Example +description: Test example with service account and service + +xrPath: examples/apps/example.yaml + +expected: + resourceCount: 3 + resourceTypes: + - ServiceAccount + - Service + - Deployment + + resources: + serviceaccount: + kind: ServiceAccount + apiVersion: v1 + metadata: + name: my-service-account + labels: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: serviceaccount + automountServiceAccountToken: true + + service: + kind: Service + apiVersion: v1 + metadata: + labels: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: service + spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + + deployment: + kind: Deployment + apiVersion: apps/v1 + metadata: + labels: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + app.kubernetes.io/managed-by: crossplane + annotations: + crossplane.io/composition-resource-name: deployment + example.crossplane.io/managed-by: "function-template-typescript" + spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + template: + metadata: + labels: + app.kubernetes.io/name: hello-app + app.kubernetes.io/instance: hello-app + environment: "demo" + tier: "frontend" + annotations: + example.crossplane.io/managed-by: "function-template-typescript" + spec: + serviceAccountName: my-service-account + containers: + - name: hello-app + image: gcr.io/google-samples/hello-app:1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + protocol: TCP + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 5