Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@
// We only want renovate to rebase PRs when they have conflicts, default
// "auto" mode is not required.
rebaseWhen: 'conflicted',
// Custom managers for non-standard file formats
customManagers: [
{
// Manage npm dependencies in TypeScript function template
customType: 'regex',
fileMatch: [
'cmd/crossplane/function/templates/typescript/package\\.json\\.tmpl$',
],
matchStrings: [
'"(?<depName>@?[^"]+)":\\s*"\\^(?<currentValue>[^"]+)"',
],
datasourceTemplate: 'npm',
versioningTemplate: 'npm',
},
],
// The maximum number of PRs to be created in parallel
prConcurrentLimit: 5,
// The branches renovate should target
Expand Down
14 changes: 8 additions & 6 deletions apis/dev/v1alpha1/project_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ const (
// ProjectSchemas.Languages. Each corresponds to a schema generator in
// internal/schemas/generator.
const (
SchemaLanguageGo = "go"
SchemaLanguageJSON = "json"
SchemaLanguageKCL = "kcl"
SchemaLanguagePython = "python"
SchemaLanguageGo = "go"
SchemaLanguageJSON = "json"
SchemaLanguageKCL = "kcl"
SchemaLanguagePython = "python"
SchemaLanguageTypescript = "typescript"
)

// SupportedSchemaLanguages returns the set of language identifiers accepted
Expand All @@ -63,6 +64,7 @@ func SupportedSchemaLanguages() []string {
SchemaLanguageJSON,
SchemaLanguageKCL,
SchemaLanguagePython,
SchemaLanguageTypescript,
}
}

Expand Down Expand Up @@ -133,8 +135,8 @@ type ProjectPackageMetadata struct {
// produced both for the project's own XRDs and for its declared dependencies.
type ProjectSchemas struct {
// Languages restricts schema generation to the listed languages.
// Supported values are "go", "json", "kcl", and "python". If not
// specified, schemas are generated for all supported languages.
// If not specified, schemas are generated for all supported languages.
// +kubebuilder:validation:items:Enum=go;json;kcl;python;typescript
Languages []string `json:"languages,omitempty"`
}

Expand Down
56 changes: 55 additions & 1 deletion cmd/crossplane/function/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ var (
pythonTemplates embed.FS
//go:embed templates/go-templating/*
goTemplatingTemplates embed.FS
//go:embed all:templates/typescript
typescriptTemplates embed.FS

// The go template contains a go.mod, so we can't embed it as an
// embed.FS. Instead we have to embed it as a tar archive and extract it
Expand All @@ -69,7 +71,7 @@ var (
type generateCmd struct {
Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."`
PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""`
Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"`
Language string `default:"go-templating" enum:"go,go-templating,kcl,python,typescript" help:"Language to use for the function." short:"l"`
ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"`

projFS afero.Fs
Expand Down Expand Up @@ -173,6 +175,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error {
"go-templating": c.generateGoTemplatingFiles,
"kcl": c.generateKCLFiles,
"python": c.generatePythonFiles,
"typescript": c.generateTypescriptFiles,
}

generator, ok := generators[c.Language]
Expand Down Expand Up @@ -405,6 +408,57 @@ func (c *generateCmd) generateGoTemplatingFiles(fs afero.Fs) error {
return renderTemplates(fs, tmpls, tmplData)
}

type typescriptTemplateData struct {
HasSchemas bool
SchemasPath string
}

func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error {
hasSchemas, err := afero.DirExists(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot inspect typescript schemas directory")
}
if hasSchemas {
entries, err := afero.ReadDir(c.schemasFS, "typescript")
if err != nil {
return errors.Wrap(err, "cannot read typescript schemas directory")
}
hasSchemas = len(entries) > 0
}

// Compute the relative path from the function dir to schemas/typescript/.
fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name)
relRoot, err := filepath.Rel(fnDir, "/")
if err != nil {
return errors.Wrap(err, "cannot determine path to schemas directory")
}
schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "typescript"))

data := typescriptTemplateData{
HasSchemas: hasSchemas,
SchemasPath: schemasPath,
}

// Parse top-level templates
tmpls, err := template.ParseFS(typescriptTemplates, "templates/typescript/*.*")
if err != nil {
return errors.Wrap(err, "cannot parse top-level TypeScript templates")
}
if err := renderTemplates(targetFS, tmpls, data); err != nil {
return err
}

// Create src directory and parse src templates
if err := targetFS.Mkdir("src", 0o755); err != nil {
return errors.Wrap(err, "cannot create src directory")
}
tmpls, err = template.ParseFS(typescriptTemplates, "templates/typescript/src/*.*")
if err != nil {
return errors.Wrap(err, "cannot parse TypeScript source templates")
}
return renderTemplates(afero.NewBasePathFs(targetFS, "src"), tmpls, data)
}

func renderTemplates(targetFS afero.Fs, tmpls *template.Template, data any) error {
for _, tmpl := range tmpls.Templates() {
fname := tmpl.Name()
Expand Down
1 change: 1 addition & 0 deletions cmd/crossplane/function/help/generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The following are valid arguments to the `--language` / `-l` flag:
- `go`
- `kcl`
- `python`
- `typescript`

## Examples

Expand Down
36 changes: 36 additions & 0 deletions cmd/crossplane/function/templates/typescript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Crossplane Composition Function

This is a [Crossplane](https://crossplane.io) composition function written in TypeScript.

## Development

Install dependencies:

```shell
npm install
```

Build the function:

```shell
npm run build
```

Run locally (for testing):

```shell
npm run local
```

## Testing

Test your function using `crossplane composition render`:

```shell
crossplane composition render xr.yaml composition.yaml functions.yaml
```

## Learn More

- [Composition Functions documentation](https://docs.crossplane.io/latest/concepts/composition-functions/)
- [TypeScript Function SDK](https://github.com/crossplane/function-sdk-typescript)
26 changes: 26 additions & 0 deletions cmd/crossplane/function/templates/typescript/package.json.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "function",
"version": "0.1.0",
"description": "A Crossplane composition function.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "tsgo",
"local": "node dist/main.js --insecure --debug"
},
"dependencies": {
"@crossplane-org/function-sdk-typescript": "^0.5.0",
"@types/node": "^26.0.0",
"commander": "^15.0.0",
{{- if .HasSchemas }}
"crossplane-models": "file:{{ .SchemasPath }}",
{{- end }}
"kubernetes-models": "^4.5.1",
"pino": "^10.3.0"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260627.1",
"typescript": "^6.0.0"
}
}
39 changes: 39 additions & 0 deletions cmd/crossplane/function/templates/typescript/src/function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
type RunFunctionRequest,
type RunFunctionResponse,
type FunctionHandler,
type Logger,
to,
normal,
getObservedCompositeResource,
getDesiredComposedResources,
setDesiredComposedResources,
} from '@crossplane-org/function-sdk-typescript';

/**
* Function is a Crossplane composition function.
*/
export class Function implements FunctionHandler {
async RunFunction(req: RunFunctionRequest, logger?: Logger): Promise<RunFunctionResponse> {
let rsp = to(req);

// Get the observed composite resource (XR).
const observedComposite = getObservedCompositeResource(req);
logger?.debug({ observedComposite }, 'Observed composite resource');

// Get the desired composed resources from previous functions in the pipeline.
const desiredComposed = getDesiredComposedResources(req);
logger?.debug({ desiredComposed }, 'Desired composed resources');

// TODO: Add your function logic here.
// Use desiredComposed to add, modify, or remove composed resources.
// Example:
// desiredComposed['my-resource'] = { resource: { ... } };

// Update the response with the desired composed resources.
rsp = setDesiredComposedResources(rsp, desiredComposed);

normal(rsp, 'Function completed successfully');
return rsp;
}
}
77 changes: 77 additions & 0 deletions cmd/crossplane/function/templates/typescript/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env node

import { Command, type OptionValues } from 'commander';
import {
newGrpcServer,
startServer,
FunctionRunner,
type ServerOptions,
} from '@crossplane-org/function-sdk-typescript';
import { pino } from 'pino';
import { Function } from './function.js';

const defaultAddress = '0.0.0.0:9443';
const defaultTlsServerCertsDir = '/tls/server';

const program = new Command('function')
.option('--address <address>', 'Address at which to listen for gRPC connections', defaultAddress)
.option('-d, --debug', 'Emit debug logs.', false)
.option('--insecure', 'Run without mTLS credentials.', false)
.option(
'--tls-server-certs-dir <directory>',
'Serve using mTLS certificates in this directory.',
defaultTlsServerCertsDir
);

function parseArgs(args: OptionValues): ServerOptions {
return {
address: typeof args.address === 'string' ? args.address : defaultAddress,
debug: Boolean(args.debug),
insecure: Boolean(args.insecure),
tlsServerCertsDir:
typeof args.tlsServerCertsDir === 'string'
? args.tlsServerCertsDir
: defaultTlsServerCertsDir,
};
}

function main() {
program.parse(process.argv);
const args = program.opts();
const opts = parseArgs(args);

const logger = pino({
level: opts?.debug ? 'debug' : 'info',
formatters: {
level: (label: string) => {
return { severity: label.toUpperCase() };
},
},
});

logger.debug({ options: opts }, 'Starting function');

try {
const fn = new Function();
const fnRunner = new FunctionRunner(fn, logger);
const server = newGrpcServer(fnRunner, logger);
startServer(server, opts, logger);

process.on('SIGINT', () => {
logger.info('Shutting down gracefully...');
server.tryShutdown((err: Error | undefined) => {
if (err) {
logger.error(err, 'Error during shutdown');
process.exit(1);
}
logger.info('Server shut down successfully');
process.exit(0);
});
});
} catch (err) {
logger.error(err);
process.exit(1);
}
}

main();
21 changes: 21 additions & 0 deletions cmd/crossplane/function/templates/typescript/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"module": "nodenext",
"target": "esnext",
"types": ["node"],
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
}
}
Loading
Loading