From d4bc1083ffdae00a9c7bce0da5958dcd399b5f9e Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 10:45:22 -0500 Subject: [PATCH 1/6] add typescript support Signed-off-by: Steven Borrelli --- .github/renovate.json5 | 15 + 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 +++++++ 16 files changed, 1186 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/.github/renovate.json5 b/.github/renovate.json5 index c4dc835d..b2a86d6f 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -8,6 +8,21 @@ // We only want renovate to rebase PRs when they have conflicts, default // "auto" mode is not required. rebaseWhen: 'conflicted', + // Custom managers for non-standard file formats + customManagers: [ + { + // Manage npm dependencies in TypeScript function template + customType: 'regex', + fileMatch: [ + 'cmd/crossplane/function/templates/typescript/package\\.json\\.tmpl$', + ], + matchStrings: [ + '"(?@?[^"]+)":\\s*"\\^(?[^"]+)"', + ], + datasourceTemplate: 'npm', + versioningTemplate: 'npm', + }, + ], // The maximum number of PRs to be created in parallel prConcurrentLimit: 5, // The branches renovate should target 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 52c57734..c006607f 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -58,6 +58,8 @@ var ( pythonTemplates embed.FS //go:embed templates/go-templating/* goTemplatingTemplates embed.FS + //go:embed all:templates/typescript + typescriptTemplates embed.FS // The go template contains a go.mod, so we can't embed it as an // embed.FS. Instead we have to embed it as a tar archive and extract it @@ -69,7 +71,7 @@ var ( type generateCmd struct { Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."` PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""` - Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"` + Language string `default:"go-templating" enum:"go,go-templating,kcl,python,typescript" help:"Language to use for the function." short:"l"` ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` projFS afero.Fs @@ -173,6 +175,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error { "go-templating": c.generateGoTemplatingFiles, "kcl": c.generateKCLFiles, "python": c.generatePythonFiles, + "typescript": c.generateTypescriptFiles, } generator, ok := generators[c.Language] @@ -405,6 +408,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 79c946be..922a7b2b 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -315,6 +315,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 d41300b9..59817619 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -43,6 +43,7 @@ func AllLanguages() []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 fd35fe29..fdc53e15 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" @@ -240,6 +241,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 d4255854fdd9c4c77b4c1b3590284d3ab9ba313e Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 11:34:54 -0500 Subject: [PATCH 2/6] 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 c006607f..8b1d78ab 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -437,7 +437,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 } @@ -446,7 +449,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 922a7b2b..b3d0e5fe 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -319,12 +319,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 { @@ -337,9 +337,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 }) @@ -349,15 +347,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 @@ -378,7 +384,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 fdc53e15..ce07dff2 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -319,6 +319,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 { @@ -338,7 +339,9 @@ func (m *Manager) generateFromMergedSources(ctx context.Context, sources []Sourc } if schemaFS != nil { + schemasMu.Lock() schemas[gen.Language()] = schemaFS + schemasMu.Unlock() } return nil From cc2f9c01c576db336cff2bc4ee6ccba28367eab1 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 12:20:48 -0500 Subject: [PATCH 3/6] 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 d623d713e3f674e7f0fb2fa43b211b4757d67988 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 19:19:33 -0500 Subject: [PATCH 4/6] 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 23776f59..dc48119e 100644 --- a/internal/project/controlplane/controlplane.go +++ b/internal/project/controlplane/controlplane.go @@ -114,7 +114,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 cd39b5a108db3033c97c92b2985a09d20a330600 Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 20:31:28 -0500 Subject: [PATCH 5/6] 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 | 23 +++++++-- internal/schemas/generator/typescript.go | 59 +++++------------------- internal/schemas/manager/manager.go | 14 +++--- 10 files changed, 67 insertions(+), 85 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 8b1d78ab..63ca0230 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -414,7 +414,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 b3d0e5fe..4acaf587 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -324,7 +324,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 { @@ -362,7 +361,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) } @@ -392,18 +391,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 59817619..75a7a98f 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -34,9 +34,22 @@ type Interface interface { GenerateFromOpenAPI(ctx context.Context, fs afero.Fs, runner runner.SchemaRunner) (afero.Fs, error) } -// AllLanguages returns generators for all supported languages. The set of -// supported language identifiers is defined by -// devv1alpha1.SupportedSchemaLanguages. +// DefaultLanguages returns generators for the default set of languages. +// TypeScript is excluded by default 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() []Interface { + return []Interface{ + &goGenerator{}, + &jsonGenerator{}, + &kclGenerator{}, + &pythonGenerator{}, + } +} + +// AllLanguages returns generators for all supported languages, including +// those that are not enabled by default. The set of supported language +// identifiers is defined by devv1alpha1.SupportedSchemaLanguages. func AllLanguages() []Interface { return []Interface{ &goGenerator{}, @@ -49,10 +62,10 @@ func AllLanguages() []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 ce07dff2..a3024073 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" @@ -259,6 +260,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()) } } @@ -285,7 +288,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()) @@ -296,10 +299,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 { @@ -308,7 +310,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 593d04b5e2ed7616dda097ad56dc8d9783f8560e Mon Sep 17 00:00:00 2001 From: Steven Borrelli Date: Mon, 29 Jun 2026 20:57:29 -0500 Subject: [PATCH 6/6] 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 adf864f6..7ff9387f 100644 --- a/internal/schemas/generator/interface_test.go +++ b/internal/schemas/generator/interface_test.go @@ -49,8 +49,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},