From eefd0e467016df244d7a92981e64e4ad0a12be50 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 10:45:22 -0500 Subject: [PATCH 01/15] add typescript support Signed-off-by: Steven Borrelli --- apis/dev/v1alpha1/project_types.go | 14 +- cmd/crossplane/function/generate.go | 47 ++- cmd/crossplane/function/help/generate.md | 1 + .../function/templates/typescript/README.md | 36 ++ .../templates/typescript/package.json.tmpl | 26 ++ .../templates/typescript/src/function.ts | 39 ++ .../function/templates/typescript/src/main.ts | 77 ++++ .../templates/typescript/tsconfig.json | 21 + internal/dependency/manager.go | 95 +++++ internal/project/build.go | 20 +- internal/project/functions/build.go | 1 + internal/project/functions/typescript.go | 254 +++++++++++ internal/schemas/generator/interface.go | 1 + internal/schemas/generator/typescript.go | 399 ++++++++++++++++++ internal/schemas/manager/manager.go | 154 +++++++ 15 files changed, 1171 insertions(+), 14 deletions(-) create mode 100644 cmd/crossplane/function/templates/typescript/README.md create mode 100644 cmd/crossplane/function/templates/typescript/package.json.tmpl create mode 100644 cmd/crossplane/function/templates/typescript/src/function.ts create mode 100644 cmd/crossplane/function/templates/typescript/src/main.ts create mode 100644 cmd/crossplane/function/templates/typescript/tsconfig.json create mode 100644 internal/project/functions/typescript.go create mode 100644 internal/schemas/generator/typescript.go diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index c696e97a..e8b03cb7 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -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 @@ -63,6 +64,7 @@ func SupportedSchemaLanguages() []string { SchemaLanguageJSON, SchemaLanguageKCL, SchemaLanguagePython, + SchemaLanguageTypescript, } } @@ -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. + // Supported values are "go", "json", "kcl", "python", and "typescript". + // If not specified, schemas are generated for all supported languages. Languages []string `json:"languages,omitempty"` } diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 99ebf7c5..e449d301 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -59,6 +59,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 @@ -70,7 +72,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 @@ -180,6 +182,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error "go-templating": c.generateGoTemplatingFiles, "kcl": c.generateKCLFiles, "python": c.generatePythonFiles, + "typescript": c.generateTypescriptFiles, } generator, ok := generators[c.Language] @@ -412,6 +415,48 @@ 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, _ := afero.DirExists(c.schemasFS, "typescript") + 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 := template.Must(template.ParseFS(typescriptTemplates, "templates/typescript/*.*")) + 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 = template.Must(template.ParseFS(typescriptTemplates, "templates/typescript/src/*.*")) + 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() diff --git a/cmd/crossplane/function/help/generate.md b/cmd/crossplane/function/help/generate.md index 2925b6c5..65bd9cd8 100644 --- a/cmd/crossplane/function/help/generate.md +++ b/cmd/crossplane/function/help/generate.md @@ -11,6 +11,7 @@ The following are valid arguments to the `--language` / `-l` flag: - `go` - `kcl` - `python` +- `typescript` ## Examples diff --git a/cmd/crossplane/function/templates/typescript/README.md b/cmd/crossplane/function/templates/typescript/README.md new file mode 100644 index 00000000..603f7377 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -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 resource render`: + +```shell +crossplane resource 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) diff --git a/cmd/crossplane/function/templates/typescript/package.json.tmpl b/cmd/crossplane/function/templates/typescript/package.json.tmpl new file mode 100644 index 00000000..68581757 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -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" + } +} diff --git a/cmd/crossplane/function/templates/typescript/src/function.ts b/cmd/crossplane/function/templates/typescript/src/function.ts new file mode 100644 index 00000000..08f9c0bf --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.ts @@ -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 { + 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; + } +} diff --git a/cmd/crossplane/function/templates/typescript/src/main.ts b/cmd/crossplane/function/templates/typescript/src/main.ts new file mode 100644 index 00000000..29e08714 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/main.ts @@ -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 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 ', + '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(); diff --git a/cmd/crossplane/function/templates/typescript/tsconfig.json b/cmd/crossplane/function/templates/typescript/tsconfig.json new file mode 100644 index 00000000..9143fff2 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/tsconfig.json @@ -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 + } +} diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index a0ab9a09..544d4e4d 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -386,6 +386,101 @@ func (m *Manager) addDependencyNoWrite(ctx context.Context, dep *v1alpha1.Depend } } +// CollectSources returns all schema sources from the project's dependencies +// without generating schemas. This allows the caller to merge sources and +// generate schemas in a single pass. +func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([]smanager.Source, error) { + var sources []smanager.Source + var mu sync.Mutex + + eg, egCtx := errgroup.WithContext(ctx) + + for i := range m.proj.Spec.Dependencies { + dep := &m.proj.Spec.Dependencies[i] + desc := "Updating dependency " + GetSourceDescription(*dep) + eg.Go(func() error { + ch.SendEvent(desc, async.EventStatusStarted) + src, err := m.collectSource(egCtx, dep) + if err != nil { + ch.SendEvent(desc, async.EventStatusFailure) + return err + } + ch.SendEvent(desc, async.EventStatusSuccess) + + if src != nil { + mu.Lock() + sources = append(sources, src) + mu.Unlock() + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + return sources, nil +} + +// collectSource returns the schema source for a dependency without generating schemas. +func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) (smanager.Source, error) { + switch { + case dep.Type == v1alpha1.DependencyTypeXpkg: + if dep.Xpkg == nil { + return nil, errors.New("xpkg dependency has no package reference") + } + + // If the version is a digest, format the OCI ref as + // repo@digest. Otherwise, use repo:tag, where tag may be a semver + // constraint. + ref := dep.Xpkg.Package + if _, err := conregv1.NewHash(dep.Xpkg.Version); err == nil { + ref = fmt.Sprintf("%s@%s", ref, dep.Xpkg.Version) + } else if dep.Xpkg.Version != "" { + ref = fmt.Sprintf("%s:%s", ref, dep.Xpkg.Version) + } + + return m.collectPackageSource(ctx, ref) + case dep.Git != nil: + return smanager.NewGitSource(*dep, m.gitCloner, m.gitAuthProvider), nil + case dep.HTTP != nil: + return smanager.NewHTTPSource(*dep), nil + case dep.K8s != nil: + return smanager.NewK8sSource(*dep), nil + default: + return nil, errors.New("dependency has no source configured") + } +} + +// collectPackageSource fetches a package and returns its CRD source without generating schemas. +func (m *Manager) collectPackageSource(ctx context.Context, ref string) (smanager.Source, error) { + resolvedRef, version, err := m.resolver.Resolve(ctx, ref) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve %s", ref) + } + + pullPolicy := corev1.PullIfNotPresent + pkg, err := m.client.Get(ctx, resolvedRef.String(), runtimexpkg.WithPullPolicy(pullPolicy)) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch %s", ref) + } + + crdFS, err := clixpkg.CRDFilesystem(pkg.Package) + if err != nil { + return nil, errors.Wrapf(err, "cannot extract CRDs from %s", ref) + } + + // Use the resolved version so constraint and exact-version inputs + // collapse to one schema-lock entry. + id := pkg.Source + "@" + pkg.Digest + if version != "" { + id = pkg.Source + ":" + version + } + + return smanager.NewXpkgSource(id, pkg.Digest, crdFS), nil +} + // Clean removes all generated schemas. func (m *Manager) Clean() error { return m.projFS.RemoveAll(m.proj.Spec.Paths.Schemas) diff --git a/internal/project/build.go b/internal/project/build.go index 49fe92a2..b5501007 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -253,19 +253,25 @@ func (b *Builder) Build(ctx context.Context, project *devv1alpha1.Project, proje } o.eventCh.SendEvent("Collecting resources", async.EventStatusSuccess) - // Generate schemas for declared dependencies. The dependency manager - // short-circuits sources whose recorded version still matches, so this is - // cheap on the steady-state path. + // Collect all schema sources (dependencies + local APIs) and generate + // schemas in a single pass. This is important for TypeScript generation + // where all CRDs should be processed together for proper cross-references. + var allSources []manager.Source if b.dependencyManager != nil { - if err := b.dependencyManager.AddAll(ctx, o.eventCh); err != nil { - return nil, errors.Wrap(err, "failed to generate dependency schemas") + depSources, err := b.dependencyManager.CollectSources(ctx, o.eventCh) + if err != nil { + return nil, errors.Wrap(err, "failed to collect dependency sources") } + allSources = append(allSources, depSources...) } - // Generate language-specific schemas from XRDs. + // Add the local APIs source + allSources = append(allSources, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)) + + // Generate schemas from all sources in a single pass if b.schemaManager != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusStarted) - if _, err := b.schemaManager.Generate(ctx, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)); err != nil { + if err := b.schemaManager.GenerateFromMultipleSources(ctx, allSources); err != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusFailure) return nil, errors.Wrap(err, "failed to generate schemas") } diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go index 09c1751f..4ccf073d 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -51,6 +51,7 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC builders := []Builder{ newKCLBuilder(imageConfigs), newPythonBuilder(imageConfigs), + newTypescriptBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), } diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go new file mode 100644 index 00000000..4140afa6 --- /dev/null +++ b/internal/project/functions/typescript.go @@ -0,0 +1,254 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "io" + "net/http" + "path" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + // typescriptBuildImage is the image in which we build the function. + typescriptBuildImage = "docker.io/library/node:25-slim" + // typescriptRuntimeImage is the distroless base used at runtime. + typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12" + // typescriptBuildScript is the shell pipeline that runs in the build + // container. Installs dependencies and compiles TypeScript using tsgo. + // We use npm install instead of npm ci because the schemas package may + // be added dynamically and the lock file won't be in sync. + typescriptBuildScript = `set -eu +npm install --no-fund +npm run build +` +) + +// typescriptBuilder builds TypeScript composition functions. +// +// A TypeScript embedded function is a full function-sdk-typescript project +// (package.json + src/). We build it by running npm ci and npm run build +// (which invokes tsgo) in a Node.js build container, then copy the dist/ +// and node_modules/ onto a distroless Node.js base. +type typescriptBuilder struct { + buildImage string + runtimeImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *typescriptBuilder) Name() string { + return "typescript" +} + +func (b *typescriptBuilder) match(fromFS afero.Fs) (bool, error) { + hasPackageJSON, err := afero.Exists(fromFS, "package.json") + if err != nil { + return false, err + } + hasSrcDir, err := afero.DirExists(fromFS, "src") + if err != nil { + return false, err + } + return hasPackageJSON && hasSrcDir, nil +} + +func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + if err := docker.Check(ctx); err != nil { + return nil, errors.Wrap(err, "typescript builds require a Docker-compatible container runtime") + } + + functionTar, err := b.buildFunction(ctx, c) + if err != nil { + return nil, err + } + + runtimeImage := b.runtimeImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite runtime image") + } + if rewritten != "" { + runtimeImage = rewritten + } + + runtimeRef, err := name.ParseReference(runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to parse typescript runtime base image") + } + + images := make([]v1.Image, len(c.Architectures)) + eg, _ := errgroup.WithContext(ctx) + for i, arch := range c.Architectures { + eg.Go(func() error { + baseImg, err := baseImageForArch(runtimeRef, arch, b.transport) + if err != nil { + return errors.Wrap(err, "failed to fetch typescript runtime base image") + } + + functionLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(functionTar)), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create function layer") + } + + img, err := mutate.AppendLayers(baseImg, functionLayer) + if err != nil { + return errors.Wrap(err, "failed to append function layer") + } + + img, err = configureTypescriptImage(img) + if err != nil { + return errors.Wrap(err, "failed to configure typescript image") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +// buildFunction runs the build container against the function source and returns a +// tar of /function suitable for use as an image layer. +// +// The function source is staged at / in the build container and, if a +// typescript schemas tree exists, //typescript/models/ — preserving +// the project's relative layout so that npm resolves the schemas path-dep from +// package.json. After building, we copy the built artifacts to /function and tar +// that directory for the runtime layer. +func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ([]byte, error) { + fnFS := c.FunctionFS() + // Exclude node_modules the user might have created locally. + // Use the function path as the tar prefix so files end up at / in the container. + fnTar, err := filesystem.FSToTar(fnFS, c.FunctionPath, filesystem.WithExcludePrefix("node_modules")) + if err != nil { + return nil, errors.Wrap(err, "failed to tar function source") + } + + // Check if TypeScript schemas exist and tar them if so. + // The schemas are placed at //typescript/ to match + // the relative path in package.json (e.g., "file:../../schemas/typescript"). + tsSchemasRel := path.Join(c.SchemasPath, "typescript") + tsSchemasFS := afero.NewBasePathFs(c.ProjectFS, tsSchemasRel) + hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".") + var schemasTar []byte + if hasTSSchemas { + schemasTar, err = filesystem.FSToTar(tsSchemasFS, tsSchemasRel) + if err != nil { + return nil, errors.Wrap(err, "failed to tar typescript schemas") + } + } + + buildImage := b.buildImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.buildImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite build image") + } + if rewritten != "" { + buildImage = rewritten + } + + // Build script that: + // 1. Runs npm install and build in the function's original path (so relative deps resolve) + // 2. Copies the built artifacts to /function for the runtime layer + fnPath := "/" + filepath.ToSlash(c.FunctionPath) + buildScript := `set -eu +# First, install dependencies for the schemas package so TypeScript can resolve the base types +if [ -d "/schemas/typescript" ] && [ -f "/schemas/typescript/package.json" ]; then + cd /schemas/typescript && npm install --no-fund 2>/dev/null + cd - +fi +npm install --no-fund +npm run build +# Use -L to dereference symlinks so file: dependencies (like crossplane-models) +# are copied as actual files, not symlinks that won't resolve at runtime. +cp -rL . /function +` + + opts := []docker.StartContainerOption{ + docker.StartWithCopyFiles(fnTar, "/"), + docker.StartWithCommand([]string{"sh", "-c", buildScript}), + docker.StartWithWorkingDirectory(fnPath), + } + if schemasTar != nil { + opts = append(opts, docker.StartWithCopyFiles(schemasTar, "/")) + } + + cid, err := docker.StartContainer(ctx, "", buildImage, opts...) + if err != nil { + return nil, errors.Wrap(err, "failed to start typescript build container") + } + defer func() { + _ = docker.StopContainerByID(ctx, cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return nil, errors.Wrap(err, "typescript build container failed") + } + + return docker.TarFromContainer(ctx, cid, "/function") +} + +// configureTypescriptImage sets the runtime configuration on the final image: +// the function entrypoint and the gRPC port. +func configureTypescriptImage(img v1.Image) (v1.Image, error) { + cfgFile, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config file") + } + cfg := cfgFile.Config + + cfg.Entrypoint = []string{"/nodejs/bin/node", "dist/main.js"} + cfg.Cmd = nil + cfg.WorkingDir = "/function" + if cfg.ExposedPorts == nil { + cfg.ExposedPorts = map[string]struct{}{} + } + cfg.ExposedPorts["9443/tcp"] = struct{}{} + + return mutate.Config(img, cfg) +} + +func newTypescriptBuilder(imageConfigs []pkgv1beta1.ImageConfig) *typescriptBuilder { + return &typescriptBuilder{ + buildImage: typescriptBuildImage, + runtimeImage: typescriptRuntimeImage, + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index d26519d2..b743c5a7 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -71,6 +71,7 @@ func AllLanguages(opts ...Option) []Interface { &jsonGenerator{}, &kclGenerator{}, &pythonGenerator{}, + &typescriptGenerator{}, } } diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go new file mode 100644 index 00000000..f100bbbf --- /dev/null +++ b/internal/schemas/generator/typescript.go @@ -0,0 +1,399 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "context" + "io/fs" + "path/filepath" + + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/crd" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +const ( + typescriptModelsFolder = "models" + // typescriptImage is the Docker image used to run crd-generate. + // We use a Node.js image and install the tool at runtime. + typescriptImage = "docker.io/library/node:22-slim" +) + +// typescriptPackageJSON is the package.json emitted alongside the generated +// TypeScript schemas so the directory can be used as an npm package named +// "crossplane-models". +const typescriptPackageJSON = `{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*.d.ts", + "default": "./*.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "^3.0.2", + "@kubernetes-models/base": "^6.0.1" + } +} +` + +// typescriptTSConfig is the tsconfig.json for compiling the generated TypeScript. +const typescriptTSConfig = `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "." + }, + "include": ["gen/**/*.ts"] +} +` + +type typescriptGenerator struct{} + +func (typescriptGenerator) Language() string { + return devv1alpha1.SchemaLanguageTypescript +} + +// GenerateFromCRD generates TypeScript schema files from the XRDs and CRDs in fromFS. +// It uses @kubernetes-models/crd-generate to produce proper TypeScript classes +// with constructors, interfaces, and runtime validation. +func (t typescriptGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, r runner.SchemaRunner) (afero.Fs, error) { + // Collect all CRD YAML files into a working filesystem + workFS := afero.NewMemMapFs() + crdsDir := "crds" + + if err := workFS.MkdirAll(crdsDir, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create crds directory") + } + + crdCount, err := t.collectCRDs(fromFS, workFS, crdsDir) + if err != nil { + return nil, err + } + + if crdCount == 0 { + return nil, nil + } + + return t.generateFromCRDFiles(ctx, workFS, crdsDir, r) +} + +// GenerateFromOpenAPI is not supported for TypeScript - use GenerateFromCRD instead. +// The crd-generate tool requires CRD YAML files, not OpenAPI specs. +func (t typescriptGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + // crd-generate works with CRD YAML files, not OpenAPI specs. + // Return nil to indicate no schemas were generated. + return nil, nil +} + +// collectCRDs walks the input filesystem and collects all CRD YAML files into +// the working filesystem. XRDs are converted to CRDs using the crd package. +// Returns the number of CRDs collected. +func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string) (int, error) { + // Temporary filesystem for XRD processing + xrdFS := afero.NewMemMapFs() + xrdBaseFolder := "workdir" + if err := xrdFS.MkdirAll(xrdBaseFolder, 0o755); err != nil { + return 0, err + } + + crdCount := 0 + + err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // Only process YAML files + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + var u metav1.TypeMeta + if err := yaml.Unmarshal(bs, &u); err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + switch u.GroupVersionKind().Kind { + case xpv1.CompositeResourceDefinitionKind: + // Process the XRD to generate CRDs + xrPath, claimPath, err := crd.ProcessXRD(xrdFS, bs, path, xrdBaseFolder) + if err != nil { + return err + } + + // Copy generated CRDs to the crds directory + if xrPath != "" { + crdBS, err := afero.ReadFile(xrdFS, xrPath) + if err != nil { + return errors.Wrapf(err, "failed to read generated CRD %q", xrPath) + } + outPath := filepath.Join(crdsDir, filepath.Base(xrPath)) + if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + crdCount++ + } + if claimPath != "" { + crdBS, err := afero.ReadFile(xrdFS, claimPath) + if err != nil { + return errors.Wrapf(err, "failed to read generated claim CRD %q", claimPath) + } + outPath := filepath.Join(crdsDir, filepath.Base(claimPath)) + if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { + return errors.Wrapf(err, "failed to write claim CRD %q", outPath) + } + crdCount++ + } + + case "CustomResourceDefinition": + // Validate it's a proper CRD before copying + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + // Write the CRD to the crds directory + outPath := filepath.Join(crdsDir, filepath.Base(path)) + if err := afero.WriteFile(workFS, outPath, bs, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + crdCount++ + } + + return nil + }) + + return crdCount, err +} + +// generateFromCRDFiles runs crd-generate on the collected CRD files and +// produces TypeScript models with proper classes and validation. +func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS afero.Fs, crdsDir string, r runner.SchemaRunner) (afero.Fs, error) { + // Concatenate all CRD files into a single YAML file. + // The npm published version of @kubernetes-models/read-input only supports + // individual files, not directories. + allCRDsFile := "all-crds.yaml" + var allCRDs []byte + err := afero.Walk(workFS, crdsDir, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + content, err := afero.ReadFile(workFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read CRD file %q", path) + } + if len(allCRDs) > 0 { + allCRDs = append(allCRDs, []byte("\n---\n")...) + } + allCRDs = append(allCRDs, content...) + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "failed to collect CRD files") + } + if err := afero.WriteFile(workFS, allCRDsFile, allCRDs, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write combined CRD file") + } + + // Run crd-generate in a container. + // The script: + // 1. Creates package.json with crd-generate config + // 2. Installs crd-generate and dependencies + // 3. Runs crd-generate to produce TypeScript source + // 4. Compiles TypeScript to JavaScript + if err := r.Generate( + ctx, + workFS, + ".", + "", + typescriptImage, + []string{ + "sh", "-c", + `set -eu + +# Create package.json with crd-generate config and dependencies +cat > package.json << 'PKGEOF' +{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*/index.d.ts", + "default": "./*/index.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "^3.0.2", + "@kubernetes-models/base": "^6.0.1" + }, + "devDependencies": { + "@kubernetes-models/crd-generate": "^6.1.0", + "typescript": "^5.0.0" + }, + "crd-generate": { + "input": ["./all-crds.yaml"], + "output": "./gen" + } +} +PKGEOF + +# Install dependencies (including crd-generate) +npm install 2>/dev/null + +# Run crd-generate (reads config from package.json) +npx crd-generate + +# Create tsconfig.json for compilation +cat > tsconfig.json << 'TSEOF' +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["gen/**/*.ts"] +} +TSEOF + +# Compile TypeScript to JavaScript +npx tsc + +# Copy generated files to models directory for output +mkdir -p models +cp -r dist/* models/ + +# Update package.json for distribution (remove devDependencies and crd-generate config) +cat > models/package.json << 'DISTEOF' +{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*/index.d.ts", + "default": "./*/index.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "^3.0.2", + "@kubernetes-models/base": "^6.0.1" + } +} +DISTEOF +`, + }, + ); err != nil { + return nil, errors.Wrap(err, "failed to generate TypeScript schemas") + } + + // Create output filesystem and copy the models directory + schemaFS := afero.NewMemMapFs() + + // Check if models directory was created + exists, err := afero.DirExists(workFS, typescriptModelsFolder) + if err != nil { + return nil, errors.Wrap(err, "failed to check models directory") + } + if !exists { + // No TypeScript files were generated + return schemaFS, nil + } + + // Copy all files from models/ to the output filesystem + err = afero.Walk(workFS, typescriptModelsFolder, func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return schemaFS.MkdirAll(path, 0o755) + } + + content, err := afero.ReadFile(workFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read %s", path) + } + + return afero.WriteFile(schemaFS, path, content, 0o644) + }) + if err != nil { + return nil, errors.Wrap(err, "failed to copy generated TypeScript files") + } + + return schemaFS, nil +} diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 3fe60e7c..ed6edaa0 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -22,6 +22,7 @@ import ( "encoding/json" "io/fs" "path/filepath" + "strings" "sync" "github.com/invopop/jsonschema" @@ -246,6 +247,159 @@ func (m *Manager) updateLock(l *lock) error { return nil } +// GenerateFromMultipleSources generates schemas from multiple sources at once. +// This is important for TypeScript generation where all CRDs should be processed +// together to generate proper cross-references and a unified index.js. +// Sources with the same SourceType are merged before generation. +func (m *Manager) GenerateFromMultipleSources(ctx context.Context, sources []Source) error { + if len(sources) == 0 { + return nil + } + + // Group sources by type + crdSources := make([]Source, 0) + openAPISources := make([]Source, 0) + for _, src := range sources { + switch src.Type() { + case SourceTypeCRD: + crdSources = append(crdSources, src) + case SourceTypeOpenAPI: + openAPISources = append(openAPISources, src) + } + } + + // Generate from CRD sources (merged) + if len(crdSources) > 0 { + if err := m.generateFromMergedSources(ctx, crdSources, SourceTypeCRD); err != nil { + return errors.Wrap(err, "failed to generate schemas from CRD sources") + } + } + + // Generate from OpenAPI sources (merged) + if len(openAPISources) > 0 { + if err := m.generateFromMergedSources(ctx, openAPISources, SourceTypeOpenAPI); err != nil { + return errors.Wrap(err, "failed to generate schemas from OpenAPI sources") + } + } + + return nil +} + +// generateFromMergedSources merges all source filesystems and generates schemas once. +func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Source, sourceType SourceType) error { + // Collect all resources into a merged filesystem + mergedFS := afero.NewMemMapFs() + sourceVersions := make(map[string]string) + + for _, src := range sources { + version, err := src.Version(ctx) + if err != nil { + return errors.Wrapf(err, "failed to get version for source %s", src.ID()) + } + + // Check if this source is already up to date + existing, err := m.currentVersion(src.ID()) + if err != nil { + return err + } + if existing == version { + // Source is up to date, but we still need to include its resources + // for the merged generation to work correctly + } + + srcFS, err := src.Resources(ctx) + if err != nil { + return errors.Wrapf(err, "failed to get resources for source %s", src.ID()) + } + + // Copy resources into merged filesystem under a unique prefix + // to avoid file name collisions + prefix := sanitizeSourceID(src.ID()) + prefixedFS := afero.NewBasePathFs(mergedFS, prefix) + if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil { + return errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) + } + + sourceVersions[src.ID()] = version + } + + // Run generators on the merged filesystem + schemas := make(map[string]afero.Fs) + eg, egCtx := errgroup.WithContext(ctx) + for _, gen := range m.generators { + eg.Go(func() error { + var schemaFS afero.Fs + var err error + + switch sourceType { + case SourceTypeCRD: + schemaFS, err = gen.GenerateFromCRD(egCtx, mergedFS, m.runner) + case SourceTypeOpenAPI: + schemaFS, err = gen.GenerateFromOpenAPI(egCtx, mergedFS, m.runner) + default: + return errors.Errorf("unsupported source type %q", sourceType) + } + if err != nil { + return err + } + + if schemaFS != nil { + schemas[gen.Language()] = schemaFS + } + + return nil + }) + } + if err := eg.Wait(); err != nil { + return err + } + + // Copy generated schemas into our schema repository + for lang, genFS := range schemas { + langFS := afero.NewBasePathFs(m.fs, lang) + + // Try to copy from models/ subdirectory first (generators put output there) + modelsFS := afero.NewBasePathFs(genFS, "models") + hasModels := false + if fi, err := modelsFS.Stat("."); err == nil && fi.IsDir() { + hasModels = true + } + + if hasModels { + if err := filesystem.CopyFilesBetweenFs(modelsFS, langFS); err != nil { + return err + } + } else { + if err := filesystem.CopyFilesBetweenFs(genFS, langFS); err != nil { + return err + } + } + + if err := postProcessForLanguage(lang, langFS); err != nil { + return err + } + } + + // Update version for all sources + for id, version := range sourceVersions { + if err := m.updateVersion(id, version); err != nil { + return errors.Wrapf(err, "failed to update version for source %s", id) + } + } + + return nil +} + +// sanitizeSourceID converts a source ID to a safe directory name. +func sanitizeSourceID(id string) string { + // Replace characters that are problematic in filesystem paths + result := id + for _, c := range []string{"://", ":", "/", "@"} { + result = strings.ReplaceAll(result, c, "_") + } + return result +} + // New returns an initialized manager. func New(fs afero.Fs, gens []generator.Interface, r runner.SchemaRunner) *Manager { return &Manager{ From 5cbf557bd67ed83a130ba36b4bfc36f4b14806aa Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 11:34:54 -0500 Subject: [PATCH 02/15] coderabbit fixes Signed-off-by: Steven Borrelli --- cmd/crossplane/function/generate.go | 10 ++++++++-- internal/dependency/manager.go | 22 ++++++++++++++-------- internal/project/build.go | 21 +++++++++------------ internal/project/functions/typescript.go | 6 +++--- internal/schemas/generator/typescript.go | 22 +++++++++++++++++----- internal/schemas/manager/manager.go | 3 +++ 6 files changed, 54 insertions(+), 30 deletions(-) diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index e449d301..827d396d 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -444,7 +444,10 @@ func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { } // Parse top-level templates - tmpls := template.Must(template.ParseFS(typescriptTemplates, "templates/typescript/*.*")) + 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 } @@ -453,7 +456,10 @@ func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { if err := targetFS.Mkdir("src", 0o755); err != nil { return errors.Wrap(err, "cannot create src directory") } - tmpls = template.Must(template.ParseFS(typescriptTemplates, "templates/typescript/src/*.*")) + 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) } diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index 544d4e4d..96ff2a1f 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -390,12 +390,12 @@ func (m *Manager) addDependencyNoWrite(ctx context.Context, dep *v1alpha1.Depend // without generating schemas. This allows the caller to merge sources and // generate schemas in a single pass. func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([]smanager.Source, error) { - var sources []smanager.Source - var mu sync.Mutex - eg, egCtx := errgroup.WithContext(ctx) + sourcesByIndex := make([]smanager.Source, len(m.proj.Spec.Dependencies)) + for i := range m.proj.Spec.Dependencies { + i := i dep := &m.proj.Spec.Dependencies[i] desc := "Updating dependency " + GetSourceDescription(*dep) eg.Go(func() error { @@ -408,9 +408,7 @@ func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([] ch.SendEvent(desc, async.EventStatusSuccess) if src != nil { - mu.Lock() - sources = append(sources, src) - mu.Unlock() + sourcesByIndex[i] = src } return nil }) @@ -420,15 +418,23 @@ func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([] return nil, err } + var sources []smanager.Source + for _, src := range sourcesByIndex { + if src != nil { + sources = append(sources, src) + } + } + return sources, nil } // collectSource returns the schema source for a dependency without generating schemas. func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) (smanager.Source, error) { + desc := GetSourceDescription(*dep) switch { case dep.Type == v1alpha1.DependencyTypeXpkg: if dep.Xpkg == nil { - return nil, errors.New("xpkg dependency has no package reference") + return nil, errors.Errorf("xpkg dependency %q is missing xpkg.package; set xpkg.package to a valid package reference", desc) } // If the version is a digest, format the OCI ref as @@ -449,7 +455,7 @@ func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) ( case dep.K8s != nil: return smanager.NewK8sSource(*dep), nil default: - return nil, errors.New("dependency has no source configured") + return nil, errors.Errorf("dependency %q has no source configured; set exactly one of xpkg, git, http, or k8s", desc) } } diff --git a/internal/project/build.go b/internal/project/build.go index b5501007..d662b738 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -256,20 +256,17 @@ func (b *Builder) Build(ctx context.Context, project *devv1alpha1.Project, proje // Collect all schema sources (dependencies + local APIs) and generate // schemas in a single pass. This is important for TypeScript generation // where all CRDs should be processed together for proper cross-references. - var allSources []manager.Source - if b.dependencyManager != nil { - depSources, err := b.dependencyManager.CollectSources(ctx, o.eventCh) - if err != nil { - return nil, errors.Wrap(err, "failed to collect dependency sources") + if b.schemaManager != nil { + var allSources []manager.Source + if b.dependencyManager != nil { + depSources, err := b.dependencyManager.CollectSources(ctx, o.eventCh) + if err != nil { + return nil, errors.Wrap(err, "failed to collect dependency sources") + } + allSources = append(allSources, depSources...) } - allSources = append(allSources, depSources...) - } - - // Add the local APIs source - allSources = append(allSources, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)) + allSources = append(allSources, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)) - // Generate schemas from all sources in a single pass - if b.schemaManager != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusStarted) if err := b.schemaManager.GenerateFromMultipleSources(ctx, allSources); err != nil { o.eventCh.SendEvent("Generating schemas", async.EventStatusFailure) diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go index 4140afa6..08bdd45f 100644 --- a/internal/project/functions/typescript.go +++ b/internal/project/functions/typescript.go @@ -43,7 +43,7 @@ import ( const ( // typescriptBuildImage is the image in which we build the function. - typescriptBuildImage = "docker.io/library/node:25-slim" + typescriptBuildImage = "docker.io/library/node:24-slim" // typescriptRuntimeImage is the distroless base used at runtime. typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12" // typescriptBuildScript is the shell pipeline that runs in the build @@ -190,7 +190,7 @@ func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ( buildScript := `set -eu # First, install dependencies for the schemas package so TypeScript can resolve the base types if [ -d "/schemas/typescript" ] && [ -f "/schemas/typescript/package.json" ]; then - cd /schemas/typescript && npm install --no-fund 2>/dev/null + cd /schemas/typescript && npm install --no-fund cd - fi npm install --no-fund @@ -214,7 +214,7 @@ cp -rL . /function return nil, errors.Wrap(err, "failed to start typescript build container") } defer func() { - _ = docker.StopContainerByID(ctx, cid) + _ = docker.StopContainerByID(context.Background(), cid) }() if err := docker.WaitForContainerByID(ctx, cid); err != nil { diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index f100bbbf..e07d643f 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -20,6 +20,7 @@ import ( "context" "io/fs" "path/filepath" + "strings" "github.com/spf13/afero" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -176,7 +177,7 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string if err != nil { return errors.Wrapf(err, "failed to read generated CRD %q", xrPath) } - outPath := filepath.Join(crdsDir, filepath.Base(xrPath)) + outPath := filepath.Join(crdsDir, stagedCRDPath(path, "xrd")) if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { return errors.Wrapf(err, "failed to write CRD %q", outPath) } @@ -187,7 +188,7 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string if err != nil { return errors.Wrapf(err, "failed to read generated claim CRD %q", claimPath) } - outPath := filepath.Join(crdsDir, filepath.Base(claimPath)) + outPath := filepath.Join(crdsDir, stagedCRDPath(path, "claim")) if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { return errors.Wrapf(err, "failed to write claim CRD %q", outPath) } @@ -202,7 +203,7 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string } // Write the CRD to the crds directory - outPath := filepath.Join(crdsDir, filepath.Base(path)) + outPath := filepath.Join(crdsDir, stagedCRDPath(path, "")) if err := afero.WriteFile(workFS, outPath, bs, 0o644); err != nil { return errors.Wrapf(err, "failed to write CRD %q", outPath) } @@ -215,6 +216,17 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string return crdCount, err } +func stagedCRDPath(sourcePath, suffix string) string { + clean := filepath.ToSlash(filepath.Clean(sourcePath)) + clean = strings.TrimPrefix(clean, "./") + clean = strings.TrimPrefix(clean, "/") + if suffix != "" { + ext := filepath.Ext(clean) + clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext + } + return strings.ReplaceAll(clean, "/", "_") +} + // generateFromCRDFiles runs crd-generate on the collected CRD files and // produces TypeScript models with proper classes and validation. func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS afero.Fs, crdsDir string, r runner.SchemaRunner) (afero.Fs, error) { @@ -301,7 +313,7 @@ cat > package.json << 'PKGEOF' PKGEOF # Install dependencies (including crd-generate) -npm install 2>/dev/null +npm install # Run crd-generate (reads config from package.json) npx crd-generate @@ -359,7 +371,7 @@ DISTEOF `, }, ); err != nil { - return nil, errors.Wrap(err, "failed to generate TypeScript schemas") + return nil, errors.Wrap(err, "failed to install npm dependencies and generate TypeScript schemas; see npm output above for details") } // Create output filesystem and copy the models directory diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index ed6edaa0..9f3a3c2c 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -325,6 +325,7 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc // Run generators on the merged filesystem schemas := make(map[string]afero.Fs) + var schemasMu sync.Mutex eg, egCtx := errgroup.WithContext(ctx) for _, gen := range m.generators { eg.Go(func() error { @@ -344,7 +345,9 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc } if schemaFS != nil { + schemasMu.Lock() schemas[gen.Language()] = schemaFS + schemasMu.Unlock() } return nil From 99edf812cb277c908909a9cbed2150cec5b08845 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 12:20:48 -0500 Subject: [PATCH 03/15] fix schema generation Signed-off-by: Steven Borrelli --- internal/schemas/generator/typescript.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index e07d643f..d3a5ce3b 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -331,6 +331,7 @@ cat > tsconfig.json << 'TSEOF' "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "rootDir": "gen", "outDir": "dist" }, "include": ["gen/**/*.ts"] @@ -344,6 +345,12 @@ npx tsc mkdir -p models cp -r dist/* models/ +# crd-generate emits _schemas/ as pre-compiled JS (not TypeScript), so tsc does +# not process it and it never appears in dist/. Copy it directly from gen/. +if [ -d gen/_schemas ]; then + cp -r gen/_schemas models/ +fi + # Update package.json for distribution (remove devDependencies and crd-generate config) cat > models/package.json << 'DISTEOF' { From 3538c85beeda02540cd997ffc10f329848f211d1 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 19:19:33 -0500 Subject: [PATCH 04/15] fix package Signed-off-by: Steven Borrelli --- cmd/crossplane/function/templates/typescript/README.md | 4 ++-- internal/project/controlplane/controlplane.go | 2 +- internal/project/sort.go | 10 ++-------- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/cmd/crossplane/function/templates/typescript/README.md b/cmd/crossplane/function/templates/typescript/README.md index 603f7377..34f2758f 100644 --- a/cmd/crossplane/function/templates/typescript/README.md +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -24,10 +24,10 @@ npm run local ## Testing -Test your function using `crossplane resource render`: +Test your function using `crossplane composition render`: ```shell -crossplane resource render xr.yaml composition.yaml functions.yaml +crossplane composition render xr.yaml composition.yaml functions.yaml ``` ## Learn More diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index a3d58f18..8458368e 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -116,7 +116,7 @@ func (l *localDevControlPlane) Teardown(ctx context.Context) error { } func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.ImageTagMap, tag name.Tag) error { - cfgImage, fnImages, err := project.SortImages(imgMap, tag.Repository.Name()) + cfgImage, fnImages, err := project.SortImages(imgMap, tag.Repository.String()) if err != nil { return err } diff --git a/internal/project/sort.go b/internal/project/sort.go index cd38d42a..a61ee5b6 100644 --- a/internal/project/sort.go +++ b/internal/project/sort.go @@ -17,8 +17,6 @@ limitations under the License. package project import ( - "fmt" - "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -30,14 +28,10 @@ import ( // grouped together by function, so that multi-arch indexes can be produced // based on the returned map. func SortImages(imgMap ImageTagMap, repo string) (cfgImage v1.Image, fnImages map[name.Repository][]v1.Image, err error) { - cfgTag, err := name.NewTag(fmt.Sprintf("%s:%s", repo, ConfigurationTag), name.StrictValidation) - if err != nil { - return nil, nil, errors.Wrap(err, "failed to construct configuration tag") - } - fnImages = make(map[name.Repository][]v1.Image) for tag, image := range imgMap { - if tag == cfgTag { + // Check if this is the configuration image by looking for the configuration tag suffix + if tag.TagStr() == ConfigurationTag { cfgImage = image continue } From 75e6205f5da9dcc20ff8011967805290143da1b5 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 20:31:28 -0500 Subject: [PATCH 05/15] address review and lint issues Signed-off-by: Steven Borrelli --- apis/dev/v1alpha1/project_types.go | 2 +- cmd/crossplane/function/generate.go | 5 +- internal/dependency/manager.go | 9 ++-- internal/project/build.go | 2 +- internal/project/functions/build.go | 4 +- internal/project/functions/typescript.go | 30 ++++++------ internal/project/sort.go | 4 +- internal/schemas/generator/interface.go | 4 +- internal/schemas/generator/typescript.go | 59 +++++------------------- internal/schemas/manager/manager.go | 14 +++--- 10 files changed, 51 insertions(+), 82 deletions(-) diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index e8b03cb7..2971728d 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -135,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", "python", and "typescript". // If not specified, schemas are generated for all supported languages. + // +kubebuilder:validation:items:Enum=go;json;kcl;python;typescript Languages []string `json:"languages,omitempty"` } diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 827d396d..1bff7010 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -421,7 +421,10 @@ type typescriptTemplateData struct { } func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { - hasSchemas, _ := afero.DirExists(c.schemasFS, "typescript") + 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 { diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go index 96ff2a1f..b9eb1649 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -395,7 +395,6 @@ func (m *Manager) CollectSources(ctx context.Context, ch async.EventChannel) ([] sourcesByIndex := make([]smanager.Source, len(m.proj.Spec.Dependencies)) for i := range m.proj.Spec.Dependencies { - i := i dep := &m.proj.Spec.Dependencies[i] desc := "Updating dependency " + GetSourceDescription(*dep) eg.Go(func() error { @@ -433,7 +432,7 @@ func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) ( desc := GetSourceDescription(*dep) switch { case dep.Type == v1alpha1.DependencyTypeXpkg: - if dep.Xpkg == nil { + if dep.Xpkg == nil || dep.Xpkg.Package == "" { return nil, errors.Errorf("xpkg dependency %q is missing xpkg.package; set xpkg.package to a valid package reference", desc) } @@ -463,18 +462,18 @@ func (m *Manager) collectSource(ctx context.Context, dep *v1alpha1.Dependency) ( func (m *Manager) collectPackageSource(ctx context.Context, ref string) (smanager.Source, error) { resolvedRef, version, err := m.resolver.Resolve(ctx, ref) if err != nil { - return nil, errors.Wrapf(err, "failed to resolve %s", ref) + return nil, errors.Wrapf(err, "cannot resolve package %q; check that the package exists and that the version or digest is valid", ref) } pullPolicy := corev1.PullIfNotPresent pkg, err := m.client.Get(ctx, resolvedRef.String(), runtimexpkg.WithPullPolicy(pullPolicy)) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch %s", ref) + return nil, errors.Wrapf(err, "cannot download package %q; check registry access and credentials", ref) } crdFS, err := clixpkg.CRDFilesystem(pkg.Package) if err != nil { - return nil, errors.Wrapf(err, "cannot extract CRDs from %s", ref) + return nil, errors.Wrapf(err, "cannot extract CRDs from package %q; check that it is a valid Crossplane package", ref) } // Use the resolved version so constraint and exact-version inputs diff --git a/internal/project/build.go b/internal/project/build.go index d662b738..f22059c3 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -261,7 +261,7 @@ func (b *Builder) Build(ctx context.Context, project *devv1alpha1.Project, proje if b.dependencyManager != nil { depSources, err := b.dependencyManager.CollectSources(ctx, o.eventCh) if err != nil { - return nil, errors.Wrap(err, "failed to collect dependency sources") + return nil, errors.Wrap(err, "cannot load schemas from project dependencies; check that each dependency is reachable and contains valid API definitions") } allSources = append(allSources, depSources...) } diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go index 4ccf073d..19a984ad 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -51,9 +51,11 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC builders := []Builder{ newKCLBuilder(imageConfigs), newPythonBuilder(imageConfigs), - newTypescriptBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), + // TypeScript matcher is broad (package.json + src/), so it must come + // after Go builders to avoid misclassifying Go projects with frontend files. + newTypescriptBuilder(imageConfigs), } for _, b := range builders { ok, err := b.match(fromFS) diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go index 08bdd45f..92bfbd95 100644 --- a/internal/project/functions/typescript.go +++ b/internal/project/functions/typescript.go @@ -19,6 +19,7 @@ package functions import ( "bytes" "context" + "fmt" "io" "net/http" "path" @@ -45,21 +46,13 @@ const ( // typescriptBuildImage is the image in which we build the function. typescriptBuildImage = "docker.io/library/node:24-slim" // typescriptRuntimeImage is the distroless base used at runtime. - typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian12" - // typescriptBuildScript is the shell pipeline that runs in the build - // container. Installs dependencies and compiles TypeScript using tsgo. - // We use npm install instead of npm ci because the schemas package may - // be added dynamically and the lock file won't be in sync. - typescriptBuildScript = `set -eu -npm install --no-fund -npm run build -` + typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian13" ) // typescriptBuilder builds TypeScript composition functions. // // A TypeScript embedded function is a full function-sdk-typescript project -// (package.json + src/). We build it by running npm ci and npm run build +// (package.json + src/). We build it by running npm install and npm run build // (which invokes tsgo) in a Node.js build container, then copy the dist/ // and node_modules/ onto a distroless Node.js base. type typescriptBuilder struct { @@ -151,6 +144,8 @@ func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Ima // the project's relative layout so that npm resolves the schemas path-dep from // package.json. After building, we copy the built artifacts to /function and tar // that directory for the runtime layer. +// +//nolint:contextcheck // The defer uses context.Background() intentionally for cleanup. func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ([]byte, error) { fnFS := c.FunctionFS() // Exclude node_modules the user might have created locally. @@ -165,7 +160,10 @@ func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ( // the relative path in package.json (e.g., "file:../../schemas/typescript"). tsSchemasRel := path.Join(c.SchemasPath, "typescript") tsSchemasFS := afero.NewBasePathFs(c.ProjectFS, tsSchemasRel) - hasTSSchemas, _ := afero.DirExists(tsSchemasFS, ".") + hasTSSchemas, err := afero.DirExists(tsSchemasFS, ".") + if err != nil { + return nil, errors.Wrapf(err, "cannot check for TypeScript schemas at %q", tsSchemasRel) + } var schemasTar []byte if hasTSSchemas { schemasTar, err = filesystem.FSToTar(tsSchemasFS, tsSchemasRel) @@ -187,10 +185,11 @@ func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ( // 1. Runs npm install and build in the function's original path (so relative deps resolve) // 2. Copies the built artifacts to /function for the runtime layer fnPath := "/" + filepath.ToSlash(c.FunctionPath) - buildScript := `set -eu + tsSchemasPath := "/" + filepath.ToSlash(tsSchemasRel) + buildScript := fmt.Sprintf(`set -eu # First, install dependencies for the schemas package so TypeScript can resolve the base types -if [ -d "/schemas/typescript" ] && [ -f "/schemas/typescript/package.json" ]; then - cd /schemas/typescript && npm install --no-fund +if [ -d "%s" ] && [ -f "%s/package.json" ]; then + cd %s && npm install --no-fund cd - fi npm install --no-fund @@ -198,7 +197,7 @@ npm run build # Use -L to dereference symlinks so file: dependencies (like crossplane-models) # are copied as actual files, not symlinks that won't resolve at runtime. cp -rL . /function -` +`, tsSchemasPath, tsSchemasPath, tsSchemasPath) opts := []docker.StartContainerOption{ docker.StartWithCopyFiles(fnTar, "/"), @@ -214,6 +213,7 @@ cp -rL . /function return nil, errors.Wrap(err, "failed to start typescript build container") } defer func() { + // Use context.Background() so container cleanup happens even if ctx is cancelled. _ = docker.StopContainerByID(context.Background(), cid) }() diff --git a/internal/project/sort.go b/internal/project/sort.go index a61ee5b6..b2a3163a 100644 --- a/internal/project/sort.go +++ b/internal/project/sort.go @@ -30,8 +30,8 @@ import ( func SortImages(imgMap ImageTagMap, repo string) (cfgImage v1.Image, fnImages map[name.Repository][]v1.Image, err error) { fnImages = make(map[name.Repository][]v1.Image) for tag, image := range imgMap { - // Check if this is the configuration image by looking for the configuration tag suffix - if tag.TagStr() == ConfigurationTag { + // Check if this is the configuration image by matching both repository and tag + if tag.Repository.String() == repo && tag.TagStr() == ConfigurationTag { cfgImage = image continue } diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index b743c5a7..4f56b677 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -77,10 +77,10 @@ func AllLanguages(opts ...Option) []Interface { // Filter returns the subset of generators whose language identifier appears // in langs. The order of generators in the result matches the order of all. -// If langs is empty, all generators are returned unchanged. +// If langs is empty, the default generators are returned (excluding TypeScript). func Filter(all []Interface, langs []string) []Interface { if len(langs) == 0 { - return all + return DefaultLanguages() } out := make([]Interface, 0, len(all)) for _, g := range all { diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index d3a5ce3b..a3f8a285 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -18,6 +18,8 @@ package generator import ( "context" + "crypto/sha256" + "encoding/hex" "io/fs" "path/filepath" "strings" @@ -43,50 +45,6 @@ const ( typescriptImage = "docker.io/library/node:22-slim" ) -// typescriptPackageJSON is the package.json emitted alongside the generated -// TypeScript schemas so the directory can be used as an npm package named -// "crossplane-models". -const typescriptPackageJSON = `{ - "name": "crossplane-models", - "version": "0.0.0", - "type": "module", - "main": "index.js", - "types": "index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "default": "./index.js" - }, - "./*": { - "types": "./*.d.ts", - "default": "./*.js" - } - }, - "dependencies": { - "@kubernetes-models/apimachinery": "^3.0.2", - "@kubernetes-models/base": "^6.0.1" - } -} -` - -// typescriptTSConfig is the tsconfig.json for compiling the generated TypeScript. -const typescriptTSConfig = `{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "." - }, - "include": ["gen/**/*.ts"] -} -` - type typescriptGenerator struct{} func (typescriptGenerator) Language() string { @@ -133,14 +91,14 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string xrdFS := afero.NewMemMapFs() xrdBaseFolder := "workdir" if err := xrdFS.MkdirAll(xrdBaseFolder, 0o755); err != nil { - return 0, err + return 0, errors.Wrap(err, "cannot prepare TypeScript schema generation workspace") } crdCount := 0 err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { if err != nil { - return err + return errors.Wrapf(err, "cannot read %q while collecting API definitions for TypeScript models", path) } if info.IsDir() { @@ -168,7 +126,7 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string // Process the XRD to generate CRDs xrPath, claimPath, err := crd.ProcessXRD(xrdFS, bs, path, xrdBaseFolder) if err != nil { - return err + return errors.Wrapf(err, "cannot convert XRD %q to CRDs for TypeScript models; check that the XRD is valid", path) } // Copy generated CRDs to the crds directory @@ -220,11 +178,16 @@ func stagedCRDPath(sourcePath, suffix string) string { clean := filepath.ToSlash(filepath.Clean(sourcePath)) clean = strings.TrimPrefix(clean, "./") clean = strings.TrimPrefix(clean, "/") + // Add a stable hash of the original clean path so flattened names do not collide. + sum := sha256.Sum256([]byte(clean)) + hash := hex.EncodeToString(sum[:])[:12] if suffix != "" { ext := filepath.Ext(clean) clean = strings.TrimSuffix(clean, ext) + "-" + suffix + ext } - return strings.ReplaceAll(clean, "/", "_") + ext := filepath.Ext(clean) + flat := strings.ReplaceAll(strings.TrimSuffix(clean, ext), "/", "_") + return flat + "-" + hash + ext } // generateFromCRDFiles runs crd-generate on the collected CRD files and diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 9f3a3c2c..440a6846 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -20,6 +20,7 @@ package manager import ( "context" "encoding/json" + "fmt" "io/fs" "path/filepath" "strings" @@ -265,6 +266,8 @@ func (m *Manager) GenerateFromMultipleSources(ctx context.Context, sources []Sou crdSources = append(crdSources, src) case SourceTypeOpenAPI: openAPISources = append(openAPISources, src) + default: + return errors.Errorf("cannot generate schemas for source %q: source type %q is not supported; use a CRD or OpenAPI source", src.ID(), src.Type()) } } @@ -291,7 +294,7 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc mergedFS := afero.NewMemMapFs() sourceVersions := make(map[string]string) - for _, src := range sources { + for i, src := range sources { version, err := src.Version(ctx) if err != nil { return errors.Wrapf(err, "failed to get version for source %s", src.ID()) @@ -302,10 +305,9 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc if err != nil { return err } - if existing == version { - // Source is up to date, but we still need to include its resources - // for the merged generation to work correctly - } + // Note: Even if existing == version, we still need to include the + // resources for the merged generation to work correctly. + _ = existing srcFS, err := src.Resources(ctx) if err != nil { @@ -314,7 +316,7 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc // Copy resources into merged filesystem under a unique prefix // to avoid file name collisions - prefix := sanitizeSourceID(src.ID()) + prefix := fmt.Sprintf("%04d_%s", i, sanitizeSourceID(src.ID())) prefixedFS := afero.NewBasePathFs(mergedFS, prefix) if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil { return errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) From b018436182e606c65730165b1a1174f777e91568 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 20:57:29 -0500 Subject: [PATCH 06/15] fix test Signed-off-by: Steven Borrelli --- internal/schemas/generator/interface_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/schemas/generator/interface_test.go b/internal/schemas/generator/interface_test.go index e48a353d..192dbbff 100644 --- a/internal/schemas/generator/interface_test.go +++ b/internal/schemas/generator/interface_test.go @@ -106,8 +106,14 @@ func TestFilter(t *testing.T) { want []string }{ "Empty": { - // An empty filter returns all languages unchanged. - want: devv1alpha1.SupportedSchemaLanguages(), + // An empty filter returns the default languages (excluding TypeScript, + // which requires explicit opt-in due to its Node.js dependency). + want: []string{ + devv1alpha1.SchemaLanguageGo, + devv1alpha1.SchemaLanguageJSON, + devv1alpha1.SchemaLanguageKCL, + devv1alpha1.SchemaLanguagePython, + }, }, "SingleLanguage": { langs: []string{devv1alpha1.SchemaLanguagePython}, From 35404d9c313b4d857db8726196994c64d855ec9d Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Tue, 30 Jun 2026 14:13:11 -0500 Subject: [PATCH 07/15] add testing guide Signed-off-by: Steven Borrelli --- docs/typescript-testing-guide.md | 505 +++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 docs/typescript-testing-guide.md diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md new file mode 100644 index 00000000..4e1ce6d4 --- /dev/null +++ b/docs/typescript-testing-guide.md @@ -0,0 +1,505 @@ +# Testing TypeScript Support in Crossplane CLI + +This guide walks through testing the TypeScript support added in PR #170. We'll create a complete Crossplane configuration project with a TypeScript composition function. + +An example project is located at . + +## Prerequisites + +- Go 1.25+ +- Docker +- Node.js 24+ (for local development) +- A Kubernetes cluster with Crossplane installed +- Access to push packages to a registry (e.g., `xpkg.upbound.io`) + +## Step 1: Build the CLI from this PR + +```bash +# Clone the CLI repository +git clone https://github.com/crossplane/cli.git +cd cli + +# Checkout PR #170 +gh pr checkout 170 + +# Build the CLI +go build -o crossplane ./cmd/crossplane + +# Verify the build +./crossplane version +``` + +## Step 2: Create a New Project + +```bash +# Initialize the project (this creates the directory) +crossplane project init configuration-aws-network-ts \ + --registry xpkg.upbound.io/your-org + +cd configuration-aws-network-ts +``` + +## Step 3: Configure the Project + +Edit `crossplane-project.yaml` to enable TypeScript schema generation and add dependencies: + +```yaml +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: configuration-aws-network-ts +spec: + maintainer: Your Name + repository: xpkg.upbound.io/your-org/configuration-aws-network-ts + # Enable TypeScript schema generation (opt-in) + schemas: + languages: + - typescript + dependencies: + - type: xpkg + xpkg: + package: xpkg.upbound.io/upbound/provider-aws-ec2 + version: ">=v2.6.0" + - type: xpkg + xpkg: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready + version: ">=v0.7.0" +``` + +## Step 4: Add Dependencies + +When a dependency is added to a Crossplane project: + +- The dependency is deployed to the cluster +- The CLI generates Schemas from any CRDS + +You can add dependencies using `crossplane dependency add` or by +modifying `crossplane-project.yaml`. + +```bash +# Add the AWS EC2 provider dependency +crossplane dependency add xpkg.upbound.io/upbound/provider-aws-ec2:v2.6.0 + +# Add the auto-ready function +crossplane dependency add xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0 +``` + +## Step 5: Create an Example Manifest and the API + +First, create an example XR file that defines your custom resource: + +```bash +mkdir -p examples/network +cat > examples/network/example.yaml << 'EOF' +apiVersion: aws.platform.upbound.io/v1alpha1 +kind: XNetwork +metadata: + name: example-network +spec: + region: us-west-2 + cidrBlock: "10.0.0.0/16" +EOF +``` + +Then generate the XRD from the example: + +```bash +# Generate an XRD from the example XR +crossplane xrd generate examples/network/example.yaml +``` + +Edit `apis/xnetwork/definition.yaml` to add spec fields: + +```yaml +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnetworks.aws.platform.upbound.io +spec: + group: aws.platform.upbound.io + names: + kind: XNetwork + plural: xnetworks + claimNames: + kind: Network + plural: networks + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + description: AWS region for the network + default: us-west-2 + cidrBlock: + type: string + description: CIDR block for the VPC + default: "10.0.0.0/16" + required: + - region + status: + type: object + properties: + vpcId: + type: string + description: The ID of the created VPC +``` + +## Step 6: Create a TypeScript Function + +```bash +# Generate a TypeScript function scaffold +crossplane function generate network --language typescript +``` + +**Note**: You can also generate a function and add it to a composition pipeline in one step: + +```bash +crossplane function generate network apis/xnetwork/composition.yaml --language typescript +``` + +This creates `functions/network/` with: +- `package.json` - Dependencies including `@crossplane-org/function-sdk-typescript` +- `tsconfig.json` - TypeScript configuration +- `src/main.ts` - Entry point +- `src/function.ts` - Function implementation template + +## Step 7: Implement the Function + +The generated `functions/network/src/function.ts` contains a template implementation. A full example is available at [function.ts](https://github.com/stevendborrelli/configuration-aws-network-ts-xp-cli/blob/main/functions/network/src/function.ts). + +Edit the `function.ts` to create a VPC: + +```typescript +import { + type RunFunctionRequest, + type RunFunctionResponse, + type FunctionHandler, + type Logger, + to, + normal, + fatal, + getObservedCompositeResource, + getDesiredComposedResources, + setDesiredComposedResources, +} from '@crossplane-org/function-sdk-typescript'; + +// Import the generated types from crossplane-models +import { VPC } from 'crossplane-models/ec2.aws.upbound.io/v1beta1'; + +/** + * Function is a Crossplane composition function that creates a VPC. + */ +export class Function implements FunctionHandler { + async RunFunction(req: RunFunctionRequest, logger?: Logger): Promise { + let rsp = to(req); + + // Get the observed composite resource (XR). + const observedComposite = getObservedCompositeResource(req); + if (!observedComposite) { + fatal(rsp, 'No composite resource found'); + return rsp; + } + logger?.debug({ observedComposite }, 'Observed composite resource'); + + // Extract spec values from the XR + const spec = observedComposite.resource?.spec as { region?: string; cidrBlock?: string }; + const region = spec?.region || 'us-west-2'; + const cidrBlock = spec?.cidrBlock || '10.0.0.0/16'; + const xrName = observedComposite.resource?.metadata?.name || 'unknown'; + + // Get the desired composed resources from previous functions in the pipeline. + const desiredComposed = getDesiredComposedResources(req); + + // Create a VPC using the generated TypeScript class + const vpc = new VPC({ + metadata: { + name: `${xrName}-vpc`, + annotations: { + 'crossplane.io/external-name': `${xrName}-vpc`, + }, + }, + spec: { + forProvider: { + region: region, + cidrBlock: cidrBlock, + enableDnsHostnames: true, + enableDnsSupport: true, + tags: { + Name: `${xrName}-vpc`, + 'managed-by': 'crossplane', + }, + }, + }, + }); + + // Add the VPC to desired resources + desiredComposed['vpc'] = { resource: vpc }; + + // Update the response with the desired composed resources. + rsp = setDesiredComposedResources(rsp, desiredComposed); + + normal(rsp, 'Successfully composed VPC resource'); + return rsp; + } +} +``` + +The generated `package.json` already includes the `crossplane-models` dependency when TypeScript schemas are enabled. It will look like: + +```json +{ + "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", + "crossplane-models": "file:../../schemas/typescript", + "kubernetes-models": "^4.5.1", + "pino": "^10.3.0" + }, + "devDependencies": { + "@typescript/native-preview": "^7.0.0-dev.20260627.1", + "typescript": "^6.0.0" + } +} +``` + +## Step 8: Generate Schemas + +Before building, generate the TypeScript schemas from the dependencies: + +```bash +# This happens automatically during build, but you can trigger it manually +crossplane project build +``` + +After this, `schemas/typescript/` will contain generated TypeScript models including: + +- `ec2.aws.upbound.io/v1beta1/VPC.ts` - VPC class with full type definitions +- `aws.platform.upbound.io/v1alpha1/XNetwork.ts` - Your XRD's types + +## Step 9: Local Development (Optional) + +For local development and IDE support: + +```bash +cd functions/network + +# Install dependencies (including the local schemas package) +npm install + +# Build locally to check for TypeScript errors +npm run build +``` + +## Step 10: Create a Composition + +```bash +# Generate a composition from the XRD +crossplane composition generate apis/xnetwork/definition.yaml +``` + +This generates a basic composition with `function-auto-ready`. You need to add your embedded function to the pipeline. + +The function name in the composition follows the pattern: `--` derived from the project repository. For example, if your repository is `xpkg.upbound.io/your-org/configuration-aws-network-ts` and your function is named `network`, the functionRef name will be `your-org-configuration-aws-network-ts-network`. + +Edit `apis/xnetwork/composition.yaml` to add your function before `function-auto-ready`. The `name` of the function is of the format: +`-`. + +```yaml +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnetworks.aws.platform.upbound.io +spec: + compositeTypeRef: + apiVersion: aws.platform.upbound.io/v1alpha1 + kind: XNetwork + mode: Pipeline + pipeline: + - step: network + functionRef: + name: your-org-configuration-aws-network-tsnetwork + - step: crossplane-contrib-function-auto-ready + functionRef: + name: crossplane-contrib-function-auto-ready +``` + +**Tip**: You can generate the function and add it to the composition pipeline automatically by running: + +```bash +crossplane function generate network apis/xnetwork/composition.yaml --language typescript +``` + +## Step 11: Build the Project + +```bash +# Build the complete project (configuration + embedded functions) +crossplane project build +``` + +This will: + +1. Generate TypeScript schemas from all dependencies (provider-aws-ec2, your XRD) +2. Build the TypeScript function in a Node.js container +3. Package everything into a Crossplane configuration package + +The output will be in `_output/configuration-aws-network-ts.xpkg`. + +## Step 12: Test Locally with a Dev Cluster + +For quick local testing, use `crossplane project run` to spin up a local Kubernetes cluster with Crossplane and your configuration automatically deployed: + +```bash +# Start a local dev cluster and deploy the project +crossplane project run +``` + +This will: + +1. Create a local Kind cluster +2. Install Crossplane +3. Build and deploy your configuration package +4. Install all provider dependencies + +Once the cluster is running, configure AWS credentials for the provider: + +```bash +# Create AWS credentials secret (creds.conf should contain your AWS credentials) +# Format: [default] +# aws_access_key_id = YOUR_ACCESS_KEY +# aws_secret_access_key = YOUR_SECRET_KEY +kubectl create secret generic aws-creds -n default --from-file=creds=creds.conf + +# Create a ProviderConfig to use the credentials +kubectl apply -f - < Date: Thu, 2 Jul 2026 17:17:08 -0500 Subject: [PATCH 08/15] fix all lint issues Signed-off-by: Steven Borrelli --- cmd/crossplane/common/resource/xrm/client.go | 14 ++- cmd/crossplane/function/generate.go | 18 ++-- cmd/crossplane/render/convert.go | 26 +++-- cmd/crossplane/render/runtime_docker.go | 10 +- cmd/crossplane/top/top.go | 6 +- cmd/crossplane/validate/manager.go | 10 +- cmd/crossplane/xr/generate.go | 16 ++- cmd/crossplane/xrd/generate.go | 11 +- internal/crd/convert.go | 5 +- internal/git/git.go | 5 +- internal/project/render.go | 10 +- internal/schemas/generator/go.go | 4 +- internal/schemas/generator/interface.go | 26 ++++- internal/schemas/generator/kcl.go | 4 +- internal/schemas/generator/python.go | 6 +- internal/schemas/generator/typescript.go | 104 ++++++++++++------- internal/schemas/manager/manager.go | 86 ++++++++++----- 17 files changed, 245 insertions(+), 116 deletions(-) diff --git a/cmd/crossplane/common/resource/xrm/client.go b/cmd/crossplane/common/resource/xrm/client.go index f1d854ff..771a00df 100644 --- a/cmd/crossplane/common/resource/xrm/client.go +++ b/cmd/crossplane/common/resource/xrm/client.go @@ -36,8 +36,12 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" ) -// defaultConcurrency is the concurrency using which the resource tree if loaded when not explicitly specified. -const defaultConcurrency = 5 +const ( + // defaultConcurrency is the concurrency using which the resource tree if loaded when not explicitly specified. + defaultConcurrency = 5 + // kindSecret is the Kubernetes Secret kind. + kindSecret = "Secret" +) // Client to get a Resource with all its children. type Client struct { @@ -105,7 +109,7 @@ func getResourceChildrenRefs(r *resource.Resource, getConnectionSecrets bool) [] obj := r.Unstructured switch obj.GroupVersionKind().GroupKind() { - case schema.GroupKind{Group: "", Kind: "Secret"}, + case schema.GroupKind{Group: "", Kind: kindSecret}, v1alpha1.UsageGroupVersionKind.GroupKind(), v1beta1.EnvironmentConfigGroupVersionKind.GroupKind(): // nothing to do here, it's a resource we know not to have any reference @@ -131,7 +135,7 @@ func getResourceChildrenRefs(r *resource.Resource, getConnectionSecrets bool) [] if cmSecretRef := cm.GetWriteConnectionSecretToReference(); cmSecretRef != nil { ref := v1.ObjectReference{ APIVersion: "v1", - Kind: "Secret", + Kind: kindSecret, Name: cmSecretRef.Name, Namespace: cm.GetNamespace(), } @@ -159,7 +163,7 @@ func getResourceChildrenRefs(r *resource.Resource, getConnectionSecrets bool) [] if xrSecretRef := xr.GetWriteConnectionSecretToReference(); xrSecretRef != nil { ref := v1.ObjectReference{ APIVersion: "v1", - Kind: "Secret", + Kind: kindSecret, Name: xrSecretRef.Name, Namespace: xrSecretRef.Namespace, } diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 1bff7010..f0b1e3f1 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -49,6 +49,12 @@ import ( "github.com/crossplane/cli/v2/internal/terminal" ) +// Function language constants. +const ( + langGoTemplating = "go-templating" + langPython = "python" +) + //go:embed help/generate.md var generateHelp string @@ -140,7 +146,7 @@ func validateLanguageAgainstSchemas(functionLang string, schemaLangs []string) e // the given function language consumes. Most function languages map to a // like-named schema language; go-templating consumes the JSON schema. func functionSchemaLanguage(functionLang string) string { - if functionLang == "go-templating" { + if functionLang == langGoTemplating { return v1alpha1.SchemaLanguageJSON } return functionLang @@ -178,11 +184,11 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error type generatorFunc func(afero.Fs) error generators := map[string]generatorFunc{ - "go": c.generateGoFiles, - "go-templating": c.generateGoTemplatingFiles, - "kcl": c.generateKCLFiles, - "python": c.generatePythonFiles, - "typescript": c.generateTypescriptFiles, + "go": c.generateGoFiles, + langGoTemplating: c.generateGoTemplatingFiles, + "kcl": c.generateKCLFiles, + langPython: c.generatePythonFiles, + "typescript": c.generateTypescriptFiles, } generator, ok := generators[c.Language] diff --git a/cmd/crossplane/render/convert.go b/cmd/crossplane/render/convert.go index f445cdd4..808d12ec 100644 --- a/cmd/crossplane/render/convert.go +++ b/cmd/crossplane/render/convert.go @@ -36,6 +36,12 @@ import ( renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" ) +// Common field names for unstructured objects. +const ( + fieldAPIVersion = "apiVersion" + fieldKind = "kind" +) + // BuildCompositeRequest builds a RenderRequest for a composite resource from // the supplied inputs and function addresses. func BuildCompositeRequest(in CompositionInputs) (*renderv1alpha1.RenderRequest, error) { @@ -123,11 +129,11 @@ func ParseCompositeResponse(out *renderv1alpha1.CompositeOutput) (CompositionOut results := make([]kunstructured.Unstructured, 0, len(out.GetEvents())) for _, ev := range out.GetEvents() { results = append(results, kunstructured.Unstructured{Object: map[string]any{ - "apiVersion": "render.crossplane.io/v1beta1", - "kind": "Result", - "severity": ev.GetType(), - "reason": ev.GetReason(), - "message": ev.GetMessage(), + fieldAPIVersion: "render.crossplane.io/v1beta1", + fieldKind: "Result", + "severity": ev.GetType(), + "reason": ev.GetReason(), + "message": ev.GetMessage(), }}) } @@ -234,11 +240,11 @@ func ParseOperationResponse(out *renderv1alpha1.OperationOutput) (OperationOutpu results := make([]kunstructured.Unstructured, 0, len(out.GetEvents())) for _, ev := range out.GetEvents() { results = append(results, kunstructured.Unstructured{Object: map[string]any{ - "apiVersion": "render.crossplane.io/v1beta1", - "kind": "Result", - "severity": ev.GetType(), - "reason": ev.GetReason(), - "message": ev.GetMessage(), + fieldAPIVersion: "render.crossplane.io/v1beta1", + fieldKind: "Result", + "severity": ev.GetType(), + "reason": ev.GetReason(), + "message": ev.GetMessage(), }}) } diff --git a/cmd/crossplane/render/runtime_docker.go b/cmd/crossplane/render/runtime_docker.go index c8616a34..f190278f 100644 --- a/cmd/crossplane/render/runtime_docker.go +++ b/cmd/crossplane/render/runtime_docker.go @@ -39,8 +39,12 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" ) -// FunctionPort is the port that Composition Functions listen on inside their container. -const FunctionPort = 9443 +const ( + // FunctionPort is the port that Composition Functions listen on inside their container. + FunctionPort = 9443 + // defaultBindAddress is the default address for Docker containers. + defaultBindAddress = "127.0.0.1" +) // Annotations that can be used to configure the Docker runtime. const ( @@ -211,7 +215,7 @@ func GetRuntimeDocker(fn pkgv1.Function, log logging.Logger) (*RuntimeDocker, er PullPolicy: pullPolicy, Keychain: authn.DefaultKeychain, log: log, - BindAddress: "127.0.0.1", // Default to localhost for security + BindAddress: defaultBindAddress, // Default to localhost for security } if i := fn.GetAnnotations()[AnnotationKeyRuntimeDockerImage]; i != "" { diff --git a/cmd/crossplane/top/top.go b/cmd/crossplane/top/top.go index 83f65299..09e3ed59 100644 --- a/cmd/crossplane/top/top.go +++ b/cmd/crossplane/top/top.go @@ -54,6 +54,8 @@ const ( errAddingPodMetrics = "error adding metrics to pod, check if metrics-server is running or wait until metrics are available for the pod" errWriteHeader = "cannot write header" errWriteRow = "cannot write row" + + labelCrossplane = "crossplane" ) // Cmd represents the top command. @@ -270,8 +272,8 @@ func getCrossplanePods(pods []v1.Pod) []topMetrics { if podType != "revision" { isCrossplanePod = true } - case labelKey == "app.kubernetes.io/part-of" && labelValue == "crossplane": - podType = "crossplane" + case labelKey == "app.kubernetes.io/part-of" && labelValue == labelCrossplane: + podType = labelCrossplane isCrossplanePod = true } diff --git a/cmd/crossplane/validate/manager.go b/cmd/crossplane/validate/manager.go index 3cef8322..c3fb8c70 100644 --- a/cmd/crossplane/validate/manager.go +++ b/cmd/crossplane/validate/manager.go @@ -41,6 +41,10 @@ const ( refFmt = "%s@%s" imageFmt = "%s:%s" + + // Crossplane resource kinds. + kindXRD = "CompositeResourceDefinition" + kindConfiguration = "Configuration" ) // Manager defines a Manager for preparing Crossplane packages for validation. @@ -122,7 +126,7 @@ func (m *Manager) PrepExtensions(extensions []*unstructured.Unstructured) error m.crds = append(m.crds, crd) - case schema.GroupKind{Group: "apiextensions.crossplane.io", Kind: "CompositeResourceDefinition"}: + case schema.GroupKind{Group: "apiextensions.crossplane.io", Kind: kindXRD}: xrd := &v1.CompositeResourceDefinition{} bytes, err := e.MarshalJSON() @@ -170,7 +174,7 @@ func (m *Manager) PrepExtensions(extensions []*unstructured.Unstructured) error m.deps[image] = true - case schema.GroupKind{Group: "pkg.crossplane.io", Kind: "Configuration"}: + case schema.GroupKind{Group: "pkg.crossplane.io", Kind: kindConfiguration}: paved := fieldpath.Pave(e.Object) image, err := paved.GetString("spec.package") @@ -180,7 +184,7 @@ func (m *Manager) PrepExtensions(extensions []*unstructured.Unstructured) error m.confs[image] = nil - case schema.GroupKind{Group: "meta.pkg.crossplane.io", Kind: "Configuration"}: + case schema.GroupKind{Group: "meta.pkg.crossplane.io", Kind: kindConfiguration}: meta, err := e.MarshalJSON() if err != nil { return errors.Wrap(err, "cannot marshal configuration to JSON") diff --git a/cmd/crossplane/xr/generate.go b/cmd/crossplane/xr/generate.go index 0ee26a92..078062c2 100644 --- a/cmd/crossplane/xr/generate.go +++ b/cmd/crossplane/xr/generate.go @@ -34,6 +34,14 @@ import ( _ "embed" ) +// Common field names for unstructured objects. +const ( + fieldAPIVersion = "apiVersion" + fieldKind = "kind" + fieldName = "name" + fieldNamespace = "namespace" +) + //go:embed help/generate.md var generateHelp string @@ -211,10 +219,10 @@ func ConvertClaimToXR(claim *unstructured.Unstructured, opts Options) (*composit labels[labelClaimNamespace] = claim.GetNamespace() if err := xrPaved.SetValue("spec.claimRef", map[string]any{ - "apiVersion": apiVersion, - "kind": claimKind, - "name": claimName, - "namespace": claim.GetNamespace(), + fieldAPIVersion: apiVersion, + fieldKind: claimKind, + fieldName: claimName, + fieldNamespace: claim.GetNamespace(), }); err != nil { return nil, errors.Wrap(err, "cannot set claimRef") } diff --git a/cmd/crossplane/xrd/generate.go b/cmd/crossplane/xrd/generate.go index ee229628..6fc81a2d 100644 --- a/cmd/crossplane/xrd/generate.go +++ b/cmd/crossplane/xrd/generate.go @@ -44,6 +44,11 @@ import ( _ "embed" ) +// Common field and value constants. +const ( + categoryCrossplane = "crossplane" +) + //go:embed help/generate.md var generateHelp string @@ -302,7 +307,7 @@ func newXRDFromSimpleSchema(yamlData []byte, customPlural string) (*v2.Composite Group: gv.Group, Scope: v2.CompositeResourceScopeNamespaced, Names: extv1.CustomResourceDefinitionNames{ - Categories: []string{"crossplane"}, + Categories: []string{categoryCrossplane}, Kind: flect.Capitalize(kind), Plural: strings.ToLower(plural), }, @@ -339,7 +344,7 @@ func newXRDFromExample(yamlData []byte, customPlural string) (*v2.CompositeResou return nil, errors.Wrap(err, "failed to unmarshal YAML to check top-level keys") } for key := range topLevelKeys { - allowedKeys := []string{"apiVersion", "kind", "metadata", "spec", "status", "additionalPrinterColumns"} + allowedKeys := []string{"apiVersion", "kind", "metadata", schemaFieldSpec, schemaFieldStatus, "additionalPrinterColumns"} if !slices.Contains(allowedKeys, key) { return nil, errors.Errorf("invalid manifest: valid top-level keys are: %v", allowedKeys) } @@ -446,7 +451,7 @@ func newXRDFromExample(yamlData []byte, customPlural string) (*v2.CompositeResou Group: gv.Group, Scope: scope, Names: extv1.CustomResourceDefinitionNames{ - Categories: []string{"crossplane"}, + Categories: []string{categoryCrossplane}, Kind: flect.Capitalize(kind), Plural: strings.ToLower(plural), }, diff --git a/internal/crd/convert.go b/internal/crd/convert.go index 82c63626..2771e8d8 100644 --- a/internal/crd/convert.go +++ b/internal/crd/convert.go @@ -33,6 +33,9 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) +// typeObject is the JSON Schema type for object types. +const typeObject = "object" + // ToOpenAPI converts the storage version of a CRD to an OpenAPI spec. The // version is returned along with the OpenAPI spec. func ToOpenAPI(crd *extv1.CustomResourceDefinition) (map[string]*spec3.OpenAPI, error) { @@ -152,7 +155,7 @@ func updateSchemaPropertiesXEmbeddedResource(s *extv1.JSONSchemaProps) { if s.XEmbeddedResource && s.XPreserveUnknownFields != nil && *s.XPreserveUnknownFields { s.XEmbeddedResource = false s.XPreserveUnknownFields = nil - s.Type = "object" + s.Type = typeObject s.AdditionalProperties = &extv1.JSONSchemaPropsOrBool{ Allows: true, Schema: nil, diff --git a/internal/git/git.go b/internal/git/git.go index eb89800b..4015ace1 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -32,6 +32,9 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) +// defaultBranch is the default branch name for git repositories. +const defaultBranch = "main" + // CloneOptions configure for git actions. type CloneOptions struct { Repo string @@ -147,7 +150,7 @@ func extractBranchName(ref string) string { if ref != "" && !strings.HasPrefix(ref, "refs/") { return ref } - return "main" + return defaultBranch } func handleSHACheckout(repoObj *git.Repository, authMethod transport.AuthMethod, sha string, sparsePath string) error { diff --git a/internal/project/render.go b/internal/project/render.go index 6f2c21e5..9b890653 100644 --- a/internal/project/render.go +++ b/internal/project/render.go @@ -37,6 +37,12 @@ import ( "github.com/crossplane/cli/v2/internal/docker" ) +// Architecture constants for Go's GOARCH format. +const ( + archAMD64 = "amd64" + archARM64 = "arm64" +) + // A Resolver resolves a CLI-style package reference to an OCI reference. type Resolver interface { ResolveRef(ref string) (name.Reference, error) @@ -143,9 +149,9 @@ func getDockerDaemonArchitecture(ctx context.Context) string { func normalizeArchitecture(dockerArch string) string { switch dockerArch { case "x86_64": - return "amd64" + return archAMD64 case "aarch64": - return "arm64" + return archARM64 default: return dockerArch } diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go index c57784e8..d74a211f 100644 --- a/internal/schemas/generator/go.go +++ b/internal/schemas/generator/go.go @@ -417,7 +417,7 @@ type goOpenAPI struct { func goCollectOpenAPIs(fromFS afero.Fs) ([]goOpenAPI, error) { //nolint:gocognit // Hard to split this up, and it's not too long to read. crdFS := afero.NewMemMapFs() - baseFolder := "workdir" + baseFolder := workDir if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { return nil, err @@ -434,7 +434,7 @@ func goCollectOpenAPIs(fromFS afero.Fs) ([]goOpenAPI, error) { //nolint:gocognit } // Ignore files without yaml extensions. ext := filepath.Ext(path) - if ext != ".yaml" && ext != ".yml" { + if ext != extYAML && ext != extYML { return nil } diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index 4f56b677..fa99d3da 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -24,9 +24,20 @@ import ( "github.com/spf13/afero" + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/schemas/runner" ) +// Common constants used across generators. +const ( + // workDir is the base directory name used for processing XRDs and CRDs. + workDir = "workdir" + // extYAML is the .yaml file extension. + extYAML = ".yaml" + // extYML is the .yml file extension. + extYML = ".yml" +) + // Interface generates schemas for a specific language. type Interface interface { Language() string @@ -75,12 +86,25 @@ func AllLanguages(opts ...Option) []Interface { } } +// defaultLanguages returns the languages generated when none are requested +// explicitly. TypeScript is excluded because it requires Node.js and npm, +// which adds significant build time. Users can enable it by explicitly +// listing "typescript" in schemas.languages. +func defaultLanguages() []string { + return []string{ + devv1alpha1.SchemaLanguageGo, + devv1alpha1.SchemaLanguageJSON, + devv1alpha1.SchemaLanguageKCL, + devv1alpha1.SchemaLanguagePython, + } +} + // Filter returns the subset of generators whose language identifier appears // in langs. The order of generators in the result matches the order of all. // If langs is empty, the default generators are returned (excluding TypeScript). func Filter(all []Interface, langs []string) []Interface { if len(langs) == 0 { - return DefaultLanguages() + langs = defaultLanguages() } out := make([]Interface, 0, len(all)) for _, g := range all { diff --git a/internal/schemas/generator/kcl.go b/internal/schemas/generator/kcl.go index 0a3e9cff..05b4012d 100644 --- a/internal/schemas/generator/kcl.go +++ b/internal/schemas/generator/kcl.go @@ -64,7 +64,7 @@ func (kclGenerator) Language() string { func (kclGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { //nolint:gocognit // generate kcl schemas crdFS := afero.NewMemMapFs() schemaFS := afero.NewMemMapFs() - baseFolder := "workdir" + baseFolder := workDir if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { return nil, err @@ -81,7 +81,7 @@ func (kclGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, genera return nil } ext := filepath.Ext(path) - if ext != ".yaml" && ext != ".yml" { + if ext != extYAML && ext != extYML { return nil } diff --git a/internal/schemas/generator/python.go b/internal/schemas/generator/python.go index b6e84617..c9a41435 100644 --- a/internal/schemas/generator/python.go +++ b/internal/schemas/generator/python.go @@ -63,7 +63,7 @@ func (pythonGenerator) Language() string { func (p pythonGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { //nolint:gocognit // generation of schemas for python crdFS := afero.NewMemMapFs() schemaFS := afero.NewMemMapFs() - baseFolder := "workdir" + baseFolder := workDir if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { return nil, err @@ -80,7 +80,7 @@ func (p pythonGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, g return nil } ext := filepath.Ext(path) - if ext != ".yaml" && ext != ".yml" { + if ext != extYAML && ext != extYML { return nil } @@ -163,7 +163,7 @@ func (p pythonGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, g func (p pythonGenerator) GenerateFromOpenAPI(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { //nolint:gocognit // generation of schemas for python openapiFS := afero.NewMemMapFs() schemaFS := afero.NewMemMapFs() - baseFolder := "workdir" + baseFolder := workDir if err := openapiFS.MkdirAll(baseFolder, 0o755); err != nil { return nil, err diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index a3f8a285..1f1d2698 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -89,7 +89,7 @@ func (t typescriptGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string) (int, error) { // Temporary filesystem for XRD processing xrdFS := afero.NewMemMapFs() - xrdBaseFolder := "workdir" + xrdBaseFolder := workDir if err := xrdFS.MkdirAll(xrdBaseFolder, 0o755); err != nil { return 0, errors.Wrap(err, "cannot prepare TypeScript schema generation workspace") } @@ -107,7 +107,7 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string // Only process YAML files ext := filepath.Ext(path) - if ext != ".yaml" && ext != ".yml" { + if ext != extYAML && ext != extYML { return nil } @@ -123,47 +123,15 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string switch u.GroupVersionKind().Kind { case xpv1.CompositeResourceDefinitionKind: - // Process the XRD to generate CRDs - xrPath, claimPath, err := crd.ProcessXRD(xrdFS, bs, path, xrdBaseFolder) + n, err := t.processXRDFile(xrdFS, workFS, bs, path, xrdBaseFolder, crdsDir) if err != nil { - return errors.Wrapf(err, "cannot convert XRD %q to CRDs for TypeScript models; check that the XRD is valid", path) - } - - // Copy generated CRDs to the crds directory - if xrPath != "" { - crdBS, err := afero.ReadFile(xrdFS, xrPath) - if err != nil { - return errors.Wrapf(err, "failed to read generated CRD %q", xrPath) - } - outPath := filepath.Join(crdsDir, stagedCRDPath(path, "xrd")) - if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { - return errors.Wrapf(err, "failed to write CRD %q", outPath) - } - crdCount++ - } - if claimPath != "" { - crdBS, err := afero.ReadFile(xrdFS, claimPath) - if err != nil { - return errors.Wrapf(err, "failed to read generated claim CRD %q", claimPath) - } - outPath := filepath.Join(crdsDir, stagedCRDPath(path, "claim")) - if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { - return errors.Wrapf(err, "failed to write claim CRD %q", outPath) - } - crdCount++ + return err } + crdCount += n case "CustomResourceDefinition": - // Validate it's a proper CRD before copying - var c extv1.CustomResourceDefinition - if err := yaml.Unmarshal(bs, &c); err != nil { - return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) - } - - // Write the CRD to the crds directory - outPath := filepath.Join(crdsDir, stagedCRDPath(path, "")) - if err := afero.WriteFile(workFS, outPath, bs, 0o644); err != nil { - return errors.Wrapf(err, "failed to write CRD %q", outPath) + if err := t.processCRDFile(workFS, bs, path, crdsDir); err != nil { + return err } crdCount++ } @@ -174,6 +142,62 @@ func (t typescriptGenerator) collectCRDs(fromFS, workFS afero.Fs, crdsDir string return crdCount, err } +// processXRDFile converts an XRD to CRDs and writes them to the working filesystem. +// Returns the number of CRDs written. +func (t typescriptGenerator) processXRDFile(xrdFS, workFS afero.Fs, bs []byte, path, xrdBaseFolder, crdsDir string) (int, error) { + xrPath, claimPath, err := crd.ProcessXRD(xrdFS, bs, path, xrdBaseFolder) + if err != nil { + return 0, errors.Wrapf(err, "cannot convert XRD %q to CRDs for TypeScript models; check that the XRD is valid", path) + } + + count := 0 + + if xrPath != "" { + if err := copyGeneratedCRD(xrdFS, workFS, xrPath, crdsDir, path, "xrd"); err != nil { + return 0, err + } + count++ + } + + if claimPath != "" { + if err := copyGeneratedCRD(xrdFS, workFS, claimPath, crdsDir, path, "claim"); err != nil { + return 0, err + } + count++ + } + + return count, nil +} + +// copyGeneratedCRD copies a generated CRD file from the XRD filesystem to the working filesystem. +func copyGeneratedCRD(xrdFS, workFS afero.Fs, srcPath, crdsDir, origPath, suffix string) error { + crdBS, err := afero.ReadFile(xrdFS, srcPath) + if err != nil { + return errors.Wrapf(err, "failed to read generated CRD %q", srcPath) + } + outPath := filepath.Join(crdsDir, stagedCRDPath(origPath, suffix)) + if err := afero.WriteFile(workFS, outPath, crdBS, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + return nil +} + +// processCRDFile validates and writes a CRD file to the working filesystem. +func (t typescriptGenerator) processCRDFile(workFS afero.Fs, bs []byte, path, crdsDir string) error { + // Validate it's a proper CRD before copying + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + // Write the CRD to the crds directory + outPath := filepath.Join(crdsDir, stagedCRDPath(path, "")) + if err := afero.WriteFile(workFS, outPath, bs, 0o644); err != nil { + return errors.Wrapf(err, "failed to write CRD %q", outPath) + } + return nil +} + func stagedCRDPath(sourcePath, suffix string) string { clean := filepath.ToSlash(filepath.Clean(sourcePath)) clean = strings.TrimPrefix(clean, "./") @@ -206,7 +230,7 @@ func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS af return nil } ext := filepath.Ext(path) - if ext != ".yaml" && ext != ".yml" { + if ext != extYAML && ext != extYML { return nil } content, err := afero.ReadFile(workFS, path) diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 440a6846..7e2f16fb 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -291,19 +291,47 @@ func (m *Manager) GenerateFromMultipleSources(ctx context.Context, sources []Sou // generateFromMergedSources merges all source filesystems and generates schemas once. func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Source, sourceType SourceType) error { // Collect all resources into a merged filesystem + mergedFS, sourceVersions, err := m.collectSourceResources(ctx, sources) + if err != nil { + return err + } + + // Run generators on the merged filesystem + schemas, err := m.runGenerators(ctx, mergedFS, sourceType) + if err != nil { + return err + } + + // Copy generated schemas into our schema repository + if err := m.copyGeneratedSchemas(schemas); err != nil { + return err + } + + // Update version for all sources + for id, version := range sourceVersions { + if err := m.updateVersion(id, version); err != nil { + return errors.Wrapf(err, "failed to update version for source %s", id) + } + } + + return nil +} + +// collectSourceResources merges resources from all sources into a single filesystem. +func (m *Manager) collectSourceResources(ctx context.Context, sources []Source) (afero.Fs, map[string]string, error) { mergedFS := afero.NewMemMapFs() sourceVersions := make(map[string]string) for i, src := range sources { version, err := src.Version(ctx) if err != nil { - return errors.Wrapf(err, "failed to get version for source %s", src.ID()) + return nil, nil, errors.Wrapf(err, "failed to get version for source %s", src.ID()) } // Check if this source is already up to date existing, err := m.currentVersion(src.ID()) if err != nil { - return err + return nil, nil, err } // Note: Even if existing == version, we still need to include the // resources for the merged generation to work correctly. @@ -311,7 +339,7 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc srcFS, err := src.Resources(ctx) if err != nil { - return errors.Wrapf(err, "failed to get resources for source %s", src.ID()) + return nil, nil, errors.Wrapf(err, "failed to get resources for source %s", src.ID()) } // Copy resources into merged filesystem under a unique prefix @@ -319,47 +347,57 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc prefix := fmt.Sprintf("%04d_%s", i, sanitizeSourceID(src.ID())) prefixedFS := afero.NewBasePathFs(mergedFS, prefix) if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil { - return errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) + return nil, nil, errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) } sourceVersions[src.ID()] = version } - // Run generators on the merged filesystem + return mergedFS, sourceVersions, nil +} + +// runGenerators runs all generators on the merged filesystem and returns the generated schemas. +func (m *Manager) runGenerators(ctx context.Context, mergedFS afero.Fs, sourceType SourceType) (map[string]afero.Fs, error) { schemas := make(map[string]afero.Fs) var schemasMu sync.Mutex eg, egCtx := errgroup.WithContext(ctx) + for _, gen := range m.generators { eg.Go(func() error { - var schemaFS afero.Fs - var err error - - switch sourceType { - case SourceTypeCRD: - schemaFS, err = gen.GenerateFromCRD(egCtx, mergedFS, m.runner) - case SourceTypeOpenAPI: - schemaFS, err = gen.GenerateFromOpenAPI(egCtx, mergedFS, m.runner) - default: - return errors.Errorf("unsupported source type %q", sourceType) - } + schemaFS, err := m.runGenerator(egCtx, gen, mergedFS, sourceType) if err != nil { return err } - if schemaFS != nil { schemasMu.Lock() schemas[gen.Language()] = schemaFS schemasMu.Unlock() } - return nil }) } + if err := eg.Wait(); err != nil { - return err + return nil, err } - // Copy generated schemas into our schema repository + return schemas, nil +} + +// runGenerator runs a single generator on the merged filesystem. +func (m *Manager) runGenerator(ctx context.Context, gen generator.Interface, mergedFS afero.Fs, sourceType SourceType) (afero.Fs, error) { + switch sourceType { + case SourceTypeCRD: + return gen.GenerateFromCRD(ctx, mergedFS, m.runner) + case SourceTypeOpenAPI: + return gen.GenerateFromOpenAPI(ctx, mergedFS, m.runner) + default: + return nil, errors.Errorf("unsupported source type %q", sourceType) + } +} + +// copyGeneratedSchemas copies generated schemas to the schema repository. +func (m *Manager) copyGeneratedSchemas(schemas map[string]afero.Fs) error { for lang, genFS := range schemas { langFS := afero.NewBasePathFs(m.fs, lang) @@ -384,14 +422,6 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc return err } } - - // Update version for all sources - for id, version := range sourceVersions { - if err := m.updateVersion(id, version); err != nil { - return errors.Wrapf(err, "failed to update version for source %s", id) - } - } - return nil } From 1d994c82ce6a27c20bf01d99e8c5b8823c8d2c45 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 2 Jul 2026 18:56:42 -0500 Subject: [PATCH 09/15] add render and timeout sections Signed-off-by: Steven Borrelli --- docs/typescript-testing-guide.md | 73 +++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md index 4e1ce6d4..a578e447 100644 --- a/docs/typescript-testing-guide.md +++ b/docs/typescript-testing-guide.md @@ -29,6 +29,8 @@ go build -o crossplane ./cmd/crossplane ./crossplane version ``` +All subsequent commands should use this locally-compiled version of crossplane. + ## Step 2: Create a New Project ```bash @@ -363,7 +365,52 @@ This will: The output will be in `_output/configuration-aws-network-ts.xpkg`. -## Step 12: Test Locally with a Dev Cluster +## Step 12: Test with Composition Render + +Before deploying to a cluster, you can test your composition function locally using `crossplane composition render`. This renders the composition pipeline and shows you what resources would be created without needing a Kubernetes cluster. + +```bash +# Render the composition with a 5 minute timeout (recommended for TypeScript builds) +crossplane composition render \ + examples/network/example.yaml \ + apis/xnetwork/composition.yaml \ + --timeout=5m +``` + +The first run may take several minutes as it: + +1. Pulls the Node.js build image +2. Runs `npm install` to fetch dependencies +3. Compiles the TypeScript function +4. Executes the function pipeline + +Subsequent runs will be faster due to Docker and npm caching. + +The output shows the rendered XR and all composed resources as YAML: + +```bash +# Include function results (informational messages) +crossplane composition render \ + examples/network/example.yaml \ + apis/xnetwork/composition.yaml \ + --timeout=5m \ + --include-function-results + +# Include the full XR with spec and metadata +crossplane composition render \ + examples/network/example.yaml \ + apis/xnetwork/composition.yaml \ + --timeout=5m \ + --include-full-xr +``` + +This is useful for: + +- Validating your function logic before deployment +- Debugging composition issues +- Testing changes quickly without a cluster + +## Step 13: Test with a Local Dev Cluster For quick local testing, use `crossplane project run` to spin up a local Kubernetes cluster with Crossplane and your configuration automatically deployed: @@ -433,7 +480,7 @@ When you're done testing, tear down the local cluster: crossplane project stop ``` -## Step 13: Push and Install (Production) +## Step 14: Push and Install (Production) For deploying to a production cluster, push the package to a registry: @@ -497,6 +544,28 @@ If the function fails at runtime with "Cannot find package 'crossplane-models'": 1. Ensure the `file:` dependency path in `package.json` is correct 2. The CLI automatically dereferences symlinks during build - check that the function image includes the actual files +### Build timeout during render + +If you see an error like: + +```text +crossplane: error: cannot build embedded functions: failed to build function "network": failed to build runtime images: typescript build container failed: container unknown failure: context deadline exceeded +``` + +This means the TypeScript build (including `npm install` and `npm run build`) exceeded the default 1 minute timeout. This commonly happens on the first build when Docker images and npm packages need to be downloaded. + +Increase the timeout using the `--timeout` flag: + +```bash +# Use a 5 minute timeout +crossplane composition render examples/network/example.yaml apis/xnetwork/composition.yaml --timeout=5m + +# Or for larger projects with many dependencies +crossplane composition render examples/network/example.yaml apis/xnetwork/composition.yaml --timeout=10m +``` + +Subsequent builds will be faster as Docker images and npm packages are cached. + ## Reference Project For a complete working example, see: From c2082a6b8ed66678d9e5bd50c8ecae22c8500a8f Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 2 Jul 2026 18:58:45 -0500 Subject: [PATCH 10/15] improve ts detection Signed-off-by: Steven Borrelli --- internal/project/functions/build.go | 3 +-- internal/project/functions/build_test.go | 7 +++++++ internal/project/functions/typescript.go | 6 +++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go index 19a984ad..884f4b01 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -53,8 +53,7 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC newPythonBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), - // TypeScript matcher is broad (package.json + src/), so it must come - // after Go builders to avoid misclassifying Go projects with frontend files. + // TypeScript is checked last since package.json can appear in other project types. newTypescriptBuilder(imageConfigs), } for _, b := range builders { diff --git a/internal/project/functions/build_test.go b/internal/project/functions/build_test.go index 4c90a9fc..0117a185 100644 --- a/internal/project/functions/build_test.go +++ b/internal/project/functions/build_test.go @@ -92,6 +92,13 @@ func TestIdentify(t *testing.T) { }, expectedBuilder: &goTemplatingBuilder{}, }, + "TypeScript": { + files: map[string]string{ + "package.json": "{}", + "tsconfig.json": "{}", + }, + expectedBuilder: &typescriptBuilder{}, + }, "GoTemplatingInvalidFiles": { files: map[string]string{ "template1.gotmpl": "", diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go index 92bfbd95..06375144 100644 --- a/internal/project/functions/typescript.go +++ b/internal/project/functions/typescript.go @@ -52,7 +52,7 @@ const ( // typescriptBuilder builds TypeScript composition functions. // // A TypeScript embedded function is a full function-sdk-typescript project -// (package.json + src/). We build it by running npm install and npm run build +// (package.json + tsconfig.json). We build it by running npm install and npm run build // (which invokes tsgo) in a Node.js build container, then copy the dist/ // and node_modules/ onto a distroless Node.js base. type typescriptBuilder struct { @@ -71,11 +71,11 @@ func (b *typescriptBuilder) match(fromFS afero.Fs) (bool, error) { if err != nil { return false, err } - hasSrcDir, err := afero.DirExists(fromFS, "src") + hasTSConfig, err := afero.Exists(fromFS, "tsconfig.json") if err != nil { return false, err } - return hasPackageJSON && hasSrcDir, nil + return hasPackageJSON && hasTSConfig, nil } func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { From 6fabf634470c124e4d6195291d857b1e0a7f6ba6 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Wed, 29 Jul 2026 19:34:39 +0100 Subject: [PATCH 11/15] update for ts 7 Signed-off-by: Steven Borrelli --- .../function/templates/typescript/package.json.tmpl | 5 ++--- docs/typescript-testing-guide.md | 8 ++++---- internal/project/functions/typescript.go | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/crossplane/function/templates/typescript/package.json.tmpl b/cmd/crossplane/function/templates/typescript/package.json.tmpl index 68581757..a442a881 100644 --- a/cmd/crossplane/function/templates/typescript/package.json.tmpl +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -6,7 +6,7 @@ "type": "module", "main": "dist/main.js", "scripts": { - "build": "tsgo", + "build": "tsc", "local": "node dist/main.js --insecure --debug" }, "dependencies": { @@ -20,7 +20,6 @@ "pino": "^10.3.0" }, "devDependencies": { - "@typescript/native-preview": "^7.0.0-dev.20260627.1", - "typescript": "^6.0.0" + "typescript": "^7.0.0" } } diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md index a578e447..c5717497 100644 --- a/docs/typescript-testing-guide.md +++ b/docs/typescript-testing-guide.md @@ -103,7 +103,7 @@ spec: EOF ``` -Then generate the XRD from the example: +Then generate the XRD from the example. This will be our Platform API: ```bash # Generate an XRD from the example XR @@ -168,6 +168,7 @@ crossplane function generate network apis/xnetwork/composition.yaml --language t ``` This creates `functions/network/` with: + - `package.json` - Dependencies including `@crossplane-org/function-sdk-typescript` - `tsconfig.json` - TypeScript configuration - `src/main.ts` - Entry point @@ -265,7 +266,7 @@ The generated `package.json` already includes the `crossplane-models` dependency "type": "module", "main": "dist/main.js", "scripts": { - "build": "tsgo", + "build": "tsc", "local": "node dist/main.js --insecure --debug" }, "dependencies": { @@ -277,8 +278,7 @@ The generated `package.json` already includes the `crossplane-models` dependency "pino": "^10.3.0" }, "devDependencies": { - "@typescript/native-preview": "^7.0.0-dev.20260627.1", - "typescript": "^6.0.0" + "typescript": "^7.0.0" } } ``` diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go index 06375144..ea696247 100644 --- a/internal/project/functions/typescript.go +++ b/internal/project/functions/typescript.go @@ -53,7 +53,7 @@ const ( // // A TypeScript embedded function is a full function-sdk-typescript project // (package.json + tsconfig.json). We build it by running npm install and npm run build -// (which invokes tsgo) in a Node.js build container, then copy the dist/ +// (which invokes tsc) in a Node.js build container, then copy the dist/ // and node_modules/ onto a distroless Node.js base. type typescriptBuilder struct { buildImage string From 0e17464528a59987faca16eacd7635206ac7f4d0 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Thu, 27 Aug 2026 15:26:47 +0100 Subject: [PATCH 12/15] Fix TypeScript build issues found porting a real configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by porting upbound/configuration-aws-network-ts from a hand-written Dockerfile and xpkg build to a Crossplane project, and by a second review that took a generated function all the way to a reconciled AWS VPC. Build the function once per target architecture. buildFunction now returns a tar per architecture rather than one shared tree, and the runtime dependencies are reinstalled for each with npm's --cpu/--os into /fn_, which each image then uses as its working directory. Packages that ship per-platform native binaries — the TypeScript 7 compiler among them — otherwise resolve to the build host's architecture and fail to run. npmArchitecture maps OCI architecture names onto the ones npm expects. Note that --cpu/--os only steer optional-dependency selection: a dependency that compiles from source still produces build-host output. Builder and generator fixes: * Demultiplex container logs. WaitForContainerByID copied the raw Docker log stream, so the 8-byte frame header landed in the middle of the text and build failures rendered as "1added 26 packages", "Anpm notice". Use stdcopy.StdCopy, as the rest of the file already does. * Stop emitting sourcemaps from the schema generator. Only dist/ ships in the models package, so every .js.map pointed at a gen/*.ts that isn't there and test runners warned once per generated type. Also 3MB smaller. * Decouple the function build from its devDependencies. The build container installed them and any unsatisfiable peer range failed the build, even though only the compiler is needed. Install the compile tree with --legacy-peer-deps, and strip devDependencies from package.json before the runtime install: --omit=dev is not enough, because npm still resolves them when building the ideal tree. The shipped package.json now describes only what the image contains. * Error instead of emitting an invalid Composition. function generate skipped insertion only when a step matched on both name and functionRef, so a same-named step pointing elsewhere produced two steps sharing a name, which Crossplane rejects. Also correct the help text, which claimed the step is appended when it is prepended, and say why prepending is right. Template fixes: * Add .npmrc with install-links=true. npm symlinks the crossplane-models file: dependency and Node resolves the realpath, which sits outside the function's node_modules, so the models package cannot reach its own dependencies: "Cannot find package '@kubernetes-models/base'" at runtime. * Add Vitest and a starter test, plus ESLint with type-aware rules. TypeScript 7's native compiler no longer exposes the JavaScript compiler API that typescript-eslint needs, so alias TypeScript 6 under the typescript package name for the linter while TypeScript 7 provides tsc. Revisit when 7.1 ships its programmatic API. * Track the SDK 0.6.0 release, and move kubernetes-models to ^5.0.0 so that @kubernetes-models/base resolves to the v6 that generated models depend on, rather than installing a second, older copy. * Show a conversion that compiles in the function template. desiredComposed holds protobuf Resource values, so assigning a kubernetes-models object directly fails with TS2739. Guide fixes: * The Step 3 project file was invalid: xpkg dependencies require apiVersion and kind, and every subsequent command refused to run without them. * Step 5 now matches what xrd generate emits (v2, scope: Cluster, no claims, apis//), and drops the X prefix per v2 naming. * Step 7 no longer hard-codes crossplane.io/external-name on a VPC, where the external name is the AWS-assigned ID the provider writes back. The MR would sit at Ready=False forever while the VPC existed. * Document scope and managed resource group alignment, that render does not catch a mismatch, and the ManagedResourceActivationPolicy v2 needs. * Correct the functionRef naming, which has no separator before the function name, and the claim that generated schemas are .ts. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- cmd/crossplane/function/help/generate.md | 5 +- cmd/crossplane/function/pipeline.go | 11 +- .../function/templates/typescript/.npmrc | 9 + .../function/templates/typescript/README.md | 36 ++- .../templates/typescript/eslint.config.js | 26 ++ .../templates/typescript/package.json.tmpl | 16 +- .../templates/typescript/src/function.test.ts | 25 ++ .../templates/typescript/src/function.ts | 13 +- .../templates/typescript/tsconfig.eslint.json | 5 + .../templates/typescript/tsconfig.json | 2 +- docs/typescript-testing-guide.md | 303 +++++++++++++++--- internal/docker/docker.go | 8 +- internal/project/functions/typescript.go | 149 +++++++-- internal/schemas/generator/typescript.go | 7 +- 14 files changed, 520 insertions(+), 95 deletions(-) create mode 100644 cmd/crossplane/function/templates/typescript/.npmrc create mode 100644 cmd/crossplane/function/templates/typescript/eslint.config.js create mode 100644 cmd/crossplane/function/templates/typescript/src/function.test.ts create mode 100644 cmd/crossplane/function/templates/typescript/tsconfig.eslint.json diff --git a/cmd/crossplane/function/help/generate.md b/cmd/crossplane/function/help/generate.md index 65bd9cd8..e1f5e1ae 100644 --- a/cmd/crossplane/function/help/generate.md +++ b/cmd/crossplane/function/help/generate.md @@ -1,7 +1,8 @@ The `function generate` command creates an embedded function in the specified language under the project's `functions/` directory. It optionally idempotently -adds the new function to the end of a Composition's pipeline when given a -Composition path. +adds the new function to the start of a Composition's pipeline when given a +Composition path, so that it runs before steps such as `function-auto-ready`, +which observe the resources it composes. ## Supported languages diff --git a/cmd/crossplane/function/pipeline.go b/cmd/crossplane/function/pipeline.go index a92ca7f6..a6fef80a 100644 --- a/cmd/crossplane/function/pipeline.go +++ b/cmd/crossplane/function/pipeline.go @@ -47,9 +47,16 @@ func addStepToComposition(fs afero.Fs, path, stepName, functionRef string) error func addCompositionStep(comp *apiextv1.Composition, stepName, functionRef string) error { for _, step := range comp.Spec.Pipeline { - if step.Step == stepName && step.FunctionRef.Name == functionRef { + if step.Step != stepName { + continue + } + if step.FunctionRef.Name == functionRef { return nil // already exists } + // Step names must be unique within a pipeline, so we can't add this + // one alongside the existing step. Rewriting the existing step to + // point somewhere else isn't ours to decide either. + return errors.Errorf("composition already has a step named %q referencing function %q; rename the function or edit the pipeline by hand", stepName, step.FunctionRef.Name) } step := apiextv1.PipelineStep{ @@ -59,6 +66,8 @@ func addCompositionStep(comp *apiextv1.Composition, stepName, functionRef string }, } + // Prepend, so the generated function runs before steps that observe what + // it composes, such as function-auto-ready. comp.Spec.Pipeline = append([]apiextv1.PipelineStep{step}, comp.Spec.Pipeline...) return nil } diff --git a/cmd/crossplane/function/templates/typescript/.npmrc b/cmd/crossplane/function/templates/typescript/.npmrc new file mode 100644 index 00000000..b93261e3 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/.npmrc @@ -0,0 +1,9 @@ +# The generated crossplane-models package is a file: dependency. By default npm +# symlinks such dependencies, and Node resolves the symlink to its real path — +# which lives outside this function's node_modules, so the models package +# cannot find its own dependencies and importing it fails at runtime with +# "Cannot find package '@kubernetes-models/base'". +# +# install-links copies file: dependencies into node_modules instead, which also +# matches how the function is laid out inside its runtime image. +install-links=true diff --git a/cmd/crossplane/function/templates/typescript/README.md b/cmd/crossplane/function/templates/typescript/README.md index 34f2758f..bf180147 100644 --- a/cmd/crossplane/function/templates/typescript/README.md +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -24,12 +24,44 @@ npm run local ## Testing -Test your function using `crossplane composition render`: +Unit tests run with [Vitest](https://vitest.dev), alongside the code in `src/`: ```shell -crossplane composition render xr.yaml composition.yaml functions.yaml +npm test ``` +End to end, render the composition against an example XR: + +```shell +crossplane composition render xr.yaml composition.yaml +``` + +## Linting + +```shell +npm run lint +``` + +## Why there are two TypeScript compilers + +TypeScript 7's native compiler no longer exposes the JavaScript compiler API that +`typescript-eslint` is built on, so the two cannot share one install. `package.json` +therefore aliases both: + +```json +"@typescript/native": "npm:typescript@^7.0.0", +"typescript": "npm:@typescript/typescript6@^6.0.2" +``` + +TypeScript 7 provides the `tsc` binary that `npm run build` uses. TypeScript 6 keeps the +`typescript` package *name*, which is what `typescript-eslint` imports to get the compiler +API — and exposes its own binary as `tsc6`, so the two never collide. `npm run +typecheck:legacy` runs the TypeScript 6 check, which is worth doing once when upgrading +because TypeScript 7 drops deprecated compiler options. + +Remove the alias and go back to a plain `typescript` devDependency once TypeScript 7.1 ships +its programmatic API and `typescript-eslint` adopts it. + ## Learn More - [Composition Functions documentation](https://docs.crossplane.io/latest/concepts/composition-functions/) diff --git a/cmd/crossplane/function/templates/typescript/eslint.config.js b/cmd/crossplane/function/templates/typescript/eslint.config.js new file mode 100644 index 00000000..77557fb6 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + + { + languageOptions: { + parserOptions: { + // tsconfig.json excludes tests so they stay out of dist/, but the type + // aware rules still need them in a program, so lint against a config + // that includes everything. + project: './tsconfig.eslint.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // RunFunction is async because FunctionHandler requires a Promise, not + // because it necessarily awaits anything. + '@typescript-eslint/require-await': 'off', + }, + }, + + { ignores: ['dist/**', 'node_modules/**', '*.config.js'] } +); diff --git a/cmd/crossplane/function/templates/typescript/package.json.tmpl b/cmd/crossplane/function/templates/typescript/package.json.tmpl index a442a881..5409ef08 100644 --- a/cmd/crossplane/function/templates/typescript/package.json.tmpl +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -7,19 +7,29 @@ "main": "dist/main.js", "scripts": { "build": "tsc", + "typecheck:legacy": "tsc6 --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", "local": "node dist/main.js --insecure --debug" }, "dependencies": { - "@crossplane-org/function-sdk-typescript": "^0.5.0", + "@crossplane-org/function-sdk-typescript": "^0.6.0", "@types/node": "^26.0.0", "commander": "^15.0.0", {{- if .HasSchemas }} "crossplane-models": "file:{{ .SchemasPath }}", {{- end }} - "kubernetes-models": "^4.5.1", + "kubernetes-models": "^5.0.0", "pino": "^10.3.0" }, "devDependencies": { - "typescript": "^7.0.0" + "@eslint/js": "^10.0.1", + "@typescript/native": "npm:typescript@^7.0.0", + "eslint": "^10.9.1", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" } } diff --git a/cmd/crossplane/function/templates/typescript/src/function.test.ts b/cmd/crossplane/function/templates/typescript/src/function.test.ts new file mode 100644 index 00000000..159b502a --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { RunFunctionRequest } from '@crossplane-org/function-sdk-typescript'; +import { Function } from './function.js'; + +describe('Function', () => { + it('composes a response from an observed composite resource', async () => { + const req = RunFunctionRequest.fromJSON({ + observed: { + composite: { + resource: { + apiVersion: 'example.crossplane.io/v1alpha1', + kind: 'Example', + metadata: { name: 'example' }, + spec: {}, + }, + }, + }, + }); + + const rsp = await new Function().RunFunction(req); + + expect(rsp.desired).toBeDefined(); + expect(rsp.results.map((r) => r.message)).toContain('Function completed successfully'); + }); +}); diff --git a/cmd/crossplane/function/templates/typescript/src/function.ts b/cmd/crossplane/function/templates/typescript/src/function.ts index 08f9c0bf..767f4128 100644 --- a/cmd/crossplane/function/templates/typescript/src/function.ts +++ b/cmd/crossplane/function/templates/typescript/src/function.ts @@ -26,9 +26,16 @@ export class Function implements FunctionHandler { 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: { ... } }; + // Use desiredComposed to add, modify, or remove composed resources. Each + // entry is a Resource, so a kubernetes-models object such as one of the + // generated crossplane-models classes has to be converted first: + // + // import { Resource } from '@crossplane-org/function-sdk-typescript'; + // import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + // + // const vpc = new VPC({ spec: { forProvider: { region: 'us-west-2' } } }); + // vpc.validate(); + // desiredComposed['my-resource'] = Resource.fromJSON({ resource: vpc.toJSON() }); // Update the response with the desired composed resources. rsp = setDesiredComposedResources(rsp, desiredComposed); diff --git a/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json b/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json new file mode 100644 index 00000000..09192443 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/cmd/crossplane/function/templates/typescript/tsconfig.json b/cmd/crossplane/function/templates/typescript/tsconfig.json index 9143fff2..0c845587 100644 --- a/cmd/crossplane/function/templates/typescript/tsconfig.json +++ b/cmd/crossplane/function/templates/typescript/tsconfig.json @@ -1,5 +1,5 @@ { - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], "compilerOptions": { "rootDir": "./src", "outDir": "./dist", diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md index c5717497..f56715ca 100644 --- a/docs/typescript-testing-guide.md +++ b/docs/typescript-testing-guide.md @@ -41,6 +41,11 @@ crossplane project init configuration-aws-network-ts \ cd configuration-aws-network-ts ``` +**Porting an existing repository?** `project init` refuses to write into a directory that +isn't empty, including with `-d .`. Scaffold into a scratch directory and copy +`crossplane-project.yaml` plus the `apis/`, `functions/`, `examples/`, `tests/`, and +`operations/` directories over, or just write `crossplane-project.yaml` by hand. + ## Step 3: Configure the Project Edit `crossplane-project.yaml` to enable TypeScript schema generation and add dependencies: @@ -60,14 +65,29 @@ spec: dependencies: - type: xpkg xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Provider package: xpkg.upbound.io/upbound/provider-aws-ec2 version: ">=v2.6.0" - type: xpkg xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Function package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready version: ">=v0.7.0" ``` +`apiVersion` and `kind` are required on each `xpkg` dependency. Leaving them out fails +validation on every subsequent command: + +```text +crossplane: error: invalid project file: [dependency 0: xpkg: [apiVersion must not be +empty, kind must not be empty]] +``` + +If you would rather not write them by hand, skip this block and let `crossplane dependency add` +in Step 4 fill the whole section in for you. + ## Step 4: Add Dependencies When a dependency is added to a Crossplane project: @@ -94,7 +114,7 @@ First, create an example XR file that defines your custom resource: mkdir -p examples/network cat > examples/network/example.yaml << 'EOF' apiVersion: aws.platform.upbound.io/v1alpha1 -kind: XNetwork +kind: Network metadata: name: example-network spec: @@ -110,21 +130,25 @@ Then generate the XRD from the example. This will be our Platform API: crossplane xrd generate examples/network/example.yaml ``` -Edit `apis/xnetwork/definition.yaml` to add spec fields: +This writes `apis/networks/definition.yaml` — note the plural directory name. The CLI emits an +`apiextensions.crossplane.io/v2` XRD with `scope: Cluster` and no `claimNames`; claims are a v1 +concept, and in v2 you use the XR directly. + +Edit `apis/networks/definition.yaml` to add descriptions, defaults and status fields: ```yaml -apiVersion: apiextensions.crossplane.io/v1 +apiVersion: apiextensions.crossplane.io/v2 kind: CompositeResourceDefinition metadata: - name: xnetworks.aws.platform.upbound.io + name: networks.aws.platform.upbound.io spec: group: aws.platform.upbound.io names: - kind: XNetwork - plural: xnetworks - claimNames: + categories: + - crossplane kind: Network plural: networks + scope: Cluster versions: - name: v1alpha1 served: true @@ -154,6 +178,20 @@ spec: description: The ID of the created VPC ``` +### Cluster-scoped or namespaced? + +This choice determines which generated types your function must import, and getting it wrong +fails only at apply time. + +- `scope: Cluster` (the default above) composes **cluster-scoped** managed resources — import + from `crossplane-models/ec2.aws.upbound.io/v1beta1`. +- `scope: Namespaced` composes **namespaced** managed resources — import from the mirrored `.m.` + group instead, `crossplane-models/ec2.aws.m.upbound.io/v1beta1`. + +Mixing them gets you `cannot apply cluster scoped composed resource for a namespaced composite +resource` on the cluster. Note that `crossplane composition render` renders the mismatched +combination without complaint, so this does not surface until you deploy. + ## Step 6: Create a TypeScript Function ```bash @@ -164,7 +202,7 @@ crossplane function generate network --language typescript **Note**: You can also generate a function and add it to a composition pipeline in one step: ```bash -crossplane function generate network apis/xnetwork/composition.yaml --language typescript +crossplane function generate network apis/networks/composition.yaml --language typescript ``` This creates `functions/network/` with: @@ -173,6 +211,9 @@ This creates `functions/network/` with: - `tsconfig.json` - TypeScript configuration - `src/main.ts` - Entry point - `src/function.ts` - Function implementation template +- `src/function.test.ts` - Starter Vitest test +- `.npmrc` - Sets `install-links=true` (see Step 9) +- `eslint.config.js` and `tsconfig.eslint.json` - Type-aware linting (see Troubleshooting) ## Step 7: Implement the Function @@ -192,9 +233,12 @@ import { getObservedCompositeResource, getDesiredComposedResources, setDesiredComposedResources, + Resource, } from '@crossplane-org/function-sdk-typescript'; -// Import the generated types from crossplane-models +// Import the generated types from crossplane-models. This is the cluster-scoped +// group, matching the `scope: Cluster` XRD from Step 5. For a namespaced XRD, +// import from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1' instead. import { VPC } from 'crossplane-models/ec2.aws.upbound.io/v1beta1'; /** @@ -221,13 +265,17 @@ export class Function implements FunctionHandler { // Get the desired composed resources from previous functions in the pipeline. const desiredComposed = getDesiredComposedResources(req); - // Create a VPC using the generated TypeScript class + // Create a VPC using the generated TypeScript class. + // + // Do NOT set crossplane.io/external-name here. For a VPC the external name + // is the AWS-assigned ID, which the provider writes back after creation. + // Setting it yourself makes the provider look for a VPC by that name + // forever, so the resource stays Ready=False/Creating even though the VPC + // exists in AWS. Only set it when you genuinely control the external + // identifier, and use tags for human-readable names. const vpc = new VPC({ metadata: { name: `${xrName}-vpc`, - annotations: { - 'crossplane.io/external-name': `${xrName}-vpc`, - }, }, spec: { forProvider: { @@ -243,8 +291,13 @@ export class Function implements FunctionHandler { }, }); - // Add the VPC to desired resources - desiredComposed['vpc'] = { resource: vpc }; + // Validate the model against the CRD schema before composing it. + vpc.validate(); + + // Add the VPC to desired resources. The map holds protobuf Resource values, + // not kubernetes-models objects, so convert first — assigning + // `{ resource: vpc }` fails to compile with TS2739. + desiredComposed['vpc'] = Resource.fromJSON({ resource: vpc.toJSON() }); // Update the response with the desired composed resources. rsp = setDesiredComposedResources(rsp, desiredComposed); @@ -255,6 +308,14 @@ export class Function implements FunctionHandler { } ``` +**On `fromModel`**: the SDK also offers `fromModel(vpc)` as a shorthand for the conversion +above, and from `@crossplane-org/function-sdk-typescript@0.6.0` it accepts the generated models. +Earlier versions required `toJSON(): Record` while `@kubernetes-models/base` +declares `toJSON(): unknown` from v6 onward, so the call failed with TS2345 +([function-sdk-typescript#26](https://github.com/crossplane/function-sdk-typescript/pull/26)). +`Resource.fromJSON({ resource: vpc.toJSON() })` works on every version, which is why the example +above uses it. + The generated `package.json` already includes the `crossplane-models` dependency when TypeScript schemas are enabled. It will look like: ```json @@ -267,22 +328,36 @@ The generated `package.json` already includes the `crossplane-models` dependency "main": "dist/main.js", "scripts": { "build": "tsc", + "typecheck:legacy": "tsc6 --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", "local": "node dist/main.js --insecure --debug" }, "dependencies": { - "@crossplane-org/function-sdk-typescript": "^0.5.0", + "@crossplane-org/function-sdk-typescript": "^0.6.0", "@types/node": "^26.0.0", "commander": "^15.0.0", "crossplane-models": "file:../../schemas/typescript", - "kubernetes-models": "^4.5.1", + "kubernetes-models": "^5.0.0", "pino": "^10.3.0" }, "devDependencies": { - "typescript": "^7.0.0" + "@eslint/js": "^10.0.1", + "@typescript/native": "npm:typescript@^7.0.0", + "eslint": "^10.9.1", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" } } ``` +`kubernetes-models` is pinned to `^5.0.0` deliberately: v5 brings `@kubernetes-models/base` v6, +which is the version the generated `crossplane-models` package depends on. Staying on v4 installs +a second, older copy of `base` alongside it. + ## Step 8: Generate Schemas Before building, generate the TypeScript schemas from the dependencies: @@ -292,10 +367,14 @@ Before building, generate the TypeScript schemas from the dependencies: crossplane project build ``` -After this, `schemas/typescript/` will contain generated TypeScript models including: +The models are generated as TypeScript, then compiled — so `schemas/typescript/` holds JavaScript +plus declarations, not `.ts` sources: -- `ec2.aws.upbound.io/v1beta1/VPC.ts` - VPC class with full type definitions -- `aws.platform.upbound.io/v1alpha1/XNetwork.ts` - Your XRD's types +- `ec2.aws.upbound.io/v1beta1/VPC.js` and `VPC.d.ts` - VPC class with full type definitions +- `aws.platform.upbound.io/v1alpha1/Network.js` and `Network.d.ts` - Your XRD's types + +Schema generation runs once per dependency and is not cheap: adding a function package that +contributes no CRDs at all still costs a few minutes. ## Step 9: Local Development (Optional) @@ -309,31 +388,52 @@ npm install # Build locally to check for TypeScript errors npm run build + +# Run the unit tests +npm test +``` + +This relies on the `.npmrc` in the generated function directory, which sets: + +```ini +install-links=true +``` + +Without it, npm symlinks `crossplane-models` to `../../schemas/typescript`. Node resolves the +symlink to its real path, which sits outside the function's `node_modules`, so the schemas +package cannot reach its own dependencies and any import of a generated model fails at runtime: + +```text +Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@kubernetes-models/base' +imported from .../schemas/typescript/ec2.aws.upbound.io/v1beta1/VPC.js ``` +`install-links=true` copies the package into `node_modules` instead, which also matches the +layout inside the built function image. If you are working in a project scaffolded before this +setting existed, add the `.npmrc` yourself or run `npm install --install-links`. + ## Step 10: Create a Composition ```bash # Generate a composition from the XRD -crossplane composition generate apis/xnetwork/definition.yaml +crossplane composition generate apis/networks/definition.yaml ``` This generates a basic composition with `function-auto-ready`. You need to add your embedded function to the pipeline. -The function name in the composition follows the pattern: `--` derived from the project repository. For example, if your repository is `xpkg.upbound.io/your-org/configuration-aws-network-ts` and your function is named `network`, the functionRef name will be `your-org-configuration-aws-network-ts-network`. +The functionRef name is derived from the project repository and the function name. The CLI builds the embedded function's image repository as `_`, then converts it to a DNS label — which drops the underscore rather than replacing it. So for repository `xpkg.upbound.io/your-org/configuration-aws-network-ts` and function `network`, the functionRef name is `your-org-configuration-aws-network-tsnetwork`, with no separator before `network`. -Edit `apis/xnetwork/composition.yaml` to add your function before `function-auto-ready`. The `name` of the function is of the format: -`-`. +Edit `apis/networks/composition.yaml` to add your function before `function-auto-ready`: ```yaml apiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: - name: xnetworks.aws.platform.upbound.io + name: networks.aws.platform.upbound.io spec: compositeTypeRef: apiVersion: aws.platform.upbound.io/v1alpha1 - kind: XNetwork + kind: Network mode: Pipeline pipeline: - step: network @@ -347,9 +447,28 @@ spec: **Tip**: You can generate the function and add it to the composition pipeline automatically by running: ```bash -crossplane function generate network apis/xnetwork/composition.yaml --language typescript +crossplane function generate network apis/networks/composition.yaml --language typescript ``` +The step is inserted at the **front** of the pipeline, so your function runs before `function-auto-ready` sees the resources it composes. If the Composition already has a step with that name pointing at a different function — which happens when porting an existing configuration — the command fails rather than creating two steps with the same name, and you edit the pipeline by hand. + +### Activating managed resources (Crossplane 2) + +If your XRD is `apiextensions.crossplane.io/v2` and your function composes namespaced managed resources (the `*.m.upbound.io` API groups), Crossplane 2 does not activate those CRDs by default. Add a `ManagedResourceActivationPolicy` alongside the XRD, listing every managed resource kind the function creates: + +```yaml +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: ManagedResourceActivationPolicy +metadata: + name: network +spec: + activate: + - vpcs.ec2.aws.m.upbound.io + - subnets.ec2.aws.m.upbound.io +``` + +Without it the composed resources are created but never reconciled. `crossplane composition render` does not need the policy, so this only shows up once you deploy to a cluster. + ## Step 11: Build the Project ```bash @@ -373,7 +492,7 @@ Before deploying to a cluster, you can test your composition function locally us # Render the composition with a 5 minute timeout (recommended for TypeScript builds) crossplane composition render \ examples/network/example.yaml \ - apis/xnetwork/composition.yaml \ + apis/networks/composition.yaml \ --timeout=5m ``` @@ -384,7 +503,9 @@ The first run may take several minutes as it: 3. Compiles the TypeScript function 4. Executes the function pipeline -Subsequent runs will be faster due to Docker and npm caching. +Docker and npm caching help on subsequent runs, but not dramatically — the function is rebuilt +in a container every time, so expect a warm render to still take minutes rather than seconds. +Keep `--timeout` generous even once things are cached. The output shows the rendered XR and all composed resources as YAML: @@ -392,14 +513,14 @@ The output shows the rendered XR and all composed resources as YAML: # Include function results (informational messages) crossplane composition render \ examples/network/example.yaml \ - apis/xnetwork/composition.yaml \ + apis/networks/composition.yaml \ --timeout=5m \ --include-function-results # Include the full XR with spec and metadata crossplane composition render \ examples/network/example.yaml \ - apis/xnetwork/composition.yaml \ + apis/networks/composition.yaml \ --timeout=5m \ --include-full-xr ``` @@ -454,13 +575,13 @@ EOF Now you can test your configuration: ```bash -# Create a claim to test your function +# Create an XR to test your function. The XRD from Step 5 is cluster scoped and +# has no claim, so apply the Network XR directly — v2 drops the X prefix. kubectl apply -f - <_` — so a project +at `xpkg.upbound.io/your-org/configuration-aws-network-ts` with a function named `network` +pushes to `xpkg.upbound.io/your-org/configuration-aws-network-ts_network`. Functions are pushed +first, so if that repository is missing the configuration never gets uploaded at all. + +```bash # Install on a cluster kubectl apply -f - <_` has to be created before the +first release of a project with an embedded function. This bites when converting an existing +configuration in particular, because the function's repository name changes: a function that +used to ship as `configuration-aws-network-ts-function` becomes +`configuration-aws-network-ts_network`, which has never existed. + +Since functions are pushed before the configuration, this fails the whole push and leaves +nothing published — the tag exists with no artifact behind it. Create the repository and re-run +`crossplane project push`; there is no need to re-tag. + ### Build timeout during render If you see an error like: @@ -558,17 +741,41 @@ Increase the timeout using the `--timeout` flag: ```bash # Use a 5 minute timeout -crossplane composition render examples/network/example.yaml apis/xnetwork/composition.yaml --timeout=5m +crossplane composition render examples/network/example.yaml apis/networks/composition.yaml --timeout=5m # Or for larger projects with many dependencies -crossplane composition render examples/network/example.yaml apis/xnetwork/composition.yaml --timeout=10m +crossplane composition render examples/network/example.yaml apis/networks/composition.yaml --timeout=10m ``` -Subsequent builds will be faster as Docker images and npm packages are cached. +Subsequent builds are faster as Docker images and npm packages are cached, but the function is +still recompiled in a container on every render, so they are not instant. -## Reference Project +## Reference Projects -For a complete working example, see: +For a complete working example built from scratch, see: https://github.com/stevendborrelli/configuration-aws-network-ts-xp-cli -This repository demonstrates all the patterns described in this guide. +For an example of porting an existing configuration — one that previously built its +function image with a hand-written Dockerfile and packaged it with `crossplane xpkg build` +— see: +https://github.com/upbound/configuration-aws-network-ts + +That one is released, so you can see what a project built this way produces without building +anything yourself: + +```bash +crossplane xpkg install configuration \ + xpkg.upbound.io/upbound/configuration-aws-network-ts:v0.2.0 +``` + +v0.2.0 is the first release built with `crossplane project build`. The configuration package is +a single manifest; the embedded function at +`xpkg.upbound.io/upbound/configuration-aws-network-ts_network:v0.2.0` is a multi-architecture +index covering `linux/amd64` and `linux/arm64`. + +Note that its CI builds the CLI from this PR's branch rather than installing a release, since +the feature has not shipped yet — so v0.2.0 was itself built from an unreleased CLI. That is +marked temporary in `.github/actions/crossplane-cli` and comes out once a release includes the +feature. + +Both repositories demonstrate the patterns described in this guide. diff --git a/internal/docker/docker.go b/internal/docker/docker.go index c9202e70..77dab683 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -313,8 +313,14 @@ func WaitForContainerByID(ctx context.Context, cid string) error { return errors.Wrapf(err, "failed to get container logs") } + defer out.Close() //nolint:errcheck // Nothing useful to do with a close error here. + + // Container logs arrive as a multiplexed stream that frames stdout + // and stderr with an 8-byte header. Demultiplex both into the same + // builder so the header bytes don't end up interleaved in the + // error message. logs := new(strings.Builder) - if _, err := io.Copy(logs, out); err != nil { + if _, err := stdcopy.StdCopy(logs, logs, out); err != nil { return errors.Wrapf(err, "failed to read container logs") } diff --git a/internal/project/functions/typescript.go b/internal/project/functions/typescript.go index ea696247..4094cb38 100644 --- a/internal/project/functions/typescript.go +++ b/internal/project/functions/typescript.go @@ -24,6 +24,7 @@ import ( "net/http" "path" "path/filepath" + "strings" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -47,6 +48,58 @@ const ( typescriptBuildImage = "docker.io/library/node:24-slim" // typescriptRuntimeImage is the distroless base used at runtime. typescriptRuntimeImage = "gcr.io/distroless/nodejs24-debian13" + // typescriptBuildScript is the shell pipeline that runs in the build + // container. + // + // SCHEMAS_PATH is the absolute path to the generated TypeScript schemas, or + // empty if the project has none. ARCHS is the space-separated list of + // target architectures, in npm's naming (see npmArchitecture). + typescriptBuildScript = `set -eu +# First, install dependencies for the schemas package so TypeScript can resolve +# the base types. +if [ -n "$SCHEMAS_PATH" ] && [ -d "$SCHEMAS_PATH" ] && [ -f "$SCHEMAS_PATH/package.json" ]; then + cd "$SCHEMAS_PATH" && npm install --no-fund + cd - +fi +# Install and compile using the build container's own architecture. The +# TypeScript 7 compiler ships as a per-platform native binary, so it can only +# run if node_modules matches the architecture we're running on. +# +# This tree is throwaway: it exists only to run the compiler, and nothing from +# it ships. We therefore install it with --legacy-peer-deps, so that a function +# whose devDependencies carry an unsatisfiable peer range still builds. That is +# routine today — the lint and test tooling most TypeScript projects reach for +# still caps its typescript peer below 7. The runtime install below stays +# strict, because that tree is the one that ends up in the image. +npm install --no-fund --legacy-peer-deps +npm run build +# Compilation is done, so drop the devDependencies from package.json entirely. +# --omit=dev alone is not enough: npm still resolves devDependencies when it +# builds the ideal tree, so a build-only package with an unsatisfiable peer +# range would fail the runtime install even though it is never installed. +# Removing them also means the package.json that ships in the image describes +# only what the image actually contains. +node -e 'const f="package.json",p=require("./"+f);delete p.devDependencies;require("fs").writeFileSync(f,JSON.stringify(p,null,2)+"\n")' + +# Reinstall the runtime dependencies once per target architecture. We reinstall +# in place rather than into /fn_$arch so that file: dependencies (like +# crossplane-models) keep resolving relative to the function directory. +# +# --omit=dev drops the build-only dependencies, most importantly the native +# TypeScript compiler, which would otherwise ship in every image. --cpu/--os +# select the right prebuilt artifacts for packages that publish one per +# platform. Note that they only steer optional-dependency selection: they do +# not cross-compile node-gyp source builds, so a dependency that compiles from +# source still produces build-host output. +for arch in $ARCHS ; do + rm -rf node_modules + npm install --omit=dev --no-fund --cpu=$arch --os=linux + mkdir -p /fn_$arch + # Use -L to dereference symlinks so file: dependencies (like crossplane-models) + # are copied as actual files, not symlinks that won't resolve at runtime. + cp -rL . /fn_$arch +done +` ) // typescriptBuilder builds TypeScript composition functions. @@ -54,7 +107,9 @@ const ( // A TypeScript embedded function is a full function-sdk-typescript project // (package.json + tsconfig.json). We build it by running npm install and npm run build // (which invokes tsc) in a Node.js build container, then copy the dist/ -// and node_modules/ onto a distroless Node.js base. +// and node_modules/ onto a distroless Node.js base. Runtime dependencies are +// installed once per target architecture, so each image gets a node_modules +// matching the architecture it will run on. type typescriptBuilder struct { buildImage string runtimeImage string @@ -80,10 +135,10 @@ func (b *typescriptBuilder) match(fromFS afero.Fs) (bool, error) { func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { if err := docker.Check(ctx); err != nil { - return nil, errors.Wrap(err, "typescript builds require a Docker-compatible container runtime") + return nil, errors.Wrap(err, "cannot build the TypeScript function because Docker is unavailable; start or install Docker, then retry") } - functionTar, err := b.buildFunction(ctx, c) + functionTars, err := b.buildFunction(ctx, c) if err != nil { return nil, err } @@ -112,7 +167,7 @@ func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Ima } functionLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { - return io.NopCloser(bytes.NewReader(functionTar)), nil + return io.NopCloser(bytes.NewReader(functionTars[arch])), nil }) if err != nil { return errors.Wrap(err, "failed to create function layer") @@ -123,7 +178,7 @@ func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Ima return errors.Wrap(err, "failed to append function layer") } - img, err = configureTypescriptImage(img) + img, err = configureTypescriptImage(img, arch) if err != nil { return errors.Wrap(err, "failed to configure typescript image") } @@ -136,17 +191,19 @@ func (b *typescriptBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Ima return images, eg.Wait() } -// buildFunction runs the build container against the function source and returns a -// tar of /function suitable for use as an image layer. +// buildFunction runs the build container against the function source and +// returns tars of /fn_ for each architecture, suitable for use as image +// layers. // // The function source is staged at / in the build container and, if a // typescript schemas tree exists, //typescript/models/ — preserving // the project's relative layout so that npm resolves the schemas path-dep from -// package.json. After building, we copy the built artifacts to /function and tar -// that directory for the runtime layer. +// package.json. The compile step runs once, but the runtime dependencies are +// installed once per target architecture so that packages shipping per-platform +// binaries resolve correctly; see typescriptBuildScript. // //nolint:contextcheck // The defer uses context.Background() intentionally for cleanup. -func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ([]byte, error) { +func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) (map[string][]byte, error) { fnFS := c.FunctionFS() // Exclude node_modules the user might have created locally. // Use the function path as the tar prefix so files end up at / in the container. @@ -181,27 +238,29 @@ func (b *typescriptBuilder) buildFunction(ctx context.Context, c BuildContext) ( buildImage = rewritten } - // Build script that: - // 1. Runs npm install and build in the function's original path (so relative deps resolve) - // 2. Copies the built artifacts to /function for the runtime layer + // The build runs in the function's original path so that relative deps + // resolve, and leaves one /fn_ tree per target architecture. fnPath := "/" + filepath.ToSlash(c.FunctionPath) - tsSchemasPath := "/" + filepath.ToSlash(tsSchemasRel) - buildScript := fmt.Sprintf(`set -eu -# First, install dependencies for the schemas package so TypeScript can resolve the base types -if [ -d "%s" ] && [ -f "%s/package.json" ]; then - cd %s && npm install --no-fund - cd - -fi -npm install --no-fund -npm run build -# Use -L to dereference symlinks so file: dependencies (like crossplane-models) -# are copied as actual files, not symlinks that won't resolve at runtime. -cp -rL . /function -`, tsSchemasPath, tsSchemasPath, tsSchemasPath) + var tsSchemasPath string + if hasTSSchemas { + tsSchemasPath = "/" + filepath.ToSlash(tsSchemasRel) + } + + npmArchitectures := make([]string, len(c.Architectures)) + for i, a := range c.Architectures { + npmArchitectures[i], err = npmArchitecture(a) + if err != nil { + return nil, err + } + } opts := []docker.StartContainerOption{ docker.StartWithCopyFiles(fnTar, "/"), - docker.StartWithCommand([]string{"sh", "-c", buildScript}), + docker.StartWithEnv( + "ARCHS="+strings.Join(npmArchitectures, " "), + "SCHEMAS_PATH="+tsSchemasPath, + ), + docker.StartWithCommand([]string{"sh", "-c", typescriptBuildScript}), docker.StartWithWorkingDirectory(fnPath), } if schemasTar != nil { @@ -221,21 +280,49 @@ cp -rL . /function return nil, errors.Wrap(err, "typescript build container failed") } - return docker.TarFromContainer(ctx, cid, "/function") + ret := make(map[string][]byte, len(c.Architectures)) + for _, arch := range c.Architectures { + npmArch, _ := npmArchitecture(arch) // Ignore the error since we already did this once. + ret[arch], err = docker.TarFromContainer(ctx, cid, fmt.Sprintf("/fn_%s", npmArch)) + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve built function for architecture %s", arch) + } + } + + return ret, nil +} + +// npmArchitecture maps an OCI architecture to the name npm expects for its +// --cpu flag, which follows Node's process.arch naming. +func npmArchitecture(a string) (string, error) { + switch a { + case "amd64": + return "x64", nil + case "arm64": + return "arm64", nil + default: + return "", errors.Errorf("unable to determine npm architecture for architecture %s", a) + } } // configureTypescriptImage sets the runtime configuration on the final image: -// the function entrypoint and the gRPC port. -func configureTypescriptImage(img v1.Image) (v1.Image, error) { +// the function entrypoint and the gRPC port. The working directory is the +// architecture's own /fn_ tree, so that Node resolves the node_modules +// built for this architecture. +func configureTypescriptImage(img v1.Image, arch string) (v1.Image, error) { cfgFile, err := img.ConfigFile() if err != nil { return nil, errors.Wrap(err, "failed to get config file") } cfg := cfgFile.Config + npmArch, err := npmArchitecture(arch) + if err != nil { + return nil, err + } cfg.Entrypoint = []string{"/nodejs/bin/node", "dist/main.js"} cfg.Cmd = nil - cfg.WorkingDir = "/function" + cfg.WorkingDir = fmt.Sprintf("/fn_%s", npmArch) if cfg.ExposedPorts == nil { cfg.ExposedPorts = map[string]struct{}{} } diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index 1f1d2698..2d5331ea 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -305,7 +305,10 @@ npm install # Run crd-generate (reads config from package.json) npx crd-generate -# Create tsconfig.json for compilation +# Create tsconfig.json for compilation. We deliberately don't emit sourceMap or +# declarationMap: only dist/ ships in the models package, so every map would +# point at a gen/*.ts source that isn't there, and tools that read maps (test +# runners, bundlers) would warn once per generated type. cat > tsconfig.json << 'TSEOF' { "compilerOptions": { @@ -313,8 +316,6 @@ cat > tsconfig.json << 'TSEOF' "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, - "declarationMap": true, - "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 0e1581b2ac928bf7fc1bc0d8a9c91eab07a11737 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 29 Aug 2026 17:39:56 +0100 Subject: [PATCH 13/15] Update TypeScript template for SDK 0.7.0 serve() and fromModel() The SDK's new serve() takes a ComposeFunction and handles flag parsing, logger construction, server startup, and shutdown, so a function's entrypoint is a single call. It also hands the compose function a response already built from the request, typed so that desired is non-optional. Rewrite the template around that: - src/main.ts drops commander, pino, and the gRPC wiring in favour of serve(compose, { name }). This also picks up SIGTERM handling, which the old entrypoint lacked. - src/function.ts exports a ComposeFunction rather than implementing FunctionHandler. to(), getDesiredComposedResources(), and setDesiredComposedResources() are no longer needed; composed resources are written to rsp.desired.resources, and fromModel() replaces the Resource.fromJSON(...toJSON()) conversion in the example comment. - src/function.test.ts drives the function through fromCompose(). - package.json bumps the SDK to ^0.7.0 and drops commander and pino, which were only there for the old entrypoint. pino remains available through the SDK for the Logger type. Templating the function name into main.ts needs the name in the template data, so add it to typescriptTemplateData. Verified by generating a function and running npm install, npm run build, npm test, and npm run lint against it, then starting the built server and confirming clean SIGTERM shutdown. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- cmd/crossplane/function/generate.go | 2 + .../function/templates/typescript/README.md | 45 +++++++++++ .../templates/typescript/package.json.tmpl | 6 +- .../templates/typescript/src/function.test.ts | 10 ++- .../templates/typescript/src/function.ts | 51 ++++++------ .../function/templates/typescript/src/main.ts | 77 +------------------ 6 files changed, 83 insertions(+), 108 deletions(-) diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index f0b1e3f1..34942fdb 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -422,6 +422,7 @@ func (c *generateCmd) generateGoTemplatingFiles(fs afero.Fs) error { } type typescriptTemplateData struct { + Name string HasSchemas bool SchemasPath string } @@ -448,6 +449,7 @@ func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "typescript")) data := typescriptTemplateData{ + Name: c.Name, HasSchemas: hasSchemas, SchemasPath: schemasPath, } diff --git a/cmd/crossplane/function/templates/typescript/README.md b/cmd/crossplane/function/templates/typescript/README.md index bf180147..74156ae9 100644 --- a/cmd/crossplane/function/templates/typescript/README.md +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -2,6 +2,44 @@ This is a [Crossplane](https://crossplane.io) composition function written in TypeScript. +## How it works + +`src/function.ts` exports a `compose` function, and `src/main.ts` hands it to the SDK's +`serve`: + +```ts +serve(compose, { name: 'my-function' }); +``` + +`serve` parses the standard function flags, builds a logger from `--debug`, starts the +gRPC server, and shuts down cleanly on `SIGINT` and `SIGTERM`, so the entrypoint needs +nothing else. + +Your `compose` receives the request and a response already built from it, so there is no +`to(req)` call to make. The response type narrows `desired` to non-optional, which means +composed resources are written straight to `rsp.desired.resources` with no `!`: + +```ts +export const compose: ComposeFunction = async (req, rsp, logger) => { + rsp.desired.resources['my-resource'] = /* ... */; + return rsp; +}; +``` + +Returning the response is required, so forgetting it is a compile error rather than an +empty response at runtime. + +To add a composed resource, build a `kubernetes-models` object — including the +`crossplane-models` classes generated from your XRDs — and convert it with `fromModel`: + +```ts +import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + +const vpc = new VPC({ spec: { forProvider: { region: 'us-west-2' } } }); +vpc.validate(); +rsp.desired.resources['my-resource'] = fromModel(vpc); +``` + ## Development Install dependencies: @@ -30,6 +68,13 @@ Unit tests run with [Vitest](https://vitest.dev), alongside the code in `src/`: npm test ``` +Use `fromCompose` to wrap `compose` into a handler the test can call directly: + +```ts +const func = fromCompose(compose); +const rsp = await func.RunFunction(req); +``` + End to end, render the composition against an example XR: ```shell diff --git a/cmd/crossplane/function/templates/typescript/package.json.tmpl b/cmd/crossplane/function/templates/typescript/package.json.tmpl index 5409ef08..98744c05 100644 --- a/cmd/crossplane/function/templates/typescript/package.json.tmpl +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -15,14 +15,12 @@ "local": "node dist/main.js --insecure --debug" }, "dependencies": { - "@crossplane-org/function-sdk-typescript": "^0.6.0", + "@crossplane-org/function-sdk-typescript": "^0.7.0", "@types/node": "^26.0.0", - "commander": "^15.0.0", {{- if .HasSchemas }} "crossplane-models": "file:{{ .SchemasPath }}", {{- end }} - "kubernetes-models": "^5.0.0", - "pino": "^10.3.0" + "kubernetes-models": "^5.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/cmd/crossplane/function/templates/typescript/src/function.test.ts b/cmd/crossplane/function/templates/typescript/src/function.test.ts index 159b502a..42b6e972 100644 --- a/cmd/crossplane/function/templates/typescript/src/function.test.ts +++ b/cmd/crossplane/function/templates/typescript/src/function.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { RunFunctionRequest } from '@crossplane-org/function-sdk-typescript'; -import { Function } from './function.js'; +import { fromCompose, RunFunctionRequest } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; + +describe('compose', () => { + const func = fromCompose(compose); -describe('Function', () => { it('composes a response from an observed composite resource', async () => { const req = RunFunctionRequest.fromJSON({ observed: { @@ -17,7 +19,7 @@ describe('Function', () => { }, }); - const rsp = await new Function().RunFunction(req); + const rsp = await func.RunFunction(req); expect(rsp.desired).toBeDefined(); expect(rsp.results.map((r) => r.message)).toContain('Function completed successfully'); diff --git a/cmd/crossplane/function/templates/typescript/src/function.ts b/cmd/crossplane/function/templates/typescript/src/function.ts index 767f4128..bd0a17e2 100644 --- a/cmd/crossplane/function/templates/typescript/src/function.ts +++ b/cmd/crossplane/function/templates/typescript/src/function.ts @@ -1,46 +1,45 @@ import { - type RunFunctionRequest, - type RunFunctionResponse, - type FunctionHandler, - type Logger, - to, - normal, + type ComposeFunction, + fatal, getObservedCompositeResource, - getDesiredComposedResources, - setDesiredComposedResources, + normal, } from '@crossplane-org/function-sdk-typescript'; /** - * Function is a Crossplane composition function. + * compose is a Crossplane composition function. + * + * serve() hands us a response already built from the request, so there is no + * to(req) here, and rsp.desired is guaranteed to be present. */ -export class Function implements FunctionHandler { - async RunFunction(req: RunFunctionRequest, logger?: Logger): Promise { - let rsp = to(req); - +export const compose: ComposeFunction = async (req, rsp, logger) => { + try { // 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. Each - // entry is a Resource, so a kubernetes-models object such as one of the - // generated crossplane-models classes has to be converted first: // - // import { Resource } from '@crossplane-org/function-sdk-typescript'; + // Write composed resources straight onto the response. ComposeResponse + // narrows desired to non-optional, so there is no need for rsp.desired!. + // fromModel converts a kubernetes-models object — such as one of the + // classes generated from your XRDs — into a Resource: + // + // import { fromModel } from '@crossplane-org/function-sdk-typescript'; // import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; // // const vpc = new VPC({ spec: { forProvider: { region: 'us-west-2' } } }); // vpc.validate(); - // desiredComposed['my-resource'] = Resource.fromJSON({ resource: vpc.toJSON() }); - - // Update the response with the desired composed resources. - rsp = setDesiredComposedResources(rsp, desiredComposed); + // rsp.desired.resources['my-resource'] = fromModel(vpc); normal(rsp, 'Function completed successfully'); return rsp; + } catch (error) { + logger?.error( + { error: error instanceof Error ? error.message : String(error) }, + 'Function invocation failed' + ); + + fatal(rsp, error instanceof Error ? error.message : String(error)); + return rsp; } -} +}; diff --git a/cmd/crossplane/function/templates/typescript/src/main.ts b/cmd/crossplane/function/templates/typescript/src/main.ts index 29e08714..78bb5b34 100644 --- a/cmd/crossplane/function/templates/typescript/src/main.ts +++ b/cmd/crossplane/function/templates/typescript/src/main.ts @@ -1,77 +1,6 @@ #!/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'; +import { serve } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; -const defaultAddress = '0.0.0.0:9443'; -const defaultTlsServerCertsDir = '/tls/server'; - -const program = new Command('function') - .option('--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 ', - '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(); +serve(compose, { name: '{{ .Name }}' }); From aa552659415f384370b4f04b67e3fce3d5005e65 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 29 Aug 2026 18:24:58 +0100 Subject: [PATCH 14/15] Pin the TypeScript schema generator toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator wrote a package.json into the build container via heredoc and installed four caret ranges at runtime, so the tools that produce the models resolved fresh on every run. That drift had already happened: crd-generate ^6.1.0 resolved to 6.1.1 and typescript ^5.0.0 to 5.9.3, so generated models depended on when they were generated rather than on the CLI version. The schema lock cannot see this, since it records a source's version and nothing about the toolchain. Move the manifest out of the heredoc into a committed typescript-toolchain/package.json with exact versions, add the generated package-lock.json beside it, embed both, stage them into the working filesystem, and install with npm ci. That pins the 97 packages in the tree rather than only the four direct dependencies, and npm ci fails loudly if the manifest and lock ever disagree. Pin the container image to node:22.22.1-slim as well, since a locked npm tree running under a floating Node is only half pinned. The manifest written to models/package.json for consumers keeps its caret ranges. That one is a published library's dependency spec, and pinning it would defeat npm deduping and give a function two copies of kubernetes-models. Add two Renovate rules: one grouping the toolchain manifest and lock so they move together, and a customManager for the generator image constants so the pins do not rot. The customManager is scoped to internal/schemas/generator on purpose — the function builders also declare image constants, but those are deliberately floating bases for user function images. Generation is now byte-for-byte reproducible across runs, and npm ci turns out to be faster than the previous npm install: 3.87s versus 7.42s on a single-XRD project with warm images. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- .github/renovate.json5 | 30 + .../typescript-toolchain/package-lock.json | 1249 +++++++++++++++++ .../typescript-toolchain/package.json | 29 + internal/schemas/generator/typescript.go | 75 +- 4 files changed, 1341 insertions(+), 42 deletions(-) create mode 100644 internal/schemas/generator/typescript-toolchain/package-lock.json create mode 100644 internal/schemas/generator/typescript-toolchain/package.json diff --git a/.github/renovate.json5 b/.github/renovate.json5 index e3ed608e..d5e60108 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,6 +45,22 @@ schedule: [], }, customManagers: [ + { + customType: 'regex', + description: 'Bump the container images the schema generators run in', + // Scoped to the generator package on purpose. The function builders in + // internal/project/functions also declare image constants, but those are + // deliberately floating bases for user function images (for example + // distroless nodejs24-debian13, whose tag is not a version), so bumping + // them automatically would be wrong. + managerFilePatterns: [ + '/^internal/schemas/generator/.*\\.go$/', + ], + matchStrings: [ + 'Image\\s*=\\s*"(?[^":]+):(?[^"]+)"', + ], + datasourceTemplate: 'docker', + }, { customType: 'regex', description: 'Bump the Renovate version used by the config validator and the bot', @@ -148,6 +164,20 @@ ], enabled: false, }, + { + // The TypeScript schema generator installs this tree with npm ci inside a + // container, so package.json and package-lock.json have to move together + // or the install fails. Grouping keeps them in one reviewable PR, and + // each bump changes generated model output, so these are worth reading. + description: 'Group updates to the pinned TypeScript schema generator toolchain', + matchManagers: [ + 'npm', + ], + matchFileNames: [ + 'internal/schemas/generator/typescript-toolchain/package.json', + ], + groupName: 'typescript schema generator toolchain', + }, { description: 'Group all go version updates', matchDatasources: [ diff --git a/internal/schemas/generator/typescript-toolchain/package-lock.json b/internal/schemas/generator/typescript-toolchain/package-lock.json new file mode 100644 index 00000000..cdd83a56 --- /dev/null +++ b/internal/schemas/generator/typescript-toolchain/package-lock.json @@ -0,0 +1,1249 @@ +{ + "name": "crossplane-models", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crossplane-models", + "version": "0.0.0", + "dependencies": { + "@kubernetes-models/apimachinery": "3.0.2", + "@kubernetes-models/base": "6.0.1" + }, + "devDependencies": { + "@kubernetes-models/crd-generate": "6.1.1", + "typescript": "5.9.3" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@kubernetes-models/apimachinery": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@kubernetes-models/apimachinery/-/apimachinery-3.0.2.tgz", + "integrity": "sha512-6Vbzr/tinxPBGGdfYjXiV9WPj8PEhyiqGJiWhXrsihK7h6glPj4HKUxZ0YwYXH/5uucm/NVef/xY3vZ4Crj1Fg==", + "license": "MIT", + "dependencies": { + "@kubernetes-models/base": "^6.0.0", + "@kubernetes-models/validate": "^5.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/base": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/base/-/base-6.0.1.tgz", + "integrity": "sha512-pXKCFeSoL6RsMxoPDwaJ5JtijVjeuNCacgtYS9dImF7qFdzdc9lcQbem6y6Ug7/K6/bFOWdTu3qIX88IK81o0A==", + "license": "MIT", + "dependencies": { + "@kubernetes-models/validate": "^5.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/crd-generate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/crd-generate/-/crd-generate-6.1.1.tgz", + "integrity": "sha512-0Jc+x7GjWNZGcLk+Pdp8aos/qyI/7WGTeLZClkuNbELiGHYFZkFMPLr4IYwB9tz4ujGxa2ARwXJLUkayko4nJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kubernetes-models/generate": "^3.1.1", + "@kubernetes-models/read-input": "^4.0.1", + "@kubernetes-models/string-util": "^4.0.1", + "es-toolkit": "^1.46.0", + "yaml": "^2.2.2", + "yargs": "^18.0.0" + }, + "bin": { + "crd-generate": "bin/crd-generate.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/generate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@kubernetes-models/generate/-/generate-3.2.0.tgz", + "integrity": "sha512-CMF0h0N/5VJ7zpkuiFa7+Hi94krFVf4idC+d1FkynUT/4p8X5JbDvEC+GzzbYhOg3gBCTU6NdDH+kEoWhCVfng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kubernetes-models/string-util": "^4.0.1", + "@kubernetes-models/validate": "^5.0.2", + "ajv": "^8.12.0", + "es-toolkit": "^1.46.0", + "indent-string": "^5.0.0", + "ohash": "^2.0.11", + "p-map": "^7.0.4", + "re2-wasm": "^1.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/read-input": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/read-input/-/read-input-4.0.1.tgz", + "integrity": "sha512-Q92FPRmM6YSnLMXS+UGfbJ/j2qMJzx3cjNttNkqz3q7M4CudtepXmgB5ZS54OPCHY8PJ1lOa8OZ+PQhiqn2SMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-directory": "^6.0.0", + "get-stdin": "^10.0.0", + "make-fetch-happen": "^15.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/string-util": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@kubernetes-models/string-util/-/string-util-4.0.1.tgz", + "integrity": "sha512-raOnQucFvVfilR35Ffw5atUwIIJ8DNKZ7U/maSfUB9kOlxTN5sb5NOhUB8198wHLTJASPnpNJmoxvq7gms6/WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@kubernetes-models/validate": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@kubernetes-models/validate/-/validate-5.0.2.tgz", + "integrity": "sha512-EzfxB8mu2VPlFYSwsMlZdgTeeqJGI2R58irUrZJWHxJqOqOhnau/oS9KNCZKXjdC2vFubU6v81m2CrIoU+/pxQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-formats-draft2019": "^1.6.1", + "ajv-i18n": "^4.2.0", + "is-cidr": "^6.0.4" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "re2-wasm": "^1.0.2" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats-draft2019": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", + "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1", + "schemes": "^1.4.0", + "smtp-address-parser": "^1.0.3", + "uri-js": "^4.4.1" + }, + "peerDependencies": { + "ajv": "*" + } + }, + "node_modules/ajv-i18n": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", + "integrity": "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.0-beta.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cidr-regex": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.5.tgz", + "integrity": "sha512-59tdLZcC+BJXa4C5rOmVSuJTy/UneqfJJtCraqwdx5BDHTkGrBtKCUl3u2uiCFvXu+wk0kVuX8axX7yHCZOI9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-cache-directory": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-cache-directory/-/find-cache-directory-6.0.0.tgz", + "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stdin": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-10.0.0.tgz", + "integrity": "sha512-eWSePJ4zXFdqz+/Lyfopob4rIcoF/U2XfE8nJc7iZV6lnebWc9k7DoQQpX+2a9jc0AOvBsXvbe5YkjXl/MHbpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-cidr": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.4.tgz", + "integrity": "sha512-tOIBU3QiXy0W4LvHbcKWAWSuQfGwDiEILphFCAZtDqj7C57uv3ClO6K8aNEGV4VTA7bWJlpQ0suKQkUe6Rd6ag==", + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/re2-wasm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/re2-wasm/-/re2-wasm-1.0.2.tgz", + "integrity": "sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/schemes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/schemes/-/schemes-1.4.0.tgz", + "integrity": "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smtp-address-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", + "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", + "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/internal/schemas/generator/typescript-toolchain/package.json b/internal/schemas/generator/typescript-toolchain/package.json new file mode 100644 index 00000000..bd0c338f --- /dev/null +++ b/internal/schemas/generator/typescript-toolchain/package.json @@ -0,0 +1,29 @@ +{ + "name": "crossplane-models", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./*": { + "types": "./*/index.d.ts", + "default": "./*/index.js" + } + }, + "dependencies": { + "@kubernetes-models/apimachinery": "3.0.2", + "@kubernetes-models/base": "6.0.1" + }, + "devDependencies": { + "@kubernetes-models/crd-generate": "6.1.1", + "typescript": "5.9.3" + }, + "crd-generate": { + "input": ["./all-crds.yaml"], + "output": "./gen" + } +} diff --git a/internal/schemas/generator/typescript.go b/internal/schemas/generator/typescript.go index 2d5331ea..86d07070 100644 --- a/internal/schemas/generator/typescript.go +++ b/internal/schemas/generator/typescript.go @@ -36,15 +36,29 @@ import ( devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/crd" "github.com/crossplane/cli/v2/internal/schemas/runner" + + _ "embed" ) const ( typescriptModelsFolder = "models" - // typescriptImage is the Docker image used to run crd-generate. - // We use a Node.js image and install the tool at runtime. - typescriptImage = "docker.io/library/node:22-slim" + // typescriptImage is the Docker image used to run crd-generate. Pinned to + // an exact tag: the toolchain is installed from a lockfile, so a floating + // Node would leave generated output dependent on when it was generated. + typescriptImage = "docker.io/library/node:22.22.1-slim" ) +// The toolchain that turns CRDs into TypeScript models is pinned by a +// committed package.json and package-lock.json rather than resolved at +// generation time, so the same CLI produces the same models. Renovate keeps +// the pair current; see the typescript-toolchain rule in renovate.json5. +// +//go:embed typescript-toolchain/package.json +var typescriptToolchainPackageJSON []byte + +//go:embed typescript-toolchain/package-lock.json +var typescriptToolchainPackageLock []byte + type typescriptGenerator struct{} func (typescriptGenerator) Language() string { @@ -250,12 +264,20 @@ func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS af return nil, errors.Wrap(err, "failed to write combined CRD file") } + // Stage the pinned toolchain manifest and lockfile so the container can + // install with npm ci rather than resolving version ranges at runtime. + if err := afero.WriteFile(workFS, "package.json", typescriptToolchainPackageJSON, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write toolchain package.json") + } + if err := afero.WriteFile(workFS, "package-lock.json", typescriptToolchainPackageLock, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write toolchain package-lock.json") + } + // Run crd-generate in a container. // The script: - // 1. Creates package.json with crd-generate config - // 2. Installs crd-generate and dependencies - // 3. Runs crd-generate to produce TypeScript source - // 4. Compiles TypeScript to JavaScript + // 1. Installs the pinned toolchain from the staged lockfile + // 2. Runs crd-generate to produce TypeScript source + // 3. Compiles TypeScript to JavaScript if err := r.Generate( ctx, workFS, @@ -266,41 +288,10 @@ func (t typescriptGenerator) generateFromCRDFiles(ctx context.Context, workFS af "sh", "-c", `set -eu -# Create package.json with crd-generate config and dependencies -cat > package.json << 'PKGEOF' -{ - "name": "crossplane-models", - "version": "0.0.0", - "type": "module", - "main": "index.js", - "types": "index.d.ts", - "exports": { - ".": { - "types": "./index.d.ts", - "default": "./index.js" - }, - "./*": { - "types": "./*/index.d.ts", - "default": "./*/index.js" - } - }, - "dependencies": { - "@kubernetes-models/apimachinery": "^3.0.2", - "@kubernetes-models/base": "^6.0.1" - }, - "devDependencies": { - "@kubernetes-models/crd-generate": "^6.1.0", - "typescript": "^5.0.0" - }, - "crd-generate": { - "input": ["./all-crds.yaml"], - "output": "./gen" - } -} -PKGEOF - -# Install dependencies (including crd-generate) -npm install +# Install the pinned toolchain. package.json and package-lock.json are staged +# by the generator, so npm ci installs exactly the locked tree and fails if the +# two ever disagree. +npm ci --no-audit --no-fund # Run crd-generate (reads config from package.json) npx crd-generate From 485891528a7985f264ec479e41306d82c0504a10 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Sat, 29 Aug 2026 22:09:49 +0100 Subject: [PATCH 15/15] Update the TypeScript testing guide for the SDK 0.7.0 API The guide taught the API the template no longer generates: a class implementing FunctionHandler, to(req), the getDesiredComposedResources/setDesiredComposedResources round trip, and Resource.fromJSON({ resource: vpc.toJSON() }) for conversion. Its sample package.json still listed commander and pino, which the template dropped when serve() took over the entrypoint. Worse, the note on fromModel actively recommended against it: it said Resource.fromJSON "works on every version, which is why the example above uses it". That was true when the template pinned SDK ^0.5.0. Someone following the guide against a function generated today would write code that does not match their own src/function.ts. Rewrite the example as a ComposeFunction writing to rsp.desired.resources via fromModel, show the generated main.ts that hands it to serve(), and bump the sample package.json to ^0.7.0 without commander or pino. Keep the older-SDK note, inverted: it now says what to do when working in an existing project pinned below 0.6.0, rather than steering everyone away from fromModel. Verified by extracting the example into a generated function with real crossplane-models and running tsc --noEmit, so the code in the guide compiles rather than merely reading correctly. Signed-off-by: Steven Borrelli Co-Authored-By: Claude Opus 5 --- docs/typescript-testing-guide.md | 149 +++++++++++++++---------------- 1 file changed, 73 insertions(+), 76 deletions(-) diff --git a/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md index f56715ca..f0528bed 100644 --- a/docs/typescript-testing-guide.md +++ b/docs/typescript-testing-guide.md @@ -223,17 +223,11 @@ Edit the `function.ts` to create a VPC: ```typescript import { - type RunFunctionRequest, - type RunFunctionResponse, - type FunctionHandler, - type Logger, - to, - normal, + type ComposeFunction, fatal, + fromModel, getObservedCompositeResource, - getDesiredComposedResources, - setDesiredComposedResources, - Resource, + normal, } from '@crossplane-org/function-sdk-typescript'; // Import the generated types from crossplane-models. This is the cluster-scoped @@ -242,79 +236,84 @@ import { import { VPC } from 'crossplane-models/ec2.aws.upbound.io/v1beta1'; /** - * Function is a Crossplane composition function that creates a VPC. + * compose is a Crossplane composition function that creates a VPC. + * + * serve() hands us a response already built from the request, so there is no + * to(req) here, and rsp.desired is guaranteed to be present. */ -export class Function implements FunctionHandler { - async RunFunction(req: RunFunctionRequest, logger?: Logger): Promise { - let rsp = to(req); - - // Get the observed composite resource (XR). - const observedComposite = getObservedCompositeResource(req); - if (!observedComposite) { - fatal(rsp, 'No composite resource found'); - return rsp; - } - logger?.debug({ observedComposite }, 'Observed composite resource'); - - // Extract spec values from the XR - const spec = observedComposite.resource?.spec as { region?: string; cidrBlock?: string }; - const region = spec?.region || 'us-west-2'; - const cidrBlock = spec?.cidrBlock || '10.0.0.0/16'; - const xrName = observedComposite.resource?.metadata?.name || 'unknown'; - - // Get the desired composed resources from previous functions in the pipeline. - const desiredComposed = getDesiredComposedResources(req); - - // Create a VPC using the generated TypeScript class. - // - // Do NOT set crossplane.io/external-name here. For a VPC the external name - // is the AWS-assigned ID, which the provider writes back after creation. - // Setting it yourself makes the provider look for a VPC by that name - // forever, so the resource stays Ready=False/Creating even though the VPC - // exists in AWS. Only set it when you genuinely control the external - // identifier, and use tags for human-readable names. - const vpc = new VPC({ - metadata: { - name: `${xrName}-vpc`, - }, - spec: { - forProvider: { - region: region, - cidrBlock: cidrBlock, - enableDnsHostnames: true, - enableDnsSupport: true, - tags: { - Name: `${xrName}-vpc`, - 'managed-by': 'crossplane', - }, +export const compose: ComposeFunction = async (req, rsp, logger) => { + // Get the observed composite resource (XR). + const observedComposite = getObservedCompositeResource(req); + if (!observedComposite) { + fatal(rsp, 'No composite resource found'); + return rsp; + } + logger?.debug({ observedComposite }, 'Observed composite resource'); + + // Extract spec values from the XR + const spec = observedComposite.resource?.spec as { region?: string; cidrBlock?: string }; + const region = spec?.region || 'us-west-2'; + const cidrBlock = spec?.cidrBlock || '10.0.0.0/16'; + const xrName = observedComposite.resource?.metadata?.name || 'unknown'; + + // Create a VPC using the generated TypeScript class. + // + // Do NOT set crossplane.io/external-name here. For a VPC the external name + // is the AWS-assigned ID, which the provider writes back after creation. + // Setting it yourself makes the provider look for a VPC by that name + // forever, so the resource stays Ready=False/Creating even though the VPC + // exists in AWS. Only set it when you genuinely control the external + // identifier, and use tags for human-readable names. + const vpc = new VPC({ + metadata: { + name: `${xrName}-vpc`, + }, + spec: { + forProvider: { + region: region, + cidrBlock: cidrBlock, + enableDnsHostnames: true, + enableDnsSupport: true, + tags: { + Name: `${xrName}-vpc`, + 'managed-by': 'crossplane', }, }, - }); + }, + }); + + // Validate the model against the CRD schema before composing it. + vpc.validate(); - // Validate the model against the CRD schema before composing it. - vpc.validate(); + // Write the VPC straight onto the response. ComposeResponse narrows desired + // to non-optional, so there is no need for rsp.desired!. The map holds + // protobuf Resource values rather than kubernetes-models objects, so convert + // with fromModel — assigning the model directly fails to compile with TS2739. + rsp.desired.resources['vpc'] = fromModel(vpc); - // Add the VPC to desired resources. The map holds protobuf Resource values, - // not kubernetes-models objects, so convert first — assigning - // `{ resource: vpc }` fails to compile with TS2739. - desiredComposed['vpc'] = Resource.fromJSON({ resource: vpc.toJSON() }); + normal(rsp, 'Successfully composed VPC resource'); + return rsp; +}; +``` - // Update the response with the desired composed resources. - rsp = setDesiredComposedResources(rsp, desiredComposed); +The generated `src/main.ts` hands this to the SDK and needs no edits: - normal(rsp, 'Successfully composed VPC resource'); - return rsp; - } -} +```typescript +serve(compose, { name: 'network' }); ``` -**On `fromModel`**: the SDK also offers `fromModel(vpc)` as a shorthand for the conversion -above, and from `@crossplane-org/function-sdk-typescript@0.6.0` it accepts the generated models. -Earlier versions required `toJSON(): Record` while `@kubernetes-models/base` -declares `toJSON(): unknown` from v6 onward, so the call failed with TS2345 +`serve` parses the standard function flags, builds a logger from `--debug`, starts the gRPC +server, and shuts down cleanly on `SIGINT` and `SIGTERM`. + +**On older SDK versions**: `fromModel` accepts the generated models from +`@crossplane-org/function-sdk-typescript@0.6.0` onward. Earlier versions required +`toJSON(): Record` while `@kubernetes-models/base` declares `toJSON(): unknown` +from v6 onward, so the call failed with TS2345 ([function-sdk-typescript#26](https://github.com/crossplane/function-sdk-typescript/pull/26)). -`Resource.fromJSON({ resource: vpc.toJSON() })` works on every version, which is why the example -above uses it. +On an SDK older than 0.6.0 the equivalent is +`Resource.fromJSON({ resource: vpc.toJSON() })`, and the function is written as a class +implementing `FunctionHandler` rather than a `ComposeFunction`. The template generates 0.7.0, +so this only matters when working in an existing project pinned to an older SDK. The generated `package.json` already includes the `crossplane-models` dependency when TypeScript schemas are enabled. It will look like: @@ -336,12 +335,10 @@ The generated `package.json` already includes the `crossplane-models` dependency "local": "node dist/main.js --insecure --debug" }, "dependencies": { - "@crossplane-org/function-sdk-typescript": "^0.6.0", + "@crossplane-org/function-sdk-typescript": "^0.7.0", "@types/node": "^26.0.0", - "commander": "^15.0.0", "crossplane-models": "file:../../schemas/typescript", - "kubernetes-models": "^5.0.0", - "pino": "^10.3.0" + "kubernetes-models": "^5.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1",