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/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index c696e97a..2971728d 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. + // 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/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 616917a8..a9159149 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 @@ -59,6 +65,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 +78,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 @@ -138,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 @@ -176,10 +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, + "go": c.generateGoFiles, + langGoTemplating: c.generateGoTemplatingFiles, + "kcl": c.generateKCLFiles, + langPython: c.generatePythonFiles, + "typescript": c.generateTypescriptFiles, } generator, ok := generators[c.Language] @@ -420,6 +429,59 @@ func (c *generateCmd) generateGoTemplatingFiles(fs afero.Fs) error { return renderTemplates(fs, tmpls, tmplData) } +type typescriptTemplateData struct { + Name string + HasSchemas bool + SchemasPath string +} + +func (c *generateCmd) generateTypescriptFiles(targetFS afero.Fs) error { + hasSchemas, err := afero.DirExists(c.schemasFS, "typescript") + if err != nil { + return errors.Wrap(err, "cannot inspect typescript schemas directory") + } + if hasSchemas { + entries, err := afero.ReadDir(c.schemasFS, "typescript") + if err != nil { + return errors.Wrap(err, "cannot read typescript schemas directory") + } + hasSchemas = len(entries) > 0 + } + + // Compute the relative path from the function dir to schemas/typescript/. + fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name) + relRoot, err := filepath.Rel(fnDir, "/") + if err != nil { + return errors.Wrap(err, "cannot determine path to schemas directory") + } + schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "typescript")) + + data := typescriptTemplateData{ + Name: c.Name, + HasSchemas: hasSchemas, + SchemasPath: schemasPath, + } + + // Parse top-level templates + tmpls, err := template.ParseFS(typescriptTemplates, "templates/typescript/*.*") + if err != nil { + return errors.Wrap(err, "cannot parse top-level TypeScript templates") + } + if err := renderTemplates(targetFS, tmpls, data); err != nil { + return err + } + + // Create src directory and parse src templates + if err := targetFS.Mkdir("src", 0o755); err != nil { + return errors.Wrap(err, "cannot create src directory") + } + tmpls, err = template.ParseFS(typescriptTemplates, "templates/typescript/src/*.*") + if err != nil { + return errors.Wrap(err, "cannot parse TypeScript source templates") + } + return renderTemplates(afero.NewBasePathFs(targetFS, "src"), tmpls, data) +} + func renderTemplates(targetFS afero.Fs, tmpls *template.Template, data any) error { for _, tmpl := range tmpls.Templates() { fname := tmpl.Name() diff --git a/cmd/crossplane/function/help/generate.md b/cmd/crossplane/function/help/generate.md index 2925b6c5..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 @@ -11,6 +12,7 @@ The following are valid arguments to the `--language` / `-l` flag: - `go` - `kcl` - `python` +- `typescript` ## Examples 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 new file mode 100644 index 00000000..74156ae9 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/README.md @@ -0,0 +1,113 @@ +# Crossplane Composition Function + +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: + +```shell +npm install +``` + +Build the function: + +```shell +npm run build +``` + +Run locally (for testing): + +```shell +npm run local +``` + +## Testing + +Unit tests run with [Vitest](https://vitest.dev), alongside the code in `src/`: + +```shell +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 +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/) +- [TypeScript Function SDK](https://github.com/crossplane/function-sdk-typescript) 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 new file mode 100644 index 00000000..98744c05 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/package.json.tmpl @@ -0,0 +1,33 @@ +{ + "name": "function", + "version": "0.1.0", + "description": "A Crossplane composition function.", + "license": "Apache-2.0", + "type": "module", + "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.7.0", + "@types/node": "^26.0.0", +{{- if .HasSchemas }} + "crossplane-models": "file:{{ .SchemasPath }}", +{{- end }} + "kubernetes-models": "^5.0.0" + }, + "devDependencies": { + "@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..42b6e972 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { fromCompose, RunFunctionRequest } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; + +describe('compose', () => { + const func = fromCompose(compose); + + 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 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 new file mode 100644 index 00000000..bd0a17e2 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/function.ts @@ -0,0 +1,45 @@ +import { + type ComposeFunction, + fatal, + getObservedCompositeResource, + normal, +} from '@crossplane-org/function-sdk-typescript'; + +/** + * 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 const compose: ComposeFunction = async (req, rsp, logger) => { + try { + // Get the observed composite resource (XR). + const observedComposite = getObservedCompositeResource(req); + logger?.debug({ observedComposite }, 'Observed composite resource'); + + // TODO: Add your function logic here. + // + // 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(); + // 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 new file mode 100644 index 00000000..78bb5b34 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/src/main.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { serve } from '@crossplane-org/function-sdk-typescript'; +import { compose } from './function.js'; + +serve(compose, { name: '{{ .Name }}' }); 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 new file mode 100644 index 00000000..0c845587 --- /dev/null +++ b/cmd/crossplane/function/templates/typescript/tsconfig.json @@ -0,0 +1,21 @@ +{ + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "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/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/docs/typescript-testing-guide.md b/docs/typescript-testing-guide.md new file mode 100644 index 00000000..768d2966 --- /dev/null +++ b/docs/typescript-testing-guide.md @@ -0,0 +1,848 @@ +# 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 Crossplane project is located at . + +## Prerequisites + +- Go 1.25+ +- The Github CLI +- Docker, or an engine that supports `DOCKER_HOST` +- Node.js 24+ (for local development) +- A Kubernetes cluster with Crossplane installed +- Access to push packages to a registry (e.g., `xpkg.upbound.io`) +- (optional) AWS Credentials + +## 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 +``` + +All subsequent commands should use this locally-compiled version of crossplane. + +## 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 +``` + +**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: + +```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: + 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: + +- The package is resolved and cached locally, under `--cache-dir` + (`$CROSSPLANE_XPKG_CACHE`, defaulting to a per-user directory) +- The CLI generates schemas from any CRDs the package contains +- The dependency is recorded in `crossplane-project.yaml` + +No cluster is involved. `dependency add` works before any control plane exists; the dependency is +installed on a control plane later, by `project run` or by applying the built Configuration. + +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: Network +metadata: + name: example-network + namespace: network-team +spec: + region: us-west-2 + cidrBlock: "10.0.0.0/16" +EOF +``` + +Then generate the XRD from the example. This will be our Platform API: + +```bash +# Generate an XRD from the example XR +crossplane xrd generate examples/network/example.yaml +``` + +This writes `apis/networks/definition.yaml` — note the plural directory name. The CLI emits an +`apiextensions.crossplane.io/v2` XRD with no `claimNames`; claims are a v1 concept, and in v2 you +use the XR directly. + +**The scope is inferred from the example.** Because the XR above carries +`metadata.namespace`, the generated XRD gets `scope: Namespaced`. Drop the namespace from the +example and you get `scope: Cluster` instead. This walkthrough is namespaced throughout, which is +the usual choice for a platform API a team consumes inside its own namespace, and it is what +[configuration-aws-network-ts](https://github.com/upbound/configuration-aws-network-ts) does. + +Edit `apis/networks/definition.yaml` to add descriptions, defaults and status fields: + +```yaml +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: networks.aws.platform.upbound.io +spec: + group: aws.platform.upbound.io + names: + categories: + - crossplane + kind: Network + plural: networks + scope: Namespaced + 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 +``` + +### Scope determines which types you import + +The XRD's scope decides which generated types your function must use, and getting it wrong fails +only at apply time. + +- `scope: Namespaced` — what this guide uses — composes **namespaced** managed resources. Import + from the mirrored `.m.` group: `crossplane-models/ec2.aws.m.upbound.io/v1beta1`. +- `scope: Cluster` composes **cluster-scoped** managed resources. Import from + `crossplane-models/ec2.aws.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. + +A namespaced XR also means the composed resources belong in the XR's namespace. Crossplane does +not infer that for you — the function has to set it, which Step 7 does. + +## 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/networks/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 +- `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 + +The generated `functions/network/src/function.ts` contains a template implementation. A full example is available at [function.ts](https://github.com/upbound/configuration-aws-network-ts/blob/main/functions/network/src/function.ts). + +Edit the `function.ts` to create a VPC: + +```typescript +import { + type ComposeFunction, + fatal, + fromModel, + getObservedCompositeResource, + normal, +} from '@crossplane-org/function-sdk-typescript'; + +// Import the generated types from crossplane-models. This is the mirrored `.m.` +// group, matching the `scope: Namespaced` XRD from Step 5. For a cluster-scoped +// XRD, import from 'crossplane-models/ec2.aws.upbound.io/v1beta1' instead. +import { VPC } from 'crossplane-models/ec2.aws.m.upbound.io/v1beta1'; + +/** + * 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 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'; + + // A namespaced XR composes namespaced managed resources, and Crossplane does + // not place them for you — carry the XR's namespace onto everything composed. + const namespace = observedComposite.resource?.metadata?.namespace; + + // 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`, + ...(namespace && { namespace: namespace }), + }, + 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(); + + // 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); + + normal(rsp, 'Successfully composed VPC resource'); + return rsp; +}; +``` + +The generated `src/main.ts` hands this to the SDK and needs no edits: + +```typescript +serve(compose, { name: 'network' }); +``` + +`serve` parses the standard function flags, builds a logger from `--debug`, starts the gRPC +server, and shuts down cleanly on `SIGINT` and `SIGTERM`. + +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": "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.7.0", + "@types/node": "^26.0.0", + "crossplane-models": "file:../../schemas/typescript", + "kubernetes-models": "^5.0.0" + }, + "devDependencies": { + "@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" + } +} +``` + +## 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 +``` + +The models are generated as TypeScript, then compiled — so `schemas/typescript/` holds JavaScript +plus declarations, not `.ts` sources: + +- `ec2.aws.m.upbound.io/v1beta1/VPC.js` and `VPC.d.ts` - namespaced VPC class with full type + definitions. The cluster-scoped `ec2.aws.upbound.io/` tree is generated alongside it; a + namespaced XRD uses the `.m.` one. +- `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 to the composition will add time +to the generation step. + +## 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 + +# 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.m.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 + +A Composition contains a pipeline of functions that are executed +in sequence to create resources. + +```bash +# Generate a composition from the XRD +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 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/networks/composition.yaml` to add your function before `function-auto-ready`: + +```yaml +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: networks.aws.platform.upbound.io +spec: + compositeTypeRef: + apiVersion: aws.platform.upbound.io/v1alpha1 + kind: Network + mode: Pipeline + pipeline: + - step: network + functionRef: + # Ensure this name matches your org + name: your-org-configuration-aws-network-tsnetwork + - step: crossplane-contrib-function-auto-ready + functionRef: + name: crossplane-contrib-function-auto-ready +``` + +**Tip**: `function generate` can create the function *and* wire it into the pipeline in one go, +which saves the hand-edit above: + +```bash +crossplane function generate network apis/networks/composition.yaml --language typescript +``` + +Use this **instead of Step 6**, not after it. `function generate` refuses to write into a function +directory that already exists, so having followed Step 6 this fails with `function directory +"network" already exists and is not empty`, and the hand-edit above is the way in. + +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 v2 supports [`ManagedResourceActivationPolicy`](https://docs.crossplane.io/latest/managed-resources/managed-resource-activation-policies/), a way to limit the number +of CRDs providers install onto a cluster + +By default, the Crossplane Helm chart installs wildcard policy the value of `provider.defaultActivations` `["*"]`, which causes every +CRD available in a Provider to be installed, which can have +significant performance impacts on the Kubernetes API server. + +On dev control plane created by `crossplane project run`, we can control this behavior by disabling the default policy and only +installing CRDs that are related to our Composition. A Crossplane +cluster can support multiple `ManagedResourceActivationPolicy`, so +it's good practice for each Composition to define a policy. + +In summary: + +- Pass `crossplane project run --no-default-mrap`, which suppresses the wildcard policy. +- Add a `ManagedResourceActivationPolicy` manifest that only activates the CRDs you need. + +In our example, we need to support creation of a VPC. Save this file +as `apis/network/mrap.yaml` and it will automatically be applied to the Cluster when your project is installed: + +```yaml +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: ManagedResourceActivationPolicy +metadata: + name: configuration-aws-network-ts +spec: + activate: + - vpcs.ec2.aws.m.upbound.io + - subnets.ec2.aws.m.upbound.io +``` + +Without a CRD activated, no resources can be created on the Cluster. +`crossplane composition render` does not need the +policy, so this only shows up once you deploy to a cluster. + +Testing with `--no-default-mrap` is worth doing before you ship: it is the cheapest way to find +out that an activation policy is incomplete, and the failure is far easier to read locally than in +a production control plane. + +## 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 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/networks/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 + +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: + +```bash +# Include function results (informational messages) +crossplane composition render \ + examples/network/example.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/networks/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: + +```bash +# Start a local dev cluster and deploy the project +crossplane project run +``` + +Add `--no-default-mrap` to suppress the wildcard activation policy the Crossplane chart installs, +so the control plane behaves like production and the `ManagedResourceActivationPolicy` from Step 10 +is what activates your CRDs: + +```bash +crossplane project run --no-default-mrap +``` + +The flag only takes effect when the control plane is **created**. If a Control Plane already exists it keeps +whatever the `ManagedResourceActivationPolicy` was built with, so run `crossplane project stop` first. + +Verify it applied: + +```bash +kubectl -n crossplane-system get deploy crossplane \ + -o jsonpath='{.spec.template.spec.containers[0].args}' +``` + +With the flag, that shows no `--activation` argument. Without it you get `--activation "*"`. + +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: + +The XRD from Step 5 is namespaced, so the ProviderConfig belongs in the XR's namespace and comes +from the mirrored `.m.` group. The secret it points at can live elsewhere: + +```bash +kubectl create ns network-team + +# 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 network-team --from-file=creds=creds.conf + +# Create a ProviderConfig to use the credentials +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: + +```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/networks/composition.yaml --timeout=5m + +# Or for larger projects with many dependencies +crossplane composition render examples/network/example.yaml apis/networks/composition.yaml --timeout=10m +``` + +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 Projects + +For a complete working example built from scratch, see: + + +```bash +crossplane xpkg install configuration \ + xpkg.upbound.io/upbound/configuration-aws-network-ts:v0.3.0 +``` + +Note that this Configuration's CI builds the CLI from this PR's branch rather than installing a release, since +the feature has not shipped yet — so v0.3.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. + 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/dependency/manager.go b/internal/dependency/manager.go index a0ab9a09..b9eb1649 100644 --- a/internal/dependency/manager.go +++ b/internal/dependency/manager.go @@ -386,6 +386,106 @@ 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) { + eg, egCtx := errgroup.WithContext(ctx) + + sourcesByIndex := make([]smanager.Source, len(m.proj.Spec.Dependencies)) + + 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 { + sourcesByIndex[i] = src + } + return nil + }) + } + + if err := eg.Wait(); err != nil { + 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 || dep.Xpkg.Package == "" { + 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 + // 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.Errorf("dependency %q has no source configured; set exactly one of xpkg, git, http, or k8s", desc) + } +} + +// 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, "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, "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 package %q; check that it is a valid Crossplane package", 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/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/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/build.go b/internal/project/build.go index 49fe92a2..f22059c3 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -253,19 +253,22 @@ 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. - if b.dependencyManager != nil { - if err := b.dependencyManager.AddAll(ctx, o.eventCh); err != nil { - return nil, errors.Wrap(err, "failed to generate dependency schemas") + // 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. + 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, "cannot load schemas from project dependencies; check that each dependency is reachable and contains valid API definitions") + } + allSources = append(allSources, depSources...) } - } + allSources = append(allSources, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)) - // Generate language-specific schemas from XRDs. - 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/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go index 75225f4a..b9a0172d 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/functions/build.go b/internal/project/functions/build.go index 09c1751f..884f4b01 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -53,6 +53,8 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC newPythonBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), + // TypeScript is checked last since package.json can appear in other project types. + newTypescriptBuilder(imageConfigs), } for _, b := range builders { ok, err := b.match(fromFS) 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 new file mode 100644 index 00000000..4094cb38 --- /dev/null +++ b/internal/project/functions/typescript.go @@ -0,0 +1,341 @@ +/* +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" + "fmt" + "io" + "net/http" + "path" + "path/filepath" + "strings" + + "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: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. +// +// 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. 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 + 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 + } + hasTSConfig, err := afero.Exists(fromFS, "tsconfig.json") + if err != nil { + return false, err + } + return hasPackageJSON && hasTSConfig, 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, "cannot build the TypeScript function because Docker is unavailable; start or install Docker, then retry") + } + + functionTars, 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(functionTars[arch])), 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, arch) + 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 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. 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) (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. + 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, 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) + 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 + } + + // 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) + 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.StartWithEnv( + "ARCHS="+strings.Join(npmArchitectures, " "), + "SCHEMAS_PATH="+tsSchemasPath, + ), + docker.StartWithCommand([]string{"sh", "-c", typescriptBuildScript}), + 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() { + // Use context.Background() so container cleanup happens even if ctx is cancelled. + _ = docker.StopContainerByID(context.Background(), cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return nil, errors.Wrap(err, "typescript build container failed") + } + + 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. 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 = fmt.Sprintf("/fn_%s", npmArch) + 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/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/project/sort.go b/internal/project/sort.go index cd38d42a..b2a3163a 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 matching both repository and tag + if tag.Repository.String() == repo && tag.TagStr() == ConfigurationTag { cfgImage = image continue } 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 d26519d2..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 @@ -71,15 +82,29 @@ func AllLanguages(opts ...Option) []Interface { &jsonGenerator{}, &kclGenerator{}, &pythonGenerator{}, + &typescriptGenerator{}, + } +} + +// 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, 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 + langs = defaultLanguages() } out := make([]Interface, 0, len(all)) for _, g := range all { 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}, 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-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 new file mode 100644 index 00000000..86d07070 --- /dev/null +++ b/internal/schemas/generator/typescript.go @@ -0,0 +1,397 @@ +/* +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" + "crypto/sha256" + "encoding/hex" + "io/fs" + "path/filepath" + "strings" + + "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" + + _ "embed" +) + +const ( + typescriptModelsFolder = "models" + // 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 { + 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, 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 errors.Wrapf(err, "cannot read %q while collecting API definitions for TypeScript models", path) + } + + if info.IsDir() { + return nil + } + + // Only process YAML files + ext := filepath.Ext(path) + if ext != extYAML && ext != extYML { + 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: + n, err := t.processXRDFile(xrdFS, workFS, bs, path, xrdBaseFolder, crdsDir) + if err != nil { + return err + } + crdCount += n + + case "CustomResourceDefinition": + if err := t.processCRDFile(workFS, bs, path, crdsDir); err != nil { + return err + } + crdCount++ + } + + return nil + }) + + 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, "./") + 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 + } + 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 +// 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 != extYAML && ext != extYML { + 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") + } + + // 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. 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, + ".", + "", + typescriptImage, + []string{ + "sh", "-c", + `set -eu + +# 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 + +# 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": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "rootDir": "gen", + "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/ + +# 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' +{ + "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 install npm dependencies and generate TypeScript schemas; see npm output above for details") + } + + // 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..7e2f16fb 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -20,8 +20,10 @@ package manager import ( "context" "encoding/json" + "fmt" "io/fs" "path/filepath" + "strings" "sync" "github.com/invopop/jsonschema" @@ -246,6 +248,193 @@ 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) + 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()) + } + } + + // 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, 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 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 nil, nil, err + } + // 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 { + return nil, nil, 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 := fmt.Sprintf("%04d_%s", i, sanitizeSourceID(src.ID())) + prefixedFS := afero.NewBasePathFs(mergedFS, prefix) + if err := filesystem.CopyFilesBetweenFs(srcFS, prefixedFS); err != nil { + return nil, nil, errors.Wrapf(err, "failed to copy resources from source %s", src.ID()) + } + + sourceVersions[src.ID()] = version + } + + 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 { + 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 nil, err + } + + 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) + + // 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 + } + } + 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{