diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go index c696e97a..7cf16094 100644 --- a/apis/dev/v1alpha1/project_types.go +++ b/apis/dev/v1alpha1/project_types.go @@ -53,6 +53,7 @@ const ( SchemaLanguageJSON = "json" SchemaLanguageKCL = "kcl" SchemaLanguagePython = "python" + SchemaLanguageRust = "rust" ) // SupportedSchemaLanguages returns the set of language identifiers accepted @@ -63,6 +64,7 @@ func SupportedSchemaLanguages() []string { SchemaLanguageJSON, SchemaLanguageKCL, SchemaLanguagePython, + SchemaLanguageRust, } } @@ -133,7 +135,7 @@ 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 + // Supported values are "go", "json", "kcl", "python", and "rust". If not // specified, schemas are generated for all supported languages. Languages []string `json:"languages,omitempty"` } diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go index 3d1fc20a..c5c1b0ed 100644 --- a/cmd/crossplane/function/generate.go +++ b/cmd/crossplane/function/generate.go @@ -57,6 +57,9 @@ var ( kclTemplates embed.FS //go:embed all:templates/python pythonTemplates embed.FS + // The rust template contains a .gitignore, which embed skips without all. + //go:embed all:templates/rust + rustTemplates embed.FS //go:embed templates/go-templating/* goTemplatingTemplates embed.FS @@ -70,7 +73,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,rust" help:"Language to use for the function." short:"l"` ProjectFile string `default:"${project_file}" help:"Path to project definition file." short:"f"` projFS afero.Fs @@ -180,6 +183,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error "go-templating": c.generateGoTemplatingFiles, "kcl": c.generateKCLFiles, "python": c.generatePythonFiles, + "rust": c.generateRustFiles, } generator, ok := generators[c.Language] @@ -341,6 +345,52 @@ func (c *generateCmd) generatePythonFiles(targetFS afero.Fs) error { return renderTemplates(afero.NewBasePathFs(targetFS, "function"), tmpls, data) } +type rustTemplateData struct { + Name string + HasSchemas bool + SchemasPath string +} + +func (c *generateCmd) generateRustFiles(targetFS afero.Fs) error { + hasSchemas, err := afero.DirExists(c.schemasFS, "rust") + if err != nil { + return errors.Wrap(err, "cannot inspect rust schemas directory") + } + if hasSchemas { + entries, err := afero.ReadDir(c.schemasFS, "rust") + if err != nil { + return errors.Wrap(err, "cannot read rust schemas directory") + } + hasSchemas = len(entries) > 0 + } + + // Compute the relative path from the function dir to schemas/rust/. + 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, "rust")) + + // template.ParseFS doesn't handle subdirectories, so we need to template + // the top-level directory and the 'src' sub-directory separately. + data := rustTemplateData{ + Name: c.Name, + HasSchemas: hasSchemas, + SchemasPath: schemasPath, + } + tmpls := template.Must(template.ParseFS(rustTemplates, "templates/rust/*.*")) + if err := renderTemplates(targetFS, tmpls, data); err != nil { + return err + } + + if err := targetFS.Mkdir("src", 0o755); err != nil { + return errors.Wrap(err, "cannot create src directory") + } + tmpls = template.Must(template.ParseFS(rustTemplates, "templates/rust/src/*.*")) + return renderTemplates(afero.NewBasePathFs(targetFS, "src"), tmpls, data) +} + type goTemplateData struct { ModulePath string Imports []goImport diff --git a/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go index 94e3f18c..0749a3b9 100644 --- a/cmd/crossplane/function/generate_test.go +++ b/cmd/crossplane/function/generate_test.go @@ -233,6 +233,75 @@ func TestGeneratePythonFiles(t *testing.T) { } } +func TestGenerateRustFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + seedSchemaDirs []string + wantFiles []string + wantContains map[string][]byte + wantNotContains map[string][]byte + }{ + "NoSchemas": { + wantFiles: []string{ + ".gitignore", + "Cargo.toml", + "README.md", + "rust-toolchain.toml", + "src/main.rs", + "src/function.rs", + }, + wantContains: map[string][]byte{ + "Cargo.toml": []byte(`name = "my-func"`), + "README.md": []byte("# my-func"), + }, + wantNotContains: map[string][]byte{ + "Cargo.toml": []byte("crossplane-models"), + "README.md": []byte("crossplane-models"), + "src/function.rs": []byte("crossplane_models"), + }, + }, + "WithSchemas": { + seedSchemas: map[string][]byte{ + "rust/Cargo.toml": nil, + }, + wantContains: map[string][]byte{ + "Cargo.toml": []byte(`crossplane-models = { path = "../../schemas/rust" }`), + "README.md": []byte("`../../schemas/rust`"), + "src/function.rs": []byte("use crossplane_models::"), + }, + }, + "EmptySchemasDirectory": { + seedSchemaDirs: []string{"rust"}, + wantNotContains: map[string][]byte{ + "Cargo.toml": []byte("crossplane-models"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + schemasFS := seedFS(t, tc.seedSchemas) + for _, dir := range tc.seedSchemaDirs { + if err := schemasFS.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + c := &generateCmd{ + Name: "my-func", + schemasFS: schemasFS, + proj: testProject(), + } + fs := afero.NewMemMapFs() + if err := c.generateRustFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, tc.wantNotContains) + }) + } +} + func TestGenerateGoFiles(t *testing.T) { cases := map[string]struct { seedSchemas map[string][]byte diff --git a/cmd/crossplane/function/help/generate.md b/cmd/crossplane/function/help/generate.md index 2925b6c5..bf0c666b 100644 --- a/cmd/crossplane/function/help/generate.md +++ b/cmd/crossplane/function/help/generate.md @@ -11,6 +11,7 @@ The following are valid arguments to the `--language` / `-l` flag: - `go` - `kcl` - `python` +- `rust` ## Examples @@ -27,6 +28,12 @@ Create a Python function in `functions/fn2`: crossplane function generate fn2 --language python ``` +Create a Rust function in `functions/fn3`: + +```shell +crossplane function generate fn3 --language rust +``` + Create a Go function in `functions/compose-cluster` and add it as a pipeline step in the given Composition: diff --git a/cmd/crossplane/function/templates/rust/.gitignore.tmpl b/cmd/crossplane/function/templates/rust/.gitignore.tmpl new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/cmd/crossplane/function/templates/rust/.gitignore.tmpl @@ -0,0 +1 @@ +target/ diff --git a/cmd/crossplane/function/templates/rust/Cargo.toml.tmpl b/cmd/crossplane/function/templates/rust/Cargo.toml.tmpl new file mode 100644 index 00000000..b526241e --- /dev/null +++ b/cmd/crossplane/function/templates/rust/Cargo.toml.tmpl @@ -0,0 +1,37 @@ +[package] +name = "{{ .Name }}" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false +description = "A Crossplane composition function." + +# The function image runs this binary, whatever the package is called. +[[bin]] +name = "function" +path = "src/main.rs" + +[dependencies] +function-sdk-rust = "0.3" +tonic = "0.14" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +{{- if .HasSchemas }} +# Every generated model. To compile only the API groups this function imports, +# add default-features = false and list their features, which +# {{ .SchemasPath }}/Cargo.toml names. +crossplane-models = { path = "{{ .SchemasPath }}" } +{{- end }} + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = { level = "warn", priority = -1 } + +# Function images should be small. +[profile.release] +strip = true diff --git a/cmd/crossplane/function/templates/rust/README.md.tmpl b/cmd/crossplane/function/templates/rust/README.md.tmpl new file mode 100644 index 00000000..dcff1480 --- /dev/null +++ b/cmd/crossplane/function/templates/rust/README.md.tmpl @@ -0,0 +1,30 @@ +# {{ .Name }} + +A Crossplane composition function written in Rust with +[function-sdk-rust](https://github.com/crossplane/function-sdk-rust). + +- Build: `cargo build` +- Test: `cargo test` +- Lint: `cargo clippy --all-targets -- -D warnings` +- Format: `cargo fmt` +- Run locally without mTLS: `cargo run -- --insecure` +- Package: `crossplane project build` from the project root + +Cargo writes `Cargo.lock` on the first build. Commit it to have +`crossplane project build` resolve the same dependency versions every time: the +build uses the lock file when there is one. +{{- if .HasSchemas }} + +Typed models for this project's XRDs and its dependencies are generated into +`{{ .SchemasPath }}` as the `crossplane-models` crate, which this function +depends on by path. Each API group and version is a module named after the +reversed group, and exports every type of that version. The kinds of +`platform.example.org/v1alpha1` are imported like this: + +```rust +use crossplane_models::org::example::platform::v1alpha1::{XBucket, XBucketSpec}; +``` + +The models are regenerated by `crossplane project build` and +`crossplane dependency add`. +{{- end }} diff --git a/cmd/crossplane/function/templates/rust/rust-toolchain.toml b/cmd/crossplane/function/templates/rust/rust-toolchain.toml new file mode 100644 index 00000000..0200f554 --- /dev/null +++ b/cmd/crossplane/function/templates/rust/rust-toolchain.toml @@ -0,0 +1,6 @@ +# The toolchain for working on this function, with the components that format +# and lint it. crossplane project build compiles with the toolchain of its build +# image instead. +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/cmd/crossplane/function/templates/rust/src/function.rs.tmpl b/cmd/crossplane/function/templates/rust/src/function.rs.tmpl new file mode 100644 index 00000000..4aac78d7 --- /dev/null +++ b/cmd/crossplane/function/templates/rust/src/function.rs.tmpl @@ -0,0 +1,84 @@ +//! A Crossplane composition function. + +use function_sdk_rust::proto::v1::function_runner_service_server::FunctionRunnerService; +use function_sdk_rust::proto::v1::{RunFunctionRequest, RunFunctionResponse}; +use function_sdk_rust::response; +use tonic::{Request, Response, Status}; + +/// The composition function. +#[derive(Debug, Default)] +pub struct Function; + +#[tonic::async_trait] +impl FunctionRunnerService for Function { + async fn run_function( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tag = req.meta.as_ref().map(|m| m.tag.clone()).unwrap_or_default(); + tracing::info!(tag, "running function"); + + let mut rsp = response::to(&req, response::DEFAULT_TTL); + + // Add your composition logic here. For example, read the observed + // composite resource with function_sdk_rust::resource::get, and compose + // desired resources by updating rsp.desired.resources with + // function_sdk_rust::resource::update. +{{- if .HasSchemas }} + // + // Both take any serde type, including the models generated for this + // project in the crossplane-models crate. A kind of + // platform.example.org/v1alpha1 is read like this: + // + // use crossplane_models::org::example::platform::v1alpha1::XBucket; + // + // let observed = req.observed.as_ref().and_then(|s| s.composite.as_ref()); + // let xr: XBucket = function_sdk_rust::resource::get(observed) + // .map_err(|e| Status::invalid_argument(e.to_string()))?; +{{- end }} + + response::normal(&mut rsp, "Function completed successfully"); + + Ok(Response::new(rsp)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use function_sdk_rust::proto::v1::{Resource, State}; + use function_sdk_rust::resource; + + #[tokio::test] + async fn responds_to_an_observed_composite_resource() { + let mut composite = Resource::default(); + resource::update( + &mut composite, + &serde_json::json!({ + "apiVersion": "example.crossplane.io/v1alpha1", + "kind": "Example", + "metadata": {"name": "example"}, + "spec": {}, + }), + ) + .unwrap(); + + let req = RunFunctionRequest { + observed: Some(State { + composite: Some(composite), + ..Default::default() + }), + ..Default::default() + }; + + let rsp = Function + .run_function(Request::new(req)) + .await + .unwrap() + .into_inner(); + + let messages: Vec<_> = rsp.results.iter().map(|r| r.message.as_str()).collect(); + assert_eq!(messages, ["Function completed successfully"]); + } +} diff --git a/cmd/crossplane/function/templates/rust/src/main.rs b/cmd/crossplane/function/templates/rust/src/main.rs new file mode 100644 index 00000000..8e899bf6 --- /dev/null +++ b/cmd/crossplane/function/templates/rust/src/main.rs @@ -0,0 +1,13 @@ +//! The composition function's CLI entrypoint. + +use clap::Parser; +use function_sdk_rust::{Args, logging, serve}; + +mod function; + +#[tokio::main] +async fn main() -> Result<(), function_sdk_rust::Error> { + let args = Args::parse(); + logging::configure(args.debug); + serve(function::Function, &args).await +} diff --git a/docs/rust-testing-guide.md b/docs/rust-testing-guide.md new file mode 100644 index 00000000..9a852760 --- /dev/null +++ b/docs/rust-testing-guide.md @@ -0,0 +1,863 @@ +# Testing Rust Support in Crossplane CLI + +This guide walks through testing the Rust support added in PR #374. It builds a complete +Crossplane configuration project whose composition function is written in Rust and composes an S3 +bucket with [provider-aws-s3](https://marketplace.upbound.io/providers/upbound/provider-aws-s3). + +Everything up to and including Step 12 runs on a laptop with Docker and needs no cluster and no AWS +account. Steps 13 and 14 deploy to a cluster, and create a real bucket if you supply AWS +credentials. + +## Prerequisites + +- Go 1.26+ +- The GitHub CLI +- Docker, or an engine that supports `DOCKER_HOST` +- Rust 1.85+ with `cargo` (for local development; the project build itself only needs Docker) +- `kubectl`, for Steps 13 and 14 +- Access to push packages to a registry (for Step 14) +- (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 + +# Check out PR #374 +gh pr checkout 374 + +# 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-bucket-rust \ + --registry xpkg.upbound.io/your-org + +cd configuration-aws-bucket-rust +``` + +Choose the registry now rather than later. The CLI names an embedded function after the project's +repository, and `function generate` writes that name into the composition in Step 6. If you change +`spec.repository` afterwards, the composition refers to a function that no longer exists. See +[Unknown function during render](#unknown-function-during-render). + +If you don't have a registry account, [ttl.sh](https://ttl.sh) accepts anonymous pushes of +short-lived images. Use `--registry ttl.sh/` here and `--tag 2h` in Step 14, +because ttl.sh reads the tag as the image's lifetime. + +## Step 3: Configure the Project + +Edit `crossplane-project.yaml` to generate only Rust schemas: + +```yaml +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: configuration-aws-bucket-rust +spec: + repository: xpkg.upbound.io/your-org/configuration-aws-bucket-rust + schemas: + languages: + - rust +``` + +This step is optional. A project that doesn't set `spec.schemas.languages` gets schemas for every +language, Rust included. Listing `rust` alone skips the others, which is faster: the Python +generator runs in a container. + +## Step 4: Add Dependencies + +When a dependency is added to a Crossplane project: + +- The package is resolved and cached locally +- The CLI generates schemas from any CRDs the package contains +- The dependency is recorded in `crossplane-project.yaml` + +No cluster is involved. + +```bash +# Add the AWS S3 provider dependency +crossplane dependency add xpkg.upbound.io/upbound/provider-aws-s3:v2.7.3 +``` + +The Rust models are generated natively by the CLI, without Docker, so this takes a few seconds once +the package is cached. + +### Kubernetes built-in types + +This guide doesn't need them, but a `k8s` dependency generates Rust models like any other: + +```bash +crossplane dependency add k8s:v1.35.0 +``` + +```rust +use crossplane_models::io::k8s::api::apps::v1::Deployment; +use crossplane_models::io::k8s::api::core::v1::Service; +``` + +The generated crate depends on `serde` and `serde_json` only. It doesn't use `k8s-openapi` or +`kube`. + +## Step 5: Create an Example Manifest and the API + +First, create an example XR that defines your custom resource: + +```bash +mkdir -p examples/storagebucket +cat > examples/storagebucket/example.yaml << 'EOF' +apiVersion: platform.example.com/v1alpha1 +kind: StorageBucket +metadata: + name: example + namespace: default +spec: + region: eu-central-1 + versioning: true +EOF +``` + +Then generate the XRD from the example. This is the platform API: + +```bash +# Generate an XRD from the example XR +crossplane xrd generate examples/storagebucket/example.yaml +``` + +The command writes `apis/storagebuckets/definition.yaml`, a `scope: Namespaced` XRD with a string +`region` and a boolean `versioning`. + +### Scope determines which models you import + +The XRD's scope decides which generated models your function must use, and getting it wrong fails +only at apply time. + +- `scope: Namespaced`, which this guide uses, composes **namespaced** managed resources. Import + from the mirrored `.m.` group: `crossplane_models::io::upbound::m::aws::s3::v1beta1`. +- `scope: Cluster` composes **cluster-scoped** managed resources. Import from + `crossplane_models::io::upbound::aws::s3::v1beta2`. + +Mixing them gets you `cannot apply cluster scoped composed resource "bucket" (a Bucket named +example) for a namespaced composite resource` on the cluster. `crossplane composition render` +renders the mismatched combination without complaint, so this doesn't surface until you deploy. + +A namespaced XR also means the composed resources belong in the XR's namespace. Crossplane doesn't +infer that for you. The function has to set it, which Step 7 does. + +## Step 6: Create the Composition and the Rust Function + +Generate the composition first, so that `function generate` can add the function to its pipeline: + +```bash +# Generate a composition from the XRD +crossplane composition generate apis/storagebuckets/definition.yaml + +# Generate a Rust function scaffold and add it to the composition's pipeline +crossplane function generate compose-bucket apis/storagebuckets/composition.yaml --language rust +``` + +`composition generate` also adds +[function-auto-ready](https://github.com/crossplane-contrib/function-auto-ready) to the project's +dependencies and to the pipeline. + +`function generate` creates `functions/compose-bucket/` with: + +- `Cargo.toml` - Dependencies including `function-sdk-rust`, and a path dependency on the generated + `crossplane-models` crate at `../../schemas/rust` +- `rust-toolchain.toml` - The toolchain for working on the function, with `clippy` and `rustfmt` +- `src/main.rs` - Entry point +- `src/function.rs` - Function implementation template with a starter test +- `.gitignore` - Ignores `target/` +- `README.md` + +The package is named after the function, and its binary is always called `function`. + +The composition now runs your function before `function-auto-ready`: + +```yaml + pipeline: + - functionRef: + name: your-org-configuration-aws-bucket-rustcompose-bucket + step: compose-bucket + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready +``` + +The `functionRef` name comes 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 and cuts the result at 63 characters. + +## Step 7: Implement the Function + +Replace the contents of `functions/compose-bucket/src/function.rs`: + +```rust +//! Composes an S3 bucket, and optionally its versioning configuration, for a +//! StorageBucket. + +use crossplane_models::com::example::platform::v1alpha1::StorageBucket; +use crossplane_models::io::k8s::apimachinery::pkg::apis::meta::v1::ObjectMeta; +// The mirrored `.m.` group holds the namespaced managed resources, which are +// what a namespaced XR composes. A cluster-scoped XRD would import from +// crossplane_models::io::upbound::aws::s3::v1beta2 instead. +use crossplane_models::io::upbound::m::aws::s3::v1beta1::{ + Bucket, BucketSpec, BucketSpecForProvider, BucketVersioning, BucketVersioningSpec, + BucketVersioningSpecForProvider, BucketVersioningSpecForProviderBucketRef, + BucketVersioningSpecForProviderVersioningConfiguration, +}; +use function_sdk_rust::proto::v1::function_runner_service_server::FunctionRunnerService; +use function_sdk_rust::proto::v1::{RunFunctionRequest, RunFunctionResponse}; +use function_sdk_rust::{resource, response}; +use tonic::{Request, Response, Status}; + +/// The composition function. +#[derive(Debug, Default)] +pub struct Function; + +#[tonic::async_trait] +impl FunctionRunnerService for Function { + async fn run_function( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tag = req.meta.as_ref().map(|m| m.tag.clone()).unwrap_or_default(); + tracing::info!(tag, "running function"); + + let mut rsp = response::to(&req, response::DEFAULT_TTL); + + // Read the observed XR into the model generated from the XRD. + let observed = req.observed.as_ref().and_then(|s| s.composite.as_ref()); + let xr: StorageBucket = match resource::get(observed) { + Ok(xr) => xr, + Err(e) => { + response::fatal(&mut rsp, format!("cannot get xr: {e}")); + return Ok(Response::new(rsp)); + } + }; + + let metadata = xr.metadata.unwrap_or_default(); + let spec = xr.spec.unwrap_or_default(); + let (Some(name), Some(region)) = (metadata.name, spec.region) else { + response::fatal(&mut rsp, "xr is missing metadata.name or spec.region"); + return Ok(Response::new(rsp)); + }; + + // A namespaced XR composes namespaced resources, and Crossplane doesn't + // put them in the XR's namespace for you. + let namespace = metadata.namespace; + + // Every field of a generated model is an Option that is left out when + // unset, so the desired state holds only the fields set here. Default + // fills in the apiVersion and kind. + let bucket = Bucket { + metadata: Some(ObjectMeta { + name: Some(name.clone()), + namespace: namespace.clone(), + ..Default::default() + }), + spec: Some(BucketSpec { + for_provider: Some(BucketSpecForProvider { + region: Some(region.clone()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + let desired = rsp.desired.get_or_insert_default(); + resource::update( + desired.resources.entry("bucket".to_string()).or_default(), + &bucket, + ) + .map_err(|e| Status::internal(e.to_string()))?; + + if spec.versioning == Some(true) { + let versioning = BucketVersioning { + metadata: Some(ObjectMeta { + name: Some(format!("{name}-versioning")), + namespace, + ..Default::default() + }), + spec: Some(BucketVersioningSpec { + for_provider: Some(BucketVersioningSpecForProvider { + region: Some(region), + bucket_ref: Some(BucketVersioningSpecForProviderBucketRef { + name: Some(name), + ..Default::default() + }), + versioning_configuration: Some( + BucketVersioningSpecForProviderVersioningConfiguration { + status: Some("Enabled".to_string()), + ..Default::default() + }, + ), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + resource::update( + desired + .resources + .entry("versioning".to_string()) + .or_default(), + &versioning, + ) + .map_err(|e| Status::internal(e.to_string()))?; + } + + response::normal(&mut rsp, "composed the bucket"); + + Ok(Response::new(rsp)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use function_sdk_rust::proto::v1::{Resource, State}; + + async fn run(spec: serde_json::Value) -> RunFunctionResponse { + let mut composite = Resource::default(); + resource::update( + &mut composite, + &serde_json::json!({ + "apiVersion": StorageBucket::API_VERSION, + "kind": StorageBucket::KIND, + "metadata": {"name": "example", "namespace": "default"}, + "spec": spec, + }), + ) + .unwrap(); + + let req = RunFunctionRequest { + observed: Some(State { + composite: Some(composite), + ..Default::default() + }), + ..Default::default() + }; + + Function + .run_function(Request::new(req)) + .await + .unwrap() + .into_inner() + } + + #[tokio::test] + async fn composes_only_the_fields_it_sets() { + let rsp = run(serde_json::json!({"region": "eu-central-1"})).await; + + let resources = &rsp.desired.as_ref().unwrap().resources; + assert_eq!(resources.len(), 1); + assert_eq!( + resource::struct_to_json(resources["bucket"].resource.as_ref().unwrap()), + serde_json::json!({ + "apiVersion": "s3.aws.m.upbound.io/v1beta1", + "kind": "Bucket", + "metadata": {"name": "example", "namespace": "default"}, + "spec": {"forProvider": {"region": "eu-central-1"}}, + }) + ); + } + + #[tokio::test] + async fn composes_versioning_when_asked() { + let rsp = run(serde_json::json!({"region": "eu-central-1", "versioning": true})).await; + + let resources = &rsp.desired.as_ref().unwrap().resources; + assert!(resources.contains_key("versioning")); + } +} +``` + +The function reads the XR through `StorageBucket`, the model generated from the XRD, and writes the +`Bucket` and the `BucketVersioning` through the models generated from provider-aws-s3. + +The XR's name becomes the bucket's name. S3 bucket names are global, so `example` is fine for +rendering but is already taken in AWS. Step 13 uses a name of your own. + +## Step 8: Look at the Generated Models + +The models were generated in Steps 4 to 6 and are regenerated by `crossplane project build`. They +form one Cargo crate, `crossplane-models`, under `schemas/rust/`: + +```text +schemas/rust/ +├── Cargo.toml +└── src/ + ├── lib.rs + ├── com/example/platform/v1alpha1/ # your XRD: StorageBucket + └── io/ + ├── k8s/apimachinery/... # ObjectMeta and the other shared types + └── upbound/ + ├── aws/s3/v1beta2/ # cluster-scoped managed resources + └── m/aws/s3/v1beta1/ # namespaced managed resources +``` + +- Each API group and version is a module named after the reversed group. Import types from the + module, whatever file they were generated into: a kind, its list and its `Spec` and `Status` + helpers share a file (`bucket.rs`), and the module re-exports all of them. +- Every field is an `Option` that is skipped when it serializes, so a function's desired state + contains only the fields it sets. Unknown fields are ignored when deserializing, so an observed + resource from a newer provider version still parses. +- A resource type has `API_VERSION` and `KIND` constants, and its `Default` fills both in. +- String enums stay `String`. The allowed values are listed in the field's documentation. +- An object that has named properties and allows others, through `additionalProperties` or + `x-kubernetes-preserve-unknown-fields`, keeps the others in an `additional_properties` map that + is flattened into the struct. A resource read into a model and written back loses nothing. +- Two properties that map to one Rust identifier, such as `proxyURL` and `proxyUrl`, become + `proxy_url` and `proxy_url_2`. Each keeps its own name on the wire. + +### Compiling only the models a function uses + +A project with several providers generates a lot of models, and a function build compiles all of +them by default. Each module of models is behind a Cargo feature named after its path +(`io::upbound::m::aws::s3::v1beta1` is `io-upbound-m-aws-s3-v1beta1`), all of them enabled by +default. `schemas/rust/Cargo.toml` lists them. To compile only what the function imports, turn the +defaults off in `functions/compose-bucket/Cargo.toml` and name the modules it imports from: + +```toml +crossplane-models = { path = "../../schemas/rust", default-features = false, features = [ + "com-example-platform-v1alpha1", + "io-upbound-m-aws-s3-v1beta1", +] } +``` + +A feature enables the features of the modules its models refer to, so the shared Kubernetes types +don't need listing: `io::k8s::apimachinery::pkg::apis::meta::v1` comes with either of the two above. +The function in Step 7 imports `ObjectMeta` from it directly, which works for the same reason. + +Importing from a module whose feature is off fails to compile, and the compiler names the feature: + +```text +error[E0432]: unresolved import `crossplane_models::io::upbound::m::aws::s3::v1beta1` +note: found an item that was configured out + | #[cfg(feature = "io-upbound-m-aws-s3-v1beta1")] + | --------------------------------------- the item is gated behind the `io-upbound-m-aws-s3-v1beta1` feature +``` + +## Step 9: Local Development (Optional) + +The function is an ordinary Cargo package: + +```bash +cd functions/compose-bucket + +cargo build +cargo test +cargo clippy --all-targets -- -D warnings +cargo fmt --check + +cd ../.. +``` + +The scaffold passes all four as generated, and so does the function from Step 7. + +For a fast loop, run the function on your machine and point `render` at it, instead of letting +`render` rebuild the function image on every run. Start the function: + +```bash +cargo run --manifest-path functions/compose-bucket/Cargo.toml -- --insecure +``` + +In another terminal, describe the pipeline's functions in a file. The first function's name must be +the `functionRef` name from `apis/storagebuckets/composition.yaml`: + +```bash +cat > /tmp/functions.yaml << 'EOF' +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: your-org-configuration-aws-bucket-rustcompose-bucket + annotations: + render.crossplane.io/runtime: Development +spec: + package: xpkg.upbound.io/your-org/configuration-aws-bucket-rust_compose-bucket +--- +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: crossplane-contrib-function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.6.1 +EOF + +crossplane composition render \ + examples/storagebucket/example.yaml \ + apis/storagebuckets/composition.yaml \ + /tmp/functions.yaml +``` + +With a functions file, `render` doesn't build anything, so this takes about a second. + +## Step 10: Activate the Managed Resources + +Crossplane v2 supports +[`ManagedResourceActivationPolicy`](https://docs.crossplane.io/latest/managed-resources/managed-resource-activation-policies/), +which limits how many CRDs a provider installs onto a cluster. The Crossplane Helm chart installs a +wildcard policy by default, which activates every CRD a provider ships. + +Save this file as `apis/storagebuckets/mrap.yaml`. It's packaged with the project and applied when +the Configuration is installed: + +```yaml +apiVersion: apiextensions.crossplane.io/v1alpha1 +kind: ManagedResourceActivationPolicy +metadata: + name: configuration-aws-bucket-rust +spec: + activate: + - buckets.s3.aws.m.upbound.io + - bucketversionings.s3.aws.m.upbound.io +``` + +`crossplane composition render` doesn't need the policy, so a missing activation only shows up +once you deploy to a cluster without the wildcard policy, as Step 13 does. + +## Step 11: Build the Project + +```bash +# Build the complete project (configuration + embedded functions) +crossplane project build +``` + +This will: + +1. Generate Rust schemas from all dependencies (provider-aws-s3, your XRD) +2. Compile the Rust function in a `rust:1-bookworm` container, together with the `crossplane-models` + crate it depends on, once for each architecture in `spec.architectures` (amd64 and arm64 by + default) +3. Put the binary on `gcr.io/distroless/cc-debian12:nonroot` as `/function` +4. Package everything into a Crossplane configuration package + +The output is `_output/configuration-aws-bucket-rust.xpkg`. + +Nothing is cached between builds yet: every build downloads and compiles all of the function's +dependencies. Expect a minute or two. + +`spec.architectures` may list `amd64` and `arm64`. Anything else fails before the build starts. + +## Step 12: Test with Composition Render + +Before deploying to a cluster, test the composition with `crossplane composition render`. In a +project directory, `render` builds the embedded functions itself, so you only pass the XR and the +composition: + +```bash +crossplane composition render \ + examples/storagebucket/example.yaml \ + apis/storagebuckets/composition.yaml \ + --timeout=5m +``` + +`--timeout=5m` matters. `render` times out after a minute by default, and it rebuilds the function +from source on every run, which takes longer than that. See +[Build timeout during render](#build-timeout-during-render). Step 9 shows how to render without +rebuilding. + +The output shows the XR and the composed resources: + +```yaml +--- +apiVersion: platform.example.com/v1alpha1 +kind: StorageBucket +metadata: + name: example + namespace: default +spec: + crossplane: + resourceRefs: + - apiVersion: s3.aws.m.upbound.io/v1beta1 + kind: BucketVersioning + name: example-versioning + - apiVersion: s3.aws.m.upbound.io/v1beta1 + kind: Bucket + name: example +status: + conditions: + # ... +--- +apiVersion: s3.aws.m.upbound.io/v1beta1 +kind: Bucket +metadata: + annotations: + crossplane.io/composition-resource-name: bucket + labels: + crossplane.io/composite: example + name: example + namespace: default + ownerReferences: + # ... +spec: + forProvider: + region: eu-central-1 +--- +apiVersion: s3.aws.m.upbound.io/v1beta1 +kind: BucketVersioning +metadata: + annotations: + crossplane.io/composition-resource-name: versioning + labels: + crossplane.io/composite: example + name: example-versioning + namespace: default + ownerReferences: + # ... +spec: + forProvider: + bucketRef: + name: example + region: eu-central-1 + versioningConfiguration: + status: Enabled +``` + +Note that `spec.forProvider` holds only the fields the function set. + +```bash +# Include function results (informational messages) +crossplane composition render \ + examples/storagebucket/example.yaml \ + apis/storagebuckets/composition.yaml \ + --timeout=5m --include-function-results +``` + +The results include `Pipeline step "compose-bucket": composed the bucket`, the message the function +reports with `response::normal`. + +## Step 13: Test with a Local Dev Cluster + +`crossplane project run` creates a local Kubernetes cluster with Crossplane, builds the project and +deploys it: + +```bash +# Start a local dev cluster and deploy the project +crossplane project run --no-default-mrap +``` + +`--no-default-mrap` suppresses the wildcard activation policy, so the +`ManagedResourceActivationPolicy` from Step 10 is what activates your CRDs. The flag only takes +effect when the control plane is created. If one already exists, run `crossplane project stop` +first. + +Once the cluster is running, check that the function is healthy: + +```bash +kubectl get functions.pkg.crossplane.io +kubectl -n crossplane-system logs -l pkg.crossplane.io/function=your-org-configuration-aws-bucket-rustcompose-bucket +``` + +The function logs `serving FunctionRunnerService` with `"insecure":false`: in a cluster it serves +gRPC over mTLS on port 9443, with the certificates Crossplane mounts for it. + +To create a real bucket, give the provider AWS credentials. Namespaced managed resources use the +`ClusterProviderConfig` named `default` unless told otherwise: + +```bash +# creds.conf should contain your AWS credentials: +# [default] +# aws_access_key_id = YOUR_ACCESS_KEY +# aws_secret_access_key = YOUR_SECRET_KEY +kubectl create secret generic aws-creds -n crossplane-system --from-file=creds=creds.conf + +kubectl apply -f - <_`, here +`xpkg.upbound.io/your-org/configuration-aws-bucket-rust_compose-bucket`. Functions are pushed first, +so if that repository can't be created the configuration is never uploaded. + +```bash +# Install on a cluster +kubectl apply -f - <_`: the registry host +is dropped, `/` becomes `-`, the underscore disappears, and the result is cut at 63 characters. For +`xpkg.upbound.io/your-org/configuration-aws-bucket-rust` and `compose-bucket` that is +`your-org-configuration-aws-bucket-rustcompose-bucket`. + +### Cluster scoped composed resource for a namespaced composite resource + +The function composes the cluster-scoped managed resources for a namespaced XR. Import the models +from the `.m.` group instead. See +[Scope determines which models you import](#scope-determines-which-models-you-import). + +### Missing or stale models + +On `main`, the schema manager only ever adds files, and it doesn't record which languages it +generated for. Three things follow, for every language and not only for Rust: + +- Adding `rust` to `spec.schemas.languages` of a project that already has dependencies generates + models for the project's XRDs, but not for the dependencies, because their recorded versions + haven't changed. +- Renaming or deleting a kind leaves its old model file in `schemas/rust`, still exported by its + module. +- Removing a language from `spec.schemas.languages` leaves its directory behind. + +All three are fixed by clearing the generated schemas and rebuilding: + +```bash +crossplane dependency clean-cache --keep-packages +crossplane project build +``` + +### 404 from the registry when pushing + +`project push` pushes the function to `_`. On a registry that doesn't +create repositories on first push, create that repository before pushing. diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go index cd2795db..a3098109 100644 --- a/internal/project/functions/build.go +++ b/internal/project/functions/build.go @@ -51,6 +51,7 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC builders := []Builder{ newKCLBuilder(imageConfigs), newPythonBuilder(imageConfigs), + newRustBuilder(imageConfigs), newGoBuilder(imageConfigs), newGoTemplatingBuilder(imageConfigs), } @@ -69,7 +70,8 @@ func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageC // BuildContext bundles the inputs that function builders work from. Each // builder slices the parts of the project it needs: the function -// subdirectory for Go/KCL/go-templating, plus the schemas dir for Python. +// subdirectory for Go/KCL/go-templating, plus the schemas dir for Python and +// Rust. type BuildContext struct { // ProjectFS is the project root filesystem. ProjectFS afero.Fs @@ -77,8 +79,9 @@ type BuildContext struct { // e.g. "functions/my-fn". FunctionPath string // SchemasPath is the schemas dir relative to ProjectFS root, e.g. - // "schemas". Used by Python to stage schemas/python/ alongside the - // function source so the relative path-dep resolves at build time. + // "schemas". Used by Python and Rust to stage schemas// + // alongside the function source so the relative path-dep resolves at + // build time. SchemasPath string // Architectures is the list of architectures to build for. Architectures []string diff --git a/internal/project/functions/build_test.go b/internal/project/functions/build_test.go index 4c90a9fc..1887c7a8 100644 --- a/internal/project/functions/build_test.go +++ b/internal/project/functions/build_test.go @@ -70,6 +70,29 @@ func TestIdentify(t *testing.T) { }, expectedBuilder: &pythonBuilder{}, }, + "RustOnly": { + files: map[string]string{ + "Cargo.toml": "[package]", + "src/main.rs": "fn main() {}", + }, + expectedBuilder: &rustBuilder{}, + }, + "RustManifestOnly": { + files: map[string]string{ + "Cargo.toml": "[package]", + "src/bin/function.rs": "fn main() {}", + }, + expectedBuilder: &rustBuilder{}, + }, + "PythonWithRustExtension": { + files: map[string]string{ + "pyproject.toml": "[project]", + "function/fn.py": "", + "Cargo.toml": "[package]", + }, + // pythonBuilder has precedence. + expectedBuilder: &pythonBuilder{}, + }, "GoOnly": { files: map[string]string{ "go.mod": "module example.com/fake/module", diff --git a/internal/project/functions/rust.go b/internal/project/functions/rust.go new file mode 100644 index 00000000..71b77e6a --- /dev/null +++ b/internal/project/functions/rust.go @@ -0,0 +1,337 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "io" + "net/http" + "path" + "path/filepath" + "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 ( + // rustBuildImage is the image in which we build the function. Its Debian + // release must match rustRuntimeImage's: the binary links dynamically + // against the build image's glibc, and only runs on that version or newer. + rustBuildImage = "docker.io/library/rust:1-bookworm" + // rustRuntimeImage is the distroless base used at runtime. The cc flavor + // carries glibc and libgcc, which is all a Rust binary links against. + rustRuntimeImage = "gcr.io/distroless/cc-debian12:nonroot" + + // rustBinaryPath is where the function's binary lives in the runtime image. + rustBinaryPath = "/function" + // rustBuildOutput is where the build container stages each architecture's + // binary, as //function. + rustBuildOutput = "/out" + // rustTargetDir keeps cargo's output out of the staged source tree, and in + // a known place whatever the function's cargo configuration says. + rustTargetDir = "/build/target" + + // rustBuildScript runs in the build container. Rust itself cross-compiles + // to any installed target, but linking and the C code some crates build + // (aws-lc-sys, which function-sdk-rust's TLS stack pulls in) need a C + // toolchain for the target, so an architecture other than the container's + // own gets Debian's cross gcc. + // + // We let cargo decide what the package's binaries are rather than reading + // Cargo.toml: a binary can come from [[bin]], from src/main.rs under the + // package name, or from src/bin. + rustBuildScript = `set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +# Build with the toolchain the image ships. A rust-toolchain.toml in the +# function would otherwise have rustup download another one on every build. +RUSTUP_TOOLCHAIN=$(rustup default | cut -d' ' -f1) +export RUSTUP_TOOLCHAIN +host=$(uname -m) +for arch in $ARCHS ; do + case "$arch" in + amd64) cpu=x86_64 ;; + arm64) cpu=aarch64 ;; + *) echo "unsupported architecture: $arch" >&2 ; exit 1 ;; + esac + target=$cpu-unknown-linux-gnu + if [ "$cpu" != "$host" ] ; then + apt-get update --quiet=2 + apt-get install --quiet=2 --yes --no-install-recommends \ + "gcc-${cpu//_/-}-linux-gnu" "libc6-dev-$arch-cross" >/dev/null + rustup target add "$target" + env_target=${target//-/_} + export "CARGO_TARGET_${env_target^^}_LINKER=$cpu-linux-gnu-gcc" + export "CC_$env_target=$cpu-linux-gnu-gcc" + fi + cargo build --quiet --release --bins --target "$target" + mapfile -t bins < <(find "$CARGO_TARGET_DIR/$target/release" -maxdepth 1 -type f -executable ! -name '*.so') + if [ "${#bins[@]}" -ne 1 ] ; then + echo "a function must build exactly one binary, found ${#bins[@]}: ${bins[*]##*/}" >&2 + exit 1 + fi + install -D --mode=0755 "${bins[0]}" "$OUTPUT/$arch/function" +done +` +) + +// rustBuilder builds Rust composition functions. +// +// A Rust embedded function is a function-sdk-rust project: a Cargo package that +// builds one binary. We build it the way function-sdk-rust's example Dockerfile +// does, with cargo in the official Rust image, and put the resulting binary on +// a distroless base. Unlike a Dockerfile build we compile every architecture in +// one container of the host's architecture, so nothing runs under emulation. +type rustBuilder struct { + buildImage string + runtimeImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *rustBuilder) Name() string { + return "rust" +} + +// match identifies a Rust function by its Cargo manifest alone. Where the +// binary's source lives is up to the manifest (src/main.rs, src/bin, or a +// [[bin]] path), and cargo reports it if there is none. +func (b *rustBuilder) match(fromFS afero.Fs) (bool, error) { + return afero.Exists(fromFS, "Cargo.toml") +} + +func (b *rustBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + // Reject an architecture we cannot build for before compiling the ones we + // can, which takes minutes. + for _, arch := range c.Architectures { + if err := rustCheckArchitecture(arch); err != nil { + return nil, err + } + } + + if err := docker.Check(ctx); err != nil { + return nil, errors.Wrap(err, "rust builds require a Docker-compatible container runtime") + } + + buildImage, err := b.rewriteImage(ctx, b.buildImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite build image") + } + + binaryTars, err := b.buildBinaries(ctx, buildImage, c) + if err != nil { + return nil, err + } + + runtimeImage, err := b.rewriteImage(ctx, b.runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite runtime image") + } + runtimeRef, err := name.ParseReference(runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to parse rust 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, c.BaseImageCacheDir) + if err != nil { + return errors.Wrap(err, "failed to fetch rust runtime base image") + } + + binaryLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(binaryTars[arch])), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create binary layer") + } + + img, err := mutate.AppendLayers(baseImg, binaryLayer) + if err != nil { + return errors.Wrap(err, "failed to append binary layer") + } + + img, err = configureRustImage(img) + if err != nil { + return errors.Wrap(err, "failed to configure rust image") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +func (b *rustBuilder) rewriteImage(ctx context.Context, image string) (string, error) { + _, rewritten, err := b.configStore.RewritePath(ctx, image) + if err != nil { + return "", err + } + if rewritten != "" { + return rewritten, nil + } + return image, nil +} + +// rustCheckArchitecture reports whether the build script knows how to build +// for an architecture. +func rustCheckArchitecture(arch string) error { + switch arch { + case "amd64", "arm64": + return nil + default: + return errors.Errorf("cannot build a Rust function for architecture %q; use amd64 or arm64", arch) + } +} + +// buildBinaries runs the build script against the staged sources in a throwaway +// container. It returns, for each architecture, a tar archive holding the +// function's binary as a single file named function. +func (b *rustBuilder) buildBinaries(ctx context.Context, buildImage string, c BuildContext) (map[string][]byte, error) { + sourceTars, err := rustSourceTars(c) + if err != nil { + return nil, err + } + + opts := []docker.StartContainerOption{ + docker.StartWithEnv( + "ARCHS="+strings.Join(c.Architectures, " "), + "OUTPUT="+rustBuildOutput, + "CARGO_TARGET_DIR="+rustTargetDir, + ), + docker.StartWithCommand([]string{"bash", "-c", rustBuildScript}), + docker.StartWithWorkingDirectory("/" + filepath.ToSlash(c.FunctionPath)), + } + for _, t := range sourceTars { + opts = append(opts, docker.StartWithCopyFiles(t, "/")) + } + + cid, err := docker.StartContainer(ctx, "", buildImage, opts...) + if err != nil { + return nil, errors.Wrap(err, "failed to start rust build container") + } + defer func() { + // The build is most likely to end early because ctx expired, and a + // container that outlives us would go on compiling. + _ = docker.StopContainerByID(context.WithoutCancel(ctx), cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return nil, errors.Wrap(err, "rust build container failed") + } + + ret := make(map[string][]byte, len(c.Architectures)) + for _, arch := range c.Architectures { + // Copying a single file out of a container yields a tar holding just + // that file, under its base name. Appended to the runtime image as a + // layer, that puts the binary at rustBinaryPath. + ret[arch], err = docker.TarFromContainer(ctx, cid, path.Join(rustBuildOutput, arch, path.Base(rustBinaryPath))) + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve built function for architecture %s", arch) + } + } + + return ret, nil +} + +// rustSourceTars returns the tar archives to unpack at the root of the build +// container: the function's source at / and, if the project has +// generated Rust models, the models crate at //rust. Preserving +// the project's layout is what lets cargo resolve the function's path +// dependency on the models. +// +// Both leave out target/, which holds build output for the host rather than +// anything the build needs, and is routinely gigabytes. Symlinks in the function +// are followed, which is how a function can share source with its siblings. The +// one thing that must not be a symlink is target itself: the exclusion matches +// the paths under target rather than target, so a symlink of that name is +// followed like any other and everything behind it is staged. +func rustSourceTars(c BuildContext) ([][]byte, error) { + fnTar, err := filesystem.FSToTar(c.FunctionFS(), filepath.ToSlash(c.FunctionPath), + filesystem.WithExcludePrefix("target/"), + filesystem.WithSymlinkBasePath(c.OSBasePath), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to tar function source") + } + tars := [][]byte{fnTar} + + modelsRel := path.Join(filepath.ToSlash(c.SchemasPath), "rust") + modelsFS := afero.NewBasePathFs(c.ProjectFS, modelsRel) + hasModels, err := afero.DirExists(modelsFS, ".") + if err != nil { + return nil, errors.Wrapf(err, "cannot check for rust schemas at %q", modelsRel) + } + if hasModels { + modelsTar, err := filesystem.FSToTar(modelsFS, modelsRel, filesystem.WithExcludePrefix("target/")) + if err != nil { + return nil, errors.Wrap(err, "failed to tar rust schemas") + } + tars = append(tars, modelsTar) + } + + return tars, nil +} + +// configureRustImage sets the runtime configuration on the final image to match +// function-sdk-rust's example image: nonroot user, the function entrypoint, and +// the gRPC port. +func configureRustImage(img v1.Image) (v1.Image, error) { + cfgFile, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config file") + } + cfg := cfgFile.Config + + cfg.Entrypoint = []string{rustBinaryPath} + cfg.Cmd = nil + cfg.WorkingDir = "/" + cfg.User = "nonroot:nonroot" + if cfg.ExposedPorts == nil { + cfg.ExposedPorts = map[string]struct{}{} + } + cfg.ExposedPorts["9443/tcp"] = struct{}{} + + return mutate.Config(img, cfg) +} + +func newRustBuilder(imageConfigs []pkgv1beta1.ImageConfig) *rustBuilder { + return &rustBuilder{ + buildImage: rustBuildImage, + runtimeImage: rustRuntimeImage, + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go index d26519d2..3ca4a2a0 100644 --- a/internal/schemas/generator/interface.go +++ b/internal/schemas/generator/interface.go @@ -71,6 +71,7 @@ func AllLanguages(opts ...Option) []Interface { &jsonGenerator{}, &kclGenerator{}, &pythonGenerator{}, + &rustGenerator{}, } } diff --git a/internal/schemas/generator/rust.go b/internal/schemas/generator/rust.go new file mode 100644 index 00000000..7f45f9ce --- /dev/null +++ b/internal/schemas/generator/rust.go @@ -0,0 +1,1098 @@ +/* +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" + "maps" + "path" + "slices" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/spf13/afero" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +const ( + // rustModelsDir is the directory generators write their output to, which + // the schema manager strips when it copies them into the project. + rustModelsDir = "models" + + // rustSrcDir is the crate's source directory, relative to the crate root. + rustSrcDir = "src" + + // rustGeneratedHeader marks every file we emit. It matches the convention + // Go tooling recognizes, which several editors and linters reuse. + rustGeneratedHeader = "// Code generated by github.com/crossplane/cli/v2 DO NOT EDIT." + + // rustMaxDepth caps how deep we walk into inline objects. Structural + // schemas are finite trees, so this only guards against pathological input + // taking the whole CLI down with a stack overflow. rustc gives up on types + // nested about this deep anyway; the limit is here so that the generator's + // own behavior is explicit rather than left to the size of the Go stack. + rustMaxDepth = 64 + + // rustSharedPackage prefixes the component schemas that both generation + // flows emit, see rustPrepareSchemas. + rustSharedPackage = "io.k8s.apimachinery." + + // rustAdditionalProperties is the JSON-side name of the field that keeps + // the properties a schema allows without naming them. + rustAdditionalProperties = "additionalProperties" + + // rustPreserveUnknownFields marks an object whose unknown fields the API + // server keeps. + rustPreserveUnknownFields = "x-kubernetes-preserve-unknown-fields" +) + +// rustCargoToml is the crate manifest, up to the features table that +// rustRenderCargoToml derives from the crate's modules. +const rustCargoToml = `# Code generated by github.com/crossplane/cli/v2 DO NOT EDIT. +[package] +name = "crossplane-models" +version = "0.0.1" +edition = "2024" +publish = false +description = "Rust models generated by the Crossplane CLI from a project's XRDs and dependencies." + +[lib] +path = "src/lib.rs" +# Schema descriptions become doc comments verbatim, and some of them contain +# indented or fenced blocks that rustdoc would otherwise collect as doctests +# and try to compile as Rust. +doctest = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +` + +// rustCrateAttributes are the inner attributes of the crate root. Generated +// code is not held to the same lints as hand-written code, and the descriptions +// we turn into doc comments are arbitrary prose from a CRD. +const rustCrateAttributes = `#![allow(clippy::all)] +#![allow(non_camel_case_types, non_snake_case)] +#![allow(rustdoc::bare_urls, rustdoc::broken_intra_doc_links, rustdoc::invalid_html_tags)] +#![doc = "Rust models generated by the Crossplane CLI from a project's XRDs and dependencies."] +` + +type rustGenerator struct{} + +func (rustGenerator) Language() string { + return devv1alpha1.SchemaLanguageRust +} + +// GenerateFromCRD generates Rust models for the XRDs and CRDs in the given +// filesystem. +func (rustGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + openAPIs, err := goCollectOpenAPIs(fromFS) + if err != nil { + return nil, err + } + + if len(openAPIs) == 0 { + return nil, nil + } + + // Every CRD's OpenAPI document carries its own copy of the shared + // Kubernetes schemas it references (ObjectMeta and friends). Merging them + // into one set means we generate one file per type, whichever document it + // came from, and the emitter can resolve every $ref by name. + schemas := make(map[string]*spec.Schema) + for _, oapi := range openAPIs { + if oapi.spec.Components == nil { + continue + } + goRemoveValidationOnlyCombinators(oapi.spec) + maps.Copy(schemas, oapi.spec.Components.Schemas) + } + + return rustGenerateModels(schemas) +} + +// GenerateFromOpenAPI generates Rust models for the OpenAPI v3 documents in the +// given filesystem. +func (rustGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + openAPIs, err := collectOpenAPISpecs(fromFS) + if err != nil { + return nil, err + } + + if len(openAPIs) == 0 { + return nil, nil + } + + schemas := make(map[string]*spec.Schema) + for _, oapi := range openAPIs { + // Unlike CRD schemas, which get their apiVersion and kind defaults + // from crd.ToOpenAPI, schemas from a live API server carry a + // group-version-kind extension we have to turn into defaults + // ourselves. They are what marks a type as a root, see rustRootOf. + goAddDefaults(oapi) + goRemoveValidationOnlyCombinators(oapi) + maps.Copy(schemas, oapi.Components.Schemas) + } + + return rustGenerateModels(schemas) +} + +// rustGenerateModels writes the crossplane-models crate for the given component +// schemas into the models directory of a new filesystem. +func rustGenerateModels(schemas map[string]*spec.Schema) (afero.Fs, error) { + // A crate with no types would still need a lib.rs to be a crate at all, so + // generate nothing rather than something broken. + if len(schemas) == 0 { + return nil, nil + } + + rustPrepareSchemas(schemas) + if len(schemas) == 0 { + return nil, nil + } + + schemaFS := afero.NewMemMapFs() + crateFS := afero.NewBasePathFs(schemaFS, rustModelsDir) + + names := slices.Sorted(maps.Keys(schemas)) + boxed := rustDetectCycles(schemas) + + // Directories are named after schema name segments and files after type + // names, so a kind named like one of its own sub-packages would give its + // module two children of the same name. Collect the directories up front to + // keep file names clear of them. + dirs := make(map[string]bool, len(names)) + for _, name := range names { + module, _ := rustSplitSchemaName(name) + for i := range module { + dirs[rustModuleDir(module[:i+1])] = true + } + } + + written := make(map[string]string, len(names)) + for _, group := range rustGroupSchemas(schemas) { + if err := crateFS.MkdirAll(group.dir, 0o755); err != nil { + return nil, errors.Wrapf(err, "failed to create directory %q", group.dir) + } + + file := rustFilePath(group.dir, group.stem, dirs, written) + written[file] = group.primary + + if err := afero.WriteFile(crateFS, file, rustEmitFile(group, schemas, boxed), 0o644); err != nil { + return nil, errors.Wrapf(err, "failed to write models for schema %q", group.primary) + } + } + + if err := BuildRustModuleTree(crateFS); err != nil { + return nil, err + } + + return schemaFS, nil +} + +// rustPrepareSchemas makes the component schemas safe to emit. +// +// A schema without a usable type name is dropped, as the Go generator does. A +// CRD that leaves spec.names.listKind to the API server's defaulting has one: +// its list schema is named after the group and version alone. +// +// The shared Kubernetes types (ObjectMeta, Status, DeleteOptions, ...) are +// written by every source, into the same files: the CRD flow emits the ones a +// CRD refers to, and the OpenAPI flow emits all of them. The schema manager +// copies each source's output over the last and never deletes, so both flows +// have to produce those files identically, whichever runs last. They would not: +// goAddDefaults gives Status and DeleteOptions an apiVersion and kind in the +// OpenAPI flow only, which would make them resources there, with consts, a +// Default impl and the types named after them grouped into their file. Nobody +// composes those types, so they are plain structs in both flows instead. +func rustPrepareSchemas(schemas map[string]*spec.Schema) { + for name, s := range schemas { + if _, typ := rustSplitSchemaName(name); typ == "" { + delete(schemas, name) + continue + } + if s == nil || !strings.HasPrefix(name, rustSharedPackage) { + continue + } + for _, field := range []string{"apiVersion", "kind"} { + prop, ok := s.Properties[field] + if !ok { + continue + } + prop.Default = nil + prop.Enum = nil + s.Properties[field] = prop + } + } +} + +// rustModuleDir returns the directory holding a module path's types. +func rustModuleDir(module []string) string { + return path.Join(append([]string{rustSrcDir}, module...)...) +} + +// rustFileGroup is the set of component schemas emitted into one file, named +// after the kind they belong to. +type rustFileGroup struct { + dir string + stem string + primary string // the schema the file is named after + schemas []string // every schema in the file, sorted, primary first +} + +// rustGroupSchemas decides which file each component schema is emitted into. +// Everything that belongs to a kind is emitted with it, so a CronJob's spec, +// status and list types are in cronjob.rs rather than three files of their own: +// +// - a kind gets its own file; +// - a List kind is emitted with its , being a wrapper around it +// rather than a resource anyone composes; +// - anything else joins the kind whose name it starts with, longest name +// first, which is how the Kubernetes API names a kind's helper types +// (PodSpec, PodTemplateSpec, DeploymentStrategy); +// - a schema named after no kind keeps its own file (ObjectMeta, Container, +// LabelSelector). +// +// Grouping depends on which kinds a source knows about, so it is only safe for +// files a single source writes. The shared Kubernetes types are written by +// every source; rustPrepareSchemas makes sure none of them is a kind, which +// leaves each of them in a file of its own. +// +// The result is sorted, so the emitted crate does not depend on map order. +func rustGroupSchemas(schemas map[string]*spec.Schema) []rustFileGroup { + type schemaInfo struct{ name, typ string } + + byDir := make(map[string][]schemaInfo) + kinds := make(map[string]map[string]bool) + for _, name := range slices.Sorted(maps.Keys(schemas)) { + module, typ := rustSplitSchemaName(name) + dir := rustModuleDir(module) + + byDir[dir] = append(byDir[dir], schemaInfo{name: name, typ: typ}) + if rustRootOf(schemas[name]) != nil { + if kinds[dir] == nil { + kinds[dir] = make(map[string]bool) + } + kinds[dir][typ] = true + } + } + + groups := make([]rustFileGroup, 0, len(schemas)) + for _, dir := range slices.Sorted(maps.Keys(byDir)) { + owners := make(map[string][]string) + primaries := make(map[string]string) + + for _, s := range byDir[dir] { + owner := rustFileOwner(s.typ, kinds[dir]) + owners[owner] = append(owners[owner], s.name) + if s.typ == owner { + primaries[owner] = s.name + } + } + + for _, owner := range slices.Sorted(maps.Keys(owners)) { + names := owners[owner] + slices.Sort(names) + + // The kind leads its file. A file grouped under a kind whose own + // schema we never saw (a dangling reference) is named after it + // anyway, and its first schema leads. + primary := primaries[owner] + if primary == "" { + primary = names[0] + } else if i := slices.Index(names, primary); i > 0 { + names = slices.Concat([]string{primary}, names[:i], names[i+1:]) + } + + groups = append(groups, rustFileGroup{ + dir: dir, + stem: rustFileStem(owner), + primary: primary, + schemas: names, + }) + } + } + + return groups +} + +// rustFileOwner returns the type whose file typ is emitted into, given the +// kinds its module defines. See rustGroupSchemas for the rules. +func rustFileOwner(typ string, kinds map[string]bool) string { + if kinds[typ] { + if base, ok := strings.CutSuffix(typ, "List"); ok && kinds[base] { + return base + } + return typ + } + + owner := "" + for kind := range kinds { + if len(kind) <= len(owner) || !rustNamePrefix(typ, kind) { + continue + } + owner = kind + } + if owner == "" { + return typ + } + + // The owning kind may itself be emitted with another kind. + return rustFileOwner(owner, kinds) +} + +// rustNamePrefix reports whether name starts with prefix at a word boundary, +// so Pod owns PodSpec but not Podium. +func rustNamePrefix(name, prefix string) bool { + rest, ok := strings.CutPrefix(name, prefix) + if !ok || rest == "" { + return false + } + r, _ := utf8.DecodeRuneInString(rest) + return unicode.IsUpper(r) +} + +// rustFilePath returns the file to write a type to, keeping clear of directory +// names and of files already written for a different schema. +func rustFilePath(dir, stem string, dirs map[string]bool, written map[string]string) string { + base := stem + for n := 2; ; n++ { + file := path.Join(dir, stem+".rs") + if !dirs[path.Join(dir, stem)] && written[file] == "" { + return file + } + stem = base + "_" + strconv.Itoa(n) + } +} + +// rustTypeKind is the kind of Rust item generated for a schema. +type rustTypeKind int + +const ( + // rustStruct is an object schema with properties. + rustStruct rustTypeKind = iota + // rustAlias is a scalar or unstructured schema, e.g. Time or RawExtension. + rustAlias + // rustUntagged is a schema that is a choice of scalars, e.g. Quantity. + rustUntagged +) + +// rustType is one item in a generated file. +type rustType struct { + name string + doc string + kind rustTypeKind + + fields []rustField // rustStruct, sorted by JSON name + alias string // rustAlias + variants []rustVariant // rustUntagged + root *rustRoot // set for types that are a Kubernetes resource +} + +type rustField struct { + name string // Rust identifier + jsonName string + doc string + typ string // already wrapped in Option<...>, or a map for a flattened field + + // flatten marks the field that keeps the properties a schema allows + // without naming them. It is a map serde flattens into the struct. + flatten bool +} + +type rustVariant struct { + name string + typ string +} + +// rustRoot holds the apiVersion and kind a resource's Default impl pre-fills. +type rustRoot struct { + apiVersion string + kind string +} + +// rustFile accumulates the types emitted into a single file: the type generated +// for its component schema, plus one struct per inline object nested in it. +type rustFile struct { + schema string // the component schema being emitted + all map[string]*spec.Schema // every known schema, for $ref resolution + boxed map[rustEdge]bool // references that must be boxed, see rustDetectCycles + + names map[string]bool + types []rustType +} + +// rustEmitFile renders a group of component schemas, and every inline object +// nested in them, into the contents of one Rust file. +func rustEmitFile(group rustFileGroup, all map[string]*spec.Schema, boxed map[rustEdge]bool) []byte { + f := &rustFile{all: all, boxed: boxed, names: make(map[string]bool)} + + // Reserve the component schemas' own names before emitting anything. Other + // files refer to them by an absolute crate path derived from the schema + // name, so a component type must always get the name that path expects; an + // inline object that would collide with one gives way instead. + types := make([]string, len(group.schemas)) + for i, name := range group.schemas { + _, typ := rustSplitSchemaName(name) + types[i] = f.reserve(typ) + } + + for i, name := range group.schemas { + f.schema = name + f.addNamedType(types[i], all[name]) + } + + slices.SortFunc(f.types, func(a, b rustType) int { return strings.Compare(a.name, b.name) }) + + // The kind the file is named after leads it; everything else, including the + // inline structs, follows in name order. + if i := slices.IndexFunc(f.types, func(t rustType) bool { return t.name == types[0] }); i > 0 { + lead := f.types[i] + f.types = slices.Concat([]rustType{lead}, f.types[:i], f.types[i+1:]) + } + + return rustRenderFile(f.types) +} + +// reserve claims a type name for this file. Two inline objects at different +// property paths can produce the same name (a.bC and aB.c both give ABC), so +// the second one to ask gets a numeric suffix. The name is derived from the +// schema, and schemas are walked in sorted order, so which one that is stays +// stable across runs. +func (f *rustFile) reserve(name string) string { + if !f.names[name] { + f.names[name] = true + return name + } + for n := 2; ; n++ { + candidate := name + strconv.Itoa(n) + if !f.names[candidate] { + f.names[candidate] = true + return candidate + } + } +} + +// addNamedType emits the type a component schema is named after. Objects with +// properties become structs; a choice of scalars becomes an untagged enum +// (Quantity, IntOrString); anything else becomes a type alias, which is how +// scalar wrappers (Time) and unstructured types (RawExtension) stay usable. +func (f *rustFile) addNamedType(name string, s *spec.Schema) { + if s == nil { + f.types = append(f.types, rustType{name: name, kind: rustAlias, alias: rustValueType}) + return + } + + switch variants := rustScalarVariants(s); { + case len(s.Properties) > 0: + f.types = append(f.types, f.structType(name, s, 0)) + case len(variants) > 0: + f.types = append(f.types, rustType{ + name: name, + doc: s.Description, + kind: rustUntagged, + variants: variants, + }) + default: + f.types = append(f.types, rustType{ + name: name, + doc: s.Description, + kind: rustAlias, + alias: f.typeFor(name, s, 0, false), + }) + } +} + +// structType builds the struct for an object schema. Every field is optional: +// a function sets only the fields it owns, and observed resources are routinely +// partial, which is the same reasoning behind goRemoveRequired. +func (f *rustFile) structType(name string, s *spec.Schema, depth int) rustType { + t := rustType{name: name, doc: s.Description, kind: rustStruct, root: rustRootOf(s)} + + // Two properties can map to one identifier (mirrorPercent and + // mirror_percent, proxyURL and proxyUrl). Properties are walked in sorted + // order, so which one keeps the plain name is stable across runs, and the + // rename attribute keeps both on the wire under their own names. + idents := make(map[string]bool, len(s.Properties)+1) + + for _, jsonName := range slices.Sorted(maps.Keys(s.Properties)) { + prop := s.Properties[jsonName] + t.fields = append(t.fields, rustField{ + name: rustReserveIdent(idents, rustFieldIdent(jsonName)), + jsonName: jsonName, + doc: rustFieldDoc(&prop), + typ: "Option<" + f.typeFor(rustNestedTypeName(name, jsonName), &prop, depth+1, false) + ">", + }) + } + + if value := f.additionalPropertiesType(name, s, depth); value != "" { + t.fields = append(t.fields, rustField{ + name: rustReserveIdent(idents, rustFieldIdent(rustAdditionalProperties)), + doc: "Properties the schema allows without naming them.", + typ: rustMapType + "<" + rustStringType + ", " + value + ">", + flatten: true, + }) + } + + return t +} + +// additionalPropertiesType returns the value type of the properties an object +// with named properties allows beyond those, or "" if it allows none. Without a +// field for them a model would drop them on the way from observed to desired +// state, which for an object the API server keeps unknown fields of is data the +// function never meant to remove. +func (f *rustFile) additionalPropertiesType(name string, s *spec.Schema, depth int) string { + if ap := s.AdditionalProperties; ap != nil { + if ap.Schema != nil { + return f.typeFor(rustNestedTypeName(name, rustAdditionalProperties), ap.Schema, depth+1, true) + } + if ap.Allows { + return rustValueType + } + } + + if preserve, ok := s.Extensions.GetBool(rustPreserveUnknownFields); ok && preserve { + return rustValueType + } + + return "" +} + +// rustReserveIdent claims an identifier in a struct, giving the second field to +// ask for one a numeric suffix. +func rustReserveIdent(taken map[string]bool, ident string) string { + candidate := ident + for n := 2; taken[candidate]; n++ { + candidate = ident + "_" + strconv.Itoa(n) + } + taken[candidate] = true + return candidate +} + +// typeFor maps a property schema to the Rust type expression for it, adding a +// struct to the file for every inline object it walks through. nested is the +// name to give the struct if this position turns out to be one. indirect says +// whether the position is already behind a Vec or a BTreeMap, in which case a +// reference does not need boxing to have a finite size. +// +// An unknown or unstructured shape maps to serde_json::Value rather than +// failing: a model that deserializes every observed resource is worth more than +// one that describes some of them perfectly. +func (f *rustFile) typeFor(nested string, s *spec.Schema, depth int, indirect bool) string { + if s == nil || depth > rustMaxDepth { + return rustValueType + } + + // A $ref, or the allOf wrapper CRD and Kubernetes schemas use to attach a + // description to one. + if target := rustRefTarget(s.Ref.String()); target != "" { + return f.refType(target, indirect) + } + if len(s.AllOf) == 1 && rustNoOwnStructure(s) { + return f.typeFor(nested, &s.AllOf[0], depth+1, indirect) + } + + switch { + case s.Type.Contains("array"): + if s.Items == nil || s.Items.Schema == nil { + return "Vec<" + rustValueType + ">" + } + return "Vec<" + f.typeFor(rustItemTypeName(nested), s.Items.Schema, depth+1, true) + ">" + + case len(s.Properties) > 0: + name := f.reserve(nested) + t := f.structType(name, s, depth) + f.types = append(f.types, t) + return name + + case s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil: + // BTreeMap rather than HashMap: its serialization is ordered, which + // keeps a function's output stable. + return rustMapType + "<" + rustStringType + ", " + f.typeFor(nested+"Value", s.AdditionalProperties.Schema, depth+1, true) + ">" + + case s.Type.Contains("string"): + // Including format: byte, date-time and password. Keeping them all + // strings is what lets the crate depend on serde alone. + return rustStringType + + case s.Type.Contains("integer"): + if s.Format == "int32" { + return "i32" + } + return "i64" + + case s.Type.Contains("number"): + return "f64" + + case s.Type.Contains("boolean"): + return "bool" + } + + // Objects without properties (x-kubernetes-preserve-unknown-fields, + // embedded resources, RawExtension), inline unions such as + // x-kubernetes-int-or-string, and schemas with no type at all. + return rustValueType +} + +// refType returns the type expression for a reference to another component +// schema. References to schemas we are not generating fall back to +// serde_json::Value, so a partial input still produces a crate that compiles. +func (f *rustFile) refType(target string, indirect bool) string { + if _, ok := f.all[target]; !ok { + return rustValueType + } + + p := rustPathForSchemaName(target) + if !indirect && f.boxed[rustEdge{from: f.schema, to: target}] { + return "Box<" + p + ">" + } + + return p +} + +// rustNoOwnStructure reports whether a schema describes nothing itself, which +// is what makes a single-element allOf a transparent wrapper. +func rustNoOwnStructure(s *spec.Schema) bool { + return len(s.Type) == 0 && len(s.Properties) == 0 && s.Items == nil && s.AdditionalProperties == nil +} + +// rustScalarVariants returns the untagged enum variants for a schema that is a +// choice between scalar types, e.g. Quantity (string or number) and IntOrString. +// It returns nil for anything else, including choices involving objects: serde +// resolves untagged variants by trying them in order, which is only predictable +// for scalars. +func rustScalarVariants(s *spec.Schema) []rustVariant { + variants := s.OneOf + if len(variants) == 0 { + variants = s.AnyOf + } + if len(variants) < 2 { + return nil + } + + out := make([]rustVariant, 0, len(variants)) + seen := make(map[string]bool, len(variants)) + for i := range variants { + v := rustScalarVariant(&variants[i]) + if v.name == "" { + return nil + } + if seen[v.name] { + continue + } + seen[v.name] = true + out = append(out, v) + } + + return out +} + +func rustScalarVariant(s *spec.Schema) rustVariant { + if len(s.Properties) > 0 || s.Ref.String() != "" || len(s.AllOf) > 0 { + return rustVariant{} + } + + switch { + case s.Type.Contains("integer"): + if s.Format == "int32" { + return rustVariant{name: "Int", typ: "i32"} + } + return rustVariant{name: "Int", typ: "i64"} + case s.Type.Contains("number"): + return rustVariant{name: "Number", typ: "f64"} + case s.Type.Contains("string"): + return rustVariant{name: rustStringType, typ: rustStringType} + case s.Type.Contains("boolean"): + return rustVariant{name: "Bool", typ: "bool"} + } + + return rustVariant{} +} + +// rustRootOf returns the apiVersion and kind of a schema that describes a +// Kubernetes resource, or nil if it does not. Both properties carry a default +// for exactly those schemas: crd.ToOpenAPI sets them for the kind a CRD +// defines, and goAddDefaults derives them from the group-version-kind extension +// of a schema served by an API server. +func rustRootOf(s *spec.Schema) *rustRoot { + apiVersion, ok := s.Properties["apiVersion"] + if !ok { + return nil + } + kind, ok := s.Properties["kind"] + if !ok { + return nil + } + + av, ok := apiVersion.Default.(string) + if !ok || av == "" { + return nil + } + k, ok := kind.Default.(string) + if !ok || k == "" { + return nil + } + + return &rustRoot{apiVersion: av, kind: k} +} + +// rustFieldDoc returns the documentation for a field: its description, plus the +// values a string enum accepts. The values are documented rather than turned +// into a Rust enum on purpose - Kubernetes adds enum values in minor releases, +// and a closed enum would fail to deserialize an observed resource the day the +// server starts using a new one. +func rustFieldDoc(s *spec.Schema) string { + doc := s.Description + + var values []string + for _, v := range s.Enum { + if str, ok := v.(string); ok { + values = append(values, "`"+str+"`") + } + } + if len(values) == 0 { + return doc + } + + if doc != "" { + doc += "\n\n" + } + + return doc + "Allowed values: " + strings.Join(values, ", ") + "." +} + +// rustEdge is a reference from one component schema to another. +type rustEdge struct { + from string + to string +} + +// rustDetectCycles returns the references that close a reference cycle, which +// have to be boxed for the generated types to have a finite size. Only +// references a field holds inline count: a Vec or a BTreeMap is already an +// indirection, so a cycle running through one is not a problem. +// +// The known case is JSONSchemaProps, whose items and not properties reference +// it back through a wrapper schema. +func rustDetectCycles(all map[string]*spec.Schema) map[rustEdge]bool { + names := slices.Sorted(maps.Keys(all)) + + edges := make(map[string][]string, len(all)) + for _, name := range names { + edges[name] = rustDirectRefs(all[name], all) + } + + const ( + open = 1 + done = 2 + ) + + boxed := make(map[rustEdge]bool) + state := make(map[string]int, len(all)) + + // Every cycle contains at least one edge back to a node the walk is still + // inside, so boxing those breaks all of them. + var visit func(string) + visit = func(name string) { + state[name] = open + for _, to := range edges[name] { + switch state[to] { + case open: + boxed[rustEdge{from: name, to: to}] = true + case done: + default: + visit(to) + } + } + state[name] = done + } + + for _, name := range names { + if state[name] == 0 { + visit(name) + } + } + + return boxed +} + +// rustDirectRefs returns the component schemas a schema holds inline, itself or +// through one of its inline objects. +func rustDirectRefs(s *spec.Schema, all map[string]*spec.Schema) []string { + found := make(map[string]bool) + rustCollectDirectRefs(s, all, found, 0) + return slices.Sorted(maps.Keys(found)) +} + +func rustCollectDirectRefs(s *spec.Schema, all map[string]*spec.Schema, found map[string]bool, depth int) { + if s == nil || depth > rustMaxDepth { + return + } + + if target := rustDirectRefTarget(s); target != "" { + if _, ok := all[target]; ok { + found[target] = true + } + return + } + + // Arrays put their elements behind a Vec, and additionalProperties puts its + // values behind a BTreeMap. Both are indirections already. + if s.Type.Contains("array") || len(s.Properties) == 0 { + return + } + + for _, name := range slices.Sorted(maps.Keys(s.Properties)) { + prop := s.Properties[name] + rustCollectDirectRefs(&prop, all, found, depth+1) + } + for i := range s.AllOf { + rustCollectDirectRefs(&s.AllOf[i], all, found, depth+1) + } +} + +// rustDirectRefTarget returns the component schema a schema references inline, +// whether directly or through a single-element allOf wrapper. +func rustDirectRefTarget(s *spec.Schema) string { + if target := rustRefTarget(s.Ref.String()); target != "" { + return target + } + if len(s.AllOf) == 1 && rustNoOwnStructure(s) { + return rustDirectRefTarget(&s.AllOf[0]) + } + return "" +} + +// rustRenderFile renders the types of one file. Everything is emitted fully +// qualified and one attribute per line, because there is no rustfmt in the CLI +// to tidy up after us. +func rustRenderFile(types []rustType) []byte { + var sb strings.Builder + sb.WriteString(rustGeneratedHeader + "\n") + + for _, t := range types { + sb.WriteString("\n") + switch t.kind { + case rustAlias: + rustWriteDoc(&sb, "", t.doc) + sb.WriteString("pub type " + t.name + " = " + t.alias + ";\n") + + case rustUntagged: + rustWriteDoc(&sb, "", t.doc) + sb.WriteString("#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]\n") + sb.WriteString("#[serde(untagged)]\n") + sb.WriteString("pub enum " + t.name + " {\n") + for _, v := range t.variants { + sb.WriteString(" " + v.name + "(" + v.typ + "),\n") + } + sb.WriteString("}\n") + + case rustStruct: + rustWriteStruct(&sb, t) + } + } + + return []byte(sb.String()) +} + +func rustWriteStruct(sb *strings.Builder, t rustType) { + rustWriteDoc(sb, "", t.doc) + + // A resource pre-fills its apiVersion and kind in a hand-written Default + // impl, so it must not derive one. Eq is left out throughout: a schema with + // a number field maps to f64. + derives := "#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]\n" + if t.root != nil { + derives = "#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]\n" + } + sb.WriteString(derives) + + sb.WriteString("pub struct " + t.name + " {\n") + for i, f := range t.fields { + if i > 0 { + sb.WriteString("\n") + } + rustWriteDoc(sb, " ", f.doc) + if f.flatten { + sb.WriteString(" #[serde(flatten, default, skip_serializing_if = \"" + rustMapType + "::is_empty\")]\n") + } else { + sb.WriteString(" #[serde(rename = " + strconv.Quote(f.jsonName) + ", default, skip_serializing_if = \"Option::is_none\")]\n") + } + sb.WriteString(" pub " + f.name + ": " + f.typ + ",\n") + } + sb.WriteString("}\n") + + if t.root == nil { + return + } + + // The consts and the Default impl are what let a function build a resource + // without naming its group and version: a model carries its own + // apiVersion and kind, so no trait shared with an SDK has to stamp them. + sb.WriteString("\nimpl " + t.name + " {\n") + sb.WriteString(" /// The apiVersion of this resource.\n") + sb.WriteString(" pub const API_VERSION: &'static str = " + strconv.Quote(t.root.apiVersion) + ";\n") + sb.WriteString("\n") + sb.WriteString(" /// The kind of this resource.\n") + sb.WriteString(" pub const KIND: &'static str = " + strconv.Quote(t.root.kind) + ";\n") + sb.WriteString("}\n") + + sb.WriteString("\nimpl Default for " + t.name + " {\n") + sb.WriteString(" fn default() -> Self {\n") + sb.WriteString(" Self {\n") + for _, f := range t.fields { + switch { + case f.flatten: + sb.WriteString(" " + f.name + ": " + rustMapType + "::new(),\n") + case f.jsonName == "apiVersion": + sb.WriteString(" " + f.name + ": Some(Self::API_VERSION.to_string()),\n") + case f.jsonName == "kind": + sb.WriteString(" " + f.name + ": Some(Self::KIND.to_string()),\n") + default: + sb.WriteString(" " + f.name + ": None,\n") + } + } + sb.WriteString(" }\n") + sb.WriteString(" }\n") + sb.WriteString("}\n") +} + +// rustWriteDoc renders a schema description as doc comments. Descriptions are +// arbitrary prose, so they go in verbatim; the crate root allows the rustdoc +// lints they trip and the manifest turns doctests off. +func rustWriteDoc(sb *strings.Builder, indent, doc string) { + // A carriage return ends a line, with or without a line feed after it, as + // it does in the Go models. Rust rejects a bare one in a doc comment. + doc = strings.ReplaceAll(strings.ReplaceAll(doc, "\r\n", "\n"), "\r", "\n") + + // Descriptions routinely end in a newline, which would leave the comment + // with a blank line between it and what it documents. + doc = strings.TrimSpace(doc) + if doc == "" { + return + } + + for line := range strings.SplitSeq(doc, "\n") { + line = strings.TrimRight(line, " \t") + if line == "" { + sb.WriteString(indent + "///\n") + continue + } + sb.WriteString(indent + "/// " + line + "\n") + } +} + +// BuildRustModuleTree rewrites everything about the generated Rust crate rooted +// at crateFS that depends on which models it holds: the module declarations in +// src/lib.rs and in one mod.rs per directory, and the manifest, whose features +// gate those modules. +// +// The schema manager copies the output of every source into one crate, and a +// generator run only ever sees its own source, so none of this can be written +// while emitting. The manager calls this again once it has merged everything, +// the same way it rebuilds the JSON index schema. +func BuildRustModuleTree(crateFS afero.Fs) error { + ok, err := afero.DirExists(crateFS, rustSrcDir) + if err != nil { + return errors.Wrap(err, "failed to stat the generated Rust crate's source directory") + } + if !ok { + return nil + } + + features, err := rustCollectFeatures(crateFS) + if err != nil { + return err + } + + if err := afero.WriteFile(crateFS, "Cargo.toml", []byte(rustRenderCargoToml(features)), 0o644); err != nil { + return errors.Wrap(err, "failed to write Cargo.toml") + } + + return rustBuildModule(crateFS, rustSrcDir, features) +} + +func rustBuildModule(crateFS afero.Fs, dir string, features map[string]rustFeature) error { + entries, err := afero.ReadDir(crateFS, dir) + if err != nil { + return errors.Wrapf(err, "failed to read directory %q", dir) + } + + var modules, files []string + for _, e := range entries { + if e.IsDir() { + modules = append(modules, e.Name()) + continue + } + stem, ok := strings.CutSuffix(e.Name(), ".rs") + if !ok || e.Name() == "mod.rs" || e.Name() == "lib.rs" { + continue + } + files = append(files, stem) + } + slices.Sort(modules) + slices.Sort(files) + + for _, m := range modules { + if err := rustBuildModule(crateFS, path.Join(dir, m), features); err != nil { + return err + } + } + + root := dir == rustSrcDir + + var sb strings.Builder + sb.WriteString(rustGeneratedHeader + "\n") + if root { + sb.WriteString(rustCrateAttributes) + } + if len(modules) > 0 || len(files) > 0 { + sb.WriteString("\n") + } + for _, m := range modules { + // Gating the module, rather than the types in it, is what lets rustc + // tell a function that imports from it which feature to enable. + if f, ok := features[path.Join(dir, m)]; ok { + sb.WriteString("#[cfg(feature = \"" + f.name + "\")]\n") + } + sb.WriteString("pub mod " + m + ";\n") + } + if len(modules) > 0 && len(files) > 0 { + sb.WriteString("\n") + } + // Types are re-exported by the module their schema names, so a file name + // never appears in a use declaration. + for _, f := range files { + sb.WriteString("mod " + f + ";\n") + sb.WriteString("pub use " + f + "::*;\n") + } + + name := "mod.rs" + if root { + name = "lib.rs" + } + + return afero.WriteFile(crateFS, path.Join(dir, name), []byte(sb.String()), 0o644) +} diff --git a/internal/schemas/generator/rust_features.go b/internal/schemas/generator/rust_features.go new file mode 100644 index 00000000..32902a18 --- /dev/null +++ b/internal/schemas/generator/rust_features.go @@ -0,0 +1,178 @@ +/* +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 ( + "io/fs" + "maps" + "path" + "regexp" + "slices" + "strings" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +const ( + // rustDefaultFeature is the feature Cargo enables for a dependent that + // does not say otherwise. + rustDefaultFeature = "default" + + // rustAllFeature enables every module of models. It is the default, so a + // function that does not mention features compiles all of them. + rustAllFeature = "all" + + // rustFeaturesHeader opens the features table of the generated manifest. + rustFeaturesHeader = ` +# One feature per module of models. A function that imports a few API groups +# from a crate holding many can compile just those: depend on this crate with +# default-features = false and list their features. A feature enables the +# features of the modules its models refer to, so only the modules a function +# imports from need listing. +[features] +` +) + +// rustCratePathRE matches a reference to another generated type. The emitter +// writes every such reference as an absolute path, see rustPathForSchemaName. +var rustCratePathRE = regexp.MustCompile(`\bcrate::((?:\w+::)+)\w+`) + +// rustFeature is the Cargo feature that gates one module of generated models. +type rustFeature struct { + name string + deps []string // the features of the modules this module's models refer to +} + +// rustFeatureName returns the name of the feature gating the module in dir: +// its path below src, joined by dashes. Module names hold no dashes, see +// rustModuleSegment, so two modules never share a feature name. +func rustFeatureName(dir string) string { + return strings.ReplaceAll(strings.TrimPrefix(dir, rustSrcDir+"/"), "/", "-") +} + +// rustCollectFeatures returns the feature of every module holding models, keyed +// by the module's directory. +// +// A module holds models if its directory holds a file of types, which makes it +// the module of an API group and version, or of a Kubernetes package. The +// modules above it only declare other modules, cost nothing to compile and are +// not gated. Models directly in src have no module to gate, so they are always +// compiled. +func rustCollectFeatures(crateFS afero.Fs) (map[string]rustFeature, error) { + files := make(map[string][]string) + err := afero.Walk(crateFS, rustSrcDir, func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !rustIsTypeFile(info.Name()) { + return nil + } + if dir := path.Dir(p); dir != rustSrcDir { + files[dir] = append(files[dir], p) + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "failed to walk the generated Rust crate") + } + + features := make(map[string]rustFeature, len(files)) + for dir, paths := range files { + deps := make(map[string]bool) + + // A module is declared by the module above it, so it is only reachable + // when every gated module on the way down to it is compiled too. + for parent := path.Dir(dir); parent != rustSrcDir && parent != "."; parent = path.Dir(parent) { + if _, ok := files[parent]; ok { + deps[rustFeatureName(parent)] = true + } + } + + for _, p := range paths { + contents, err := afero.ReadFile(crateFS, p) + if err != nil { + return nil, errors.Wrapf(err, "failed to read %q", p) + } + for _, target := range rustReferencedModules(string(contents)) { + if _, ok := files[target]; ok && target != dir { + deps[rustFeatureName(target)] = true + } + } + } + + features[dir] = rustFeature{name: rustFeatureName(dir), deps: slices.Sorted(maps.Keys(deps))} + } + + return features, nil +} + +// rustReferencedModules returns the directories of the modules a generated +// file's code refers to. Doc comments are schema descriptions, which can hold +// anything, so they are skipped. +func rustReferencedModules(code string) []string { + var dirs []string + for line := range strings.SplitSeq(code, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + for _, m := range rustCratePathRE.FindAllStringSubmatch(line, -1) { + module := strings.Split(strings.TrimSuffix(m[1], "::"), "::") + dirs = append(dirs, rustModuleDir(module)) + } + } + return dirs +} + +// rustIsTypeFile reports whether a file in the crate's source tree holds +// generated types, as opposed to declaring modules. +func rustIsTypeFile(name string) bool { + return strings.HasSuffix(name, ".rs") && name != "mod.rs" && name != "lib.rs" +} + +// rustRenderCargoToml renders the crate manifest for the given features. +func rustRenderCargoToml(features map[string]rustFeature) string { + if len(features) == 0 { + return rustCargoToml + } + + sorted := slices.SortedFunc(maps.Values(features), func(a, b rustFeature) int { + return strings.Compare(a.name, b.name) + }) + + var sb strings.Builder + sb.WriteString(rustCargoToml) + sb.WriteString(rustFeaturesHeader) + sb.WriteString(rustDefaultFeature + " = [\"" + rustAllFeature + "\"]\n") + + sb.WriteString(rustAllFeature + " = [\n") + for _, f := range sorted { + sb.WriteString(" \"" + f.name + "\",\n") + } + sb.WriteString("]\n") + + for _, f := range sorted { + quoted := make([]string, len(f.deps)) + for i, dep := range f.deps { + quoted[i] = "\"" + dep + "\"" + } + sb.WriteString(f.name + " = [" + strings.Join(quoted, ", ") + "]\n") + } + + return sb.String() +} diff --git a/internal/schemas/generator/rust_naming.go b/internal/schemas/generator/rust_naming.go new file mode 100644 index 00000000..7a3de163 --- /dev/null +++ b/internal/schemas/generator/rust_naming.go @@ -0,0 +1,324 @@ +/* +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 ( + "strings" + "unicode" + + "github.com/gobuffalo/flect" +) + +// Rust types the emitter refers to. Everything outside the prelude is written +// fully qualified, so generated files need no use declarations. +const ( + rustValueType = "serde_json::Value" + rustMapType = "std::collections::BTreeMap" + rustStringType = "String" + + // rustUnnamed stands in for a field or module name with no letter or digit + // in it. An underscore alone is not an identifier Rust accepts there. + rustUnnamed = "unnamed" +) + +// rustKeywords maps every Rust keyword and reserved word to whether it is +// valid as a raw identifier (r#name). crate, self, Self and super are not, so +// they get a trailing underscore instead. +// +//nolint:gochecknoglobals // Effectively a constant; Go has no constant maps. +var rustKeywords = map[string]bool{ + // Strict keywords. + "as": true, "async": true, "await": true, "break": true, "const": true, + "continue": true, "dyn": true, "else": true, "enum": true, "extern": true, + "false": true, "fn": true, "for": true, "if": true, "impl": true, + "in": true, "let": true, "loop": true, "match": true, "mod": true, + "move": true, "mut": true, "pub": true, "ref": true, "return": true, + "static": true, "struct": true, "trait": true, "true": true, "type": true, + "unsafe": true, "use": true, "where": true, "while": true, + // Strict keywords that cannot be raw identifiers. + "crate": false, "self": false, "Self": false, "super": false, + // Reserved for future use. + "abstract": true, "become": true, "box": true, "do": true, "final": true, + "gen": true, "macro": true, "override": true, "priv": true, "try": true, + "typeof": true, "unsized": true, "virtual": true, "yield": true, +} + +// rustPreludeTypes are type names the generated code uses unqualified. A +// generated type of the same name would shadow them, so it gets a trailing +// underscore. +// +//nolint:gochecknoglobals // Effectively a constant; Go has no constant maps. +var rustPreludeTypes = map[string]bool{ + "Box": true, "Option": true, "String": true, "Vec": true, +} + +// rustSplitWords splits an identifier into its words. Boundaries are: +// +// - any character that is not a letter or a digit (dropped); +// - a lowercase letter or digit followed by an uppercase letter, so +// "apiVersion" is "api" "Version" and "sha256Sum" is "sha256" "Sum"; +// - inside a run of uppercase letters, the position before the last one when +// it is followed by at least two lowercase letters, so "HTTPServer" is +// "HTTP" "Server" and "VMSize" is "VM" "Size". +// +// The two-lowercase-letter requirement in the last rule is what keeps plural +// acronyms and version-like names in one piece: "podCIDRs" stays "pod" "CIDRs" +// and "IPv6" stays one word, where heck would split them. Digits never start or +// end a word on their own, so "v1alpha1" is a single word. +func rustSplitWords(s string) []string { + rs := []rune(s) + words := make([]string, 0, 4) + cur := make([]rune, 0, len(rs)) + + flush := func() { + if len(cur) > 0 { + words = append(words, string(cur)) + cur = cur[:0] + } + } + + for i, r := range rs { + if !isRustWordRune(r) { + flush() + continue + } + if len(cur) > 0 { + prev := cur[len(cur)-1] + switch { + case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)): + flush() + case unicode.IsUpper(r) && unicode.IsUpper(prev) && rustStartsWord(rs[i+1:]): + flush() + } + } + cur = append(cur, r) + } + flush() + + return words +} + +func isRustWordRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +// rustStartsWord reports whether rs begins with at least two lowercase letters, +// which is what makes the preceding uppercase letter the start of a new word. +func rustStartsWord(rs []rune) bool { + n := 0 + for _, r := range rs { + if !unicode.IsLower(r) { + break + } + if n++; n == 2 { + return true + } + } + return false +} + +// rustSnakeCase renders s in snake_case, without identifier sanitization. +func rustSnakeCase(s string) string { + words := rustSplitWords(s) + for i, w := range words { + words[i] = strings.ToLower(w) + } + return strings.Join(words, "_") +} + +// rustUpperCamelCase renders s in UpperCamelCase, without identifier +// sanitization. +func rustUpperCamelCase(s string) string { + var b strings.Builder + for _, w := range rustSplitWords(s) { + rs := []rune(strings.ToLower(w)) + rs[0] = unicode.ToUpper(rs[0]) + b.WriteString(string(rs)) + } + return b.String() +} + +// rustFieldIdent turns a JSON property name into a Rust field identifier. The +// JSON name is always preserved in a serde rename attribute, so the identifier +// only has to be valid, not reversible. +func rustFieldIdent(jsonName string) string { + name := rustSnakeCase(jsonName) + if name == "" { + return rustUnnamed + } + if unicode.IsDigit(rune(name[0])) { + name = "_" + name + } + if raw, ok := rustKeywords[name]; ok { + if raw { + return "r#" + name + } + return name + "_" + } + return name +} + +// rustTypeName sanitizes s into a Rust type identifier. Unlike field and module +// names it keeps the input's spelling, so Kubernetes kinds stay recognizable +// (JSONSchemaProps does not become JsonSchemaProps). It returns "" when s has +// nothing to make a name of, and such a schema is not generated at all. +func rustTypeName(s string) string { + var b strings.Builder + for _, r := range s { + if r == '_' || isRustWordRune(r) { + b.WriteRune(r) + } + } + + name := b.String() + if strings.Trim(name, "_") == "" { + return "" + } + + rs := []rune(name) + if unicode.IsDigit(rs[0]) { + name = "_" + name + } else { + rs[0] = unicode.ToUpper(rs[0]) + name = string(rs) + } + + // Type names are UpperCamelCase, so the only keyword they can collide with + // is Self. Raw identifiers would work here but read badly in generated + // code, hence the underscore. + if _, ok := rustKeywords[name]; ok || rustPreludeTypes[name] { + name += "_" + } + + return name +} + +// rustNestedTypeName returns the name for the struct generated from an inline +// object at property field of the type named owner. +func rustNestedTypeName(owner, field string) string { + return owner + rustUpperCamelCase(field) +} + +// rustItemTypeName returns the name for the struct generated from the items of +// an array whose own type name would be nested. It singularizes the last word, +// so an inline object under "resourceRefs" becomes ResourceRef. Names +// whose last word has no distinct singular form get an Item suffix instead. +func rustItemTypeName(nested string) string { + words := rustSplitWords(nested) + if len(words) == 0 { + return nested + "Item" + } + + last := words[len(words)-1] + singular := flect.Singularize(last) + if strings.EqualFold(singular, last) || !strings.HasSuffix(nested, last) { + return nested + "Item" + } + + // Replace only the last word, so the rest of the name keeps the spelling it + // inherited from its parent type. + return strings.TrimSuffix(nested, last) + rustUpperFirst(singular) +} + +// rustUpperFirst uppercases the first rune of s, leaving the rest alone. +func rustUpperFirst(s string) string { + if s == "" { + return s + } + rs := []rune(s) + rs[0] = unicode.ToUpper(rs[0]) + return string(rs) +} + +// rustModuleSegment sanitizes one segment of a component schema name into a +// Rust module identifier. The result is also the directory name on disk, so +// every segment we emit is a valid identifier by construction and the module +// tree can be rebuilt from a directory listing. +func rustModuleSegment(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + + name := b.String() + if strings.Trim(name, "_") == "" { + return rustUnnamed + } + if name[0] >= '0' && name[0] <= '9' { + name = "_" + name + } + // Raw identifiers are not allowed for r#crate, r#self and r#super, so + // module names take an underscore for every keyword. + if _, ok := rustKeywords[name]; ok { + name += "_" + } + + return name +} + +// rustSplitSchemaName splits a component schema name into the module path and +// type name it maps to. Component schema names are reversed dotted group names +// for CRDs (co.acme.platform.v1alpha1.XAccountScaffold) and canonical package +// paths for Kubernetes types (io.k8s.api.core.v1.Pod), so all a segment needs +// is sanitization. typ is "" for a name that ends in nothing usable, which is +// what a CRD without spec.names.listKind gives its list schema. +func rustSplitSchemaName(name string) (module []string, typ string) { + segments := strings.Split(name, ".") + typ = rustTypeName(segments[len(segments)-1]) + + module = make([]string, 0, len(segments)-1) + for _, s := range segments[:len(segments)-1] { + module = append(module, rustModuleSegment(s)) + } + + return module, typ +} + +// rustPathForSchemaName returns the absolute path of the type generated for a +// component schema, e.g. +// crate::io::k8s::apimachinery::pkg::apis::meta::v1::ObjectMeta. +func rustPathForSchemaName(name string) string { + module, typ := rustSplitSchemaName(name) + return "crate::" + strings.Join(append(module, typ), "::") +} + +// rustFileStem returns the file name, without the .rs extension, holding the +// type named typ. Files are an implementation detail (every type is re-exported +// by its module), so the stem is just the lowercased type name, kept a valid +// identifier because mod.rs declares it. +func rustFileStem(typ string) string { + stem := strings.ToLower(typ) + if _, ok := rustKeywords[stem]; ok || stem == "lib" { + stem += "_" + } + return stem +} + +// rustRefTarget returns the component schema name a $ref points at, or "" if it +// points anywhere else. +func rustRefTarget(ref string) string { + target, ok := strings.CutPrefix(ref, "#/components/schemas/") + if !ok { + return "" + } + return target +} diff --git a/internal/schemas/generator/rust_naming_test.go b/internal/schemas/generator/rust_naming_test.go new file mode 100644 index 00000000..72774e08 --- /dev/null +++ b/internal/schemas/generator/rust_naming_test.go @@ -0,0 +1,339 @@ +/* +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 ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestRustCasing covers the casing algorithm and the identifier sanitization +// built on it. The Kubernetes API is full of names that trip naive casing +// (acronyms, plural acronyms, digits, Rust keywords), so every case here is a +// name a real CRD or built-in type uses. +func TestRustCasing(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + args struct{ in string } + want struct{ snake, camel, field, typ string } + }{ + "CamelCase": { + args: struct{ in string }{in: "apiVersion"}, + want: struct{ snake, camel, field, typ string }{ + snake: "api_version", camel: "ApiVersion", field: "api_version", typ: "ApiVersion", + }, + }, + "CrossplaneField": { + args: struct{ in string }{in: "writeConnectionSecretToRef"}, + want: struct{ snake, camel, field, typ string }{ + snake: "write_connection_secret_to_ref", camel: "WriteConnectionSecretToRef", + field: "write_connection_secret_to_ref", typ: "WriteConnectionSecretToRef", + }, + }, + "PluralAcronymStaysOneWord": { + args: struct{ in string }{in: "podCIDRs"}, + want: struct{ snake, camel, field, typ string }{ + snake: "pod_cidrs", camel: "PodCidrs", field: "pod_cidrs", typ: "PodCIDRs", + }, + }, + "AcronymBeforeWord": { + args: struct{ in string }{in: "HTTPServer"}, + want: struct{ snake, camel, field, typ string }{ + snake: "http_server", camel: "HttpServer", field: "http_server", typ: "HTTPServer", + }, + }, + "ShortAcronymBeforeWord": { + args: struct{ in string }{in: "VMSize"}, + want: struct{ snake, camel, field, typ string }{ + snake: "vm_size", camel: "VmSize", field: "vm_size", typ: "VMSize", + }, + }, + "AcronymOnly": { + args: struct{ in string }{in: "TTL"}, + want: struct{ snake, camel, field, typ string }{ + snake: "ttl", camel: "Ttl", field: "ttl", typ: "TTL", + }, + }, + "DigitInsideWord": { + args: struct{ in string }{in: "IPv6"}, + want: struct{ snake, camel, field, typ string }{ + snake: "ipv6", camel: "Ipv6", field: "ipv6", typ: "IPv6", + }, + }, + "DigitBeforeUpper": { + args: struct{ in string }{in: "sha256Sum"}, + want: struct{ snake, camel, field, typ string }{ + snake: "sha256_sum", camel: "Sha256Sum", field: "sha256_sum", typ: "Sha256Sum", + }, + }, + "APIVersionSegment": { + args: struct{ in string }{in: "v1alpha1"}, + want: struct{ snake, camel, field, typ string }{ + snake: "v1alpha1", camel: "V1alpha1", field: "v1alpha1", typ: "V1alpha1", + }, + }, + "DashedExtension": { + args: struct{ in string }{in: "x-kubernetes-foo"}, + want: struct{ snake, camel, field, typ string }{ + snake: "x_kubernetes_foo", camel: "XKubernetesFoo", field: "x_kubernetes_foo", typ: "Xkubernetesfoo", + }, + }, + "AlreadyPascal": { + args: struct{ in string }{in: "DeviceAttribute"}, + want: struct{ snake, camel, field, typ string }{ + snake: "device_attribute", camel: "DeviceAttribute", field: "device_attribute", typ: "DeviceAttribute", + }, + }, + "KubernetesKindWithAcronym": { + args: struct{ in string }{in: "JSONSchemaProps"}, + want: struct{ snake, camel, field, typ string }{ + snake: "json_schema_props", camel: "JsonSchemaProps", field: "json_schema_props", typ: "JSONSchemaProps", + }, + }, + // int, bool and string are legal Rust field names; they are only + // reserved as type names. The DRA API has fields named exactly this. + "PrimitiveNameInt": { + args: struct{ in string }{in: "int"}, + want: struct{ snake, camel, field, typ string }{ + snake: "int", camel: "Int", field: "int", typ: "Int", + }, + }, + "PrimitiveNameBool": { + args: struct{ in string }{in: "bool"}, + want: struct{ snake, camel, field, typ string }{ + snake: "bool", camel: "Bool", field: "bool", typ: "Bool", + }, + }, + "PreludeTypeName": { + args: struct{ in string }{in: "string"}, + want: struct{ snake, camel, field, typ string }{ + snake: "string", camel: "String", field: "string", typ: "String_", + }, + }, + "KeywordAsRawIdent": { + args: struct{ in string }{in: "type"}, + want: struct{ snake, camel, field, typ string }{ + snake: "type", camel: "Type", field: "r#type", typ: "Type", + }, + }, + "KeywordRef": { + args: struct{ in string }{in: "ref"}, + want: struct{ snake, camel, field, typ string }{ + snake: "ref", camel: "Ref", field: "r#ref", typ: "Ref", + }, + }, + "KeywordMatch": { + args: struct{ in string }{in: "match"}, + want: struct{ snake, camel, field, typ string }{ + snake: "match", camel: "Match", field: "r#match", typ: "Match", + }, + }, + // gen is only reserved from the 2024 edition on, which is the edition + // the generated crate declares. + "KeywordOfEdition2024": { + args: struct{ in string }{in: "gen"}, + want: struct{ snake, camel, field, typ string }{ + snake: "gen", camel: "Gen", field: "r#gen", typ: "Gen", + }, + }, + // self, crate, Self and super are the keywords raw identifiers cannot + // express, so they take a trailing underscore. + "KeywordWithoutRawForm": { + args: struct{ in string }{in: "self"}, + want: struct{ snake, camel, field, typ string }{ + snake: "self", camel: "Self", field: "self_", typ: "Self_", + }, + }, + "KeywordCrate": { + args: struct{ in string }{in: "crate"}, + want: struct{ snake, camel, field, typ string }{ + snake: "crate", camel: "Crate", field: "crate_", typ: "Crate", + }, + }, + "LeadingDigit": { + args: struct{ in string }{in: "1abc"}, + want: struct{ snake, camel, field, typ string }{ + snake: "1abc", camel: "1abc", field: "_1abc", typ: "_1abc", + }, + }, + "NoLetterOrDigit": { + args: struct{ in string }{in: "-"}, + want: struct{ snake, camel, field, typ string }{ + snake: "", camel: "", field: "unnamed", typ: "", + }, + }, + "Empty": { + args: struct{ in string }{in: ""}, + want: struct{ snake, camel, field, typ string }{ + snake: "", camel: "", field: "unnamed", typ: "", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := struct{ snake, camel, field, typ string }{ + snake: rustSnakeCase(tc.args.in), + camel: rustUpperCamelCase(tc.args.in), + field: rustFieldIdent(tc.args.in), + typ: rustTypeName(tc.args.in), + } + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("casing of %q: -want, +got:\n%s", tc.args.in, diff) + } + }) + } +} + +func TestRustModuleSegment(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + args struct{ in string } + want struct{ segment string } + }{ + "Simple": {args: struct{ in string }{in: "platform"}, want: struct{ segment string }{segment: "platform"}}, + "Version": {args: struct{ in string }{in: "v1alpha1"}, want: struct{ segment string }{segment: "v1alpha1"}}, + "MixedCase": {args: struct{ in string }{in: "apiMachinery"}, want: struct{ segment string }{segment: "apimachinery"}}, + "Dashed": {args: struct{ in string }{in: "apiextensions-apiserver"}, want: struct{ segment string }{segment: "apiextensions_apiserver"}}, + "NonIdentifier": {args: struct{ in string }{in: "a+b"}, want: struct{ segment string }{segment: "a_b"}}, + "LeadingDigit": {args: struct{ in string }{in: "2fa"}, want: struct{ segment string }{segment: "_2fa"}}, + "Keyword": {args: struct{ in string }{in: "type"}, want: struct{ segment string }{segment: "type_"}}, + "KeywordNoRaw": {args: struct{ in string }{in: "crate"}, want: struct{ segment string }{segment: "crate_"}}, + "Empty": {args: struct{ in string }{in: ""}, want: struct{ segment string }{segment: "unnamed"}}, + "AllNonAlphanum": {args: struct{ in string }{in: "--"}, want: struct{ segment string }{segment: "unnamed"}}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := struct{ segment string }{segment: rustModuleSegment(tc.args.in)} + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("rustModuleSegment(%q): -want, +got:\n%s", tc.args.in, diff) + } + }) + } +} + +func TestRustSchemaNames(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + args struct{ schema string } + want struct{ path, stem string } + }{ + "XRD": { + args: struct{ schema string }{schema: "co.acme.platform.v1alpha1.XAccountScaffold"}, + want: struct{ path, stem string }{ + path: "crate::co::acme::platform::v1alpha1::XAccountScaffold", + stem: "xaccountscaffold", + }, + }, + "ProviderCRD": { + args: struct{ schema string }{schema: "io.upbound.aws.s3.v1beta2.Bucket"}, + want: struct{ path, stem string }{ + path: "crate::io::upbound::aws::s3::v1beta2::Bucket", + stem: "bucket", + }, + }, + "KubernetesMeta": { + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}, + want: struct{ path, stem string }{ + path: "crate::io::k8s::apimachinery::pkg::apis::meta::v1::ObjectMeta", + stem: "objectmeta", + }, + }, + "DashedPackage": { + args: struct{ schema string }{schema: "io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1.JSONSchemaProps"}, + want: struct{ path, stem string }{ + path: "crate::io::k8s::apiextensions_apiserver::pkg::apis::apiextensions::v1::JSONSchemaProps", + stem: "jsonschemaprops", + }, + }, + "NoPackage": { + args: struct{ schema string }{schema: "Widget"}, + want: struct{ path, stem string }{path: "crate::Widget", stem: "widget"}, + }, + // A kind named Type would give a file whose mod declaration is a + // keyword, so the stem takes an underscore. + "KeywordStem": { + args: struct{ schema string }{schema: "com.example.v1.Type"}, + want: struct{ path, stem string }{path: "crate::com::example::v1::Type", stem: "type_"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, typ := rustSplitSchemaName(tc.args.schema) + got := struct{ path, stem string }{ + path: rustPathForSchemaName(tc.args.schema), + stem: rustFileStem(typ), + } + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("names for %q: -want, +got:\n%s", tc.args.schema, diff) + } + }) + } +} + +func TestRustItemTypeName(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + args struct{ nested string } + want struct{ item string } + }{ + "Plural": { + args: struct{ nested string }{nested: "XAccountScaffoldStatusConditions"}, + want: struct{ item string }{item: "XAccountScaffoldStatusCondition"}, + }, + "PluralAbbreviation": { + args: struct{ nested string }{nested: "XAccountScaffoldSpecResourceRefs"}, + want: struct{ item string }{item: "XAccountScaffoldSpecResourceRef"}, + }, + "KeepsParentSpelling": { + args: struct{ nested string }{nested: "JSONSchemaPropsEnums"}, + want: struct{ item string }{item: "JSONSchemaPropsEnum"}, + }, + "AlreadySingular": { + args: struct{ nested string }{nested: "BucketSpecForProvider"}, + want: struct{ item string }{item: "BucketSpecForProviderItem"}, + }, + "Unpluralizable": { + args: struct{ nested string }{nested: "PodSpecStatus"}, + want: struct{ item string }{item: "PodSpecStatusItem"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := struct{ item string }{item: rustItemTypeName(tc.args.nested)} + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("rustItemTypeName(%q): -want, +got:\n%s", tc.args.nested, diff) + } + }) + } +} diff --git a/internal/schemas/generator/rust_test.go b/internal/schemas/generator/rust_test.go new file mode 100644 index 00000000..2d9ac806 --- /dev/null +++ b/internal/schemas/generator/rust_test.go @@ -0,0 +1,1887 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "context" + "io/fs" + "maps" + "path" + "regexp" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +// Helpers for building the small hand-written schemas the emitter tests use. +// Real CRD and Kubernetes documents are covered by the testdata tests below; +// these give the emitter cases with no incidental detail. + +func rustTestSchema(t spec.Schema, description string) spec.Schema { + t.Description = description + return t +} + +func rustTestScalar(typ, format string) spec.Schema { + return spec.Schema{SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{typ}, Format: format}} +} + +func rustTestObject(props map[string]spec.Schema) spec.Schema { + return spec.Schema{SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}, Properties: props}} +} + +func rustTestArray(items spec.Schema) spec.Schema { + return spec.Schema{SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"array"}, + Items: &spec.SchemaOrArray{Schema: &items}, + }} +} + +func rustTestMap(values spec.Schema) spec.Schema { + return spec.Schema{SchemaProps: spec.SchemaProps{ + Type: spec.StringOrArray{"object"}, + AdditionalProperties: &spec.SchemaOrBool{Allows: true, Schema: &values}, + }} +} + +// rustTestRef builds the allOf-wrapped reference CRD and Kubernetes documents +// use, which is also the shape a plain $ref has to survive. +func rustTestRef(schema string) spec.Schema { + return spec.Schema{SchemaProps: spec.SchemaProps{ + AllOf: []spec.Schema{{SchemaProps: spec.SchemaProps{Ref: spec.MustCreateRef("#/components/schemas/" + schema)}}}, + }} +} + +func rustTestEnum(values ...string) spec.Schema { + s := rustTestScalar("string", "") + for _, v := range values { + s.Enum = append(s.Enum, v) + } + return s +} + +func rustTestDefault(s spec.Schema, def string) spec.Schema { + s.Default = def + return s +} + +// rustTestEmit renders the file the named schema leads, going through the real +// grouping so a test sees what the generator would write. +func rustTestEmit(t *testing.T, name string, schemas map[string]*spec.Schema) string { + t.Helper() + + boxed := rustDetectCycles(schemas) + for _, group := range rustGroupSchemas(schemas) { + if group.primary == name { + return string(rustEmitFile(group, schemas, boxed)) + } + } + t.Fatalf("no file is named after schema %q", name) + return "" +} + +// TestRustEmitSchemaFile locks the shape of the generated code: field +// attributes, optionality, type mapping, the naming of inline structs, and the +// consts and Default impl that let a function build a resource without naming +// its group and version. +func TestRustEmitSchemaFile(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + code string + } + }{ + "Resource": { + reason: "A resource's fields are optional and renamed to their wire names, each kind of schema maps to its Rust type, inline objects become structs named after their path, and a root gets its consts and a Default impl.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "name": rustTestScalar("string", ""), + }).SchemaProps}, + "com.example.v1.Widget": {SchemaProps: rustTestSchema(rustTestObject(map[string]spec.Schema{ + "apiVersion": rustTestDefault(rustTestScalar("string", ""), "example.com/v1"), + "kind": rustTestDefault(rustTestScalar("string", ""), "Widget"), + "metadata": rustTestRef("io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"), + "spec": rustTestSchema(rustTestObject(map[string]spec.Schema{ + "replicas": rustTestScalar("integer", "int32"), + "weight": rustTestScalar("integer", ""), + "ratio": rustTestScalar("number", ""), + "enabled": rustTestScalar("boolean", ""), + "type": rustTestSchema(rustTestEnum("Static", "Dynamic"), "How the widget is wired up."), + "labels": rustTestMap(rustTestScalar("string", "")), + "hosts": rustTestArray(rustTestScalar("string", "")), + "rules": rustTestArray(rustTestObject(map[string]spec.Schema{ + "port": rustTestScalar("integer", "int32"), + })), + "free": rustTestScalar("object", ""), + "unknown": {}, + }), "The widget's desired state."), + }), "Widget is a widget.").SchemaProps}, + }}, + want: struct{ code string }{code: `// Code generated by github.com/crossplane/cli/v2 DO NOT EDIT. + +/// Widget is a widget. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Widget { + #[serde(rename = "apiVersion", default, skip_serializing_if = "Option::is_none")] + pub api_version: Option, + + #[serde(rename = "kind", default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + + #[serde(rename = "metadata", default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + /// The widget's desired state. + #[serde(rename = "spec", default, skip_serializing_if = "Option::is_none")] + pub spec: Option, +} + +impl Widget { + /// The apiVersion of this resource. + pub const API_VERSION: &'static str = "example.com/v1"; + + /// The kind of this resource. + pub const KIND: &'static str = "Widget"; +} + +impl Default for Widget { + fn default() -> Self { + Self { + api_version: Some(Self::API_VERSION.to_string()), + kind: Some(Self::KIND.to_string()), + metadata: None, + spec: None, + } + } +} + +/// The widget's desired state. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WidgetSpec { + #[serde(rename = "enabled", default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + #[serde(rename = "free", default, skip_serializing_if = "Option::is_none")] + pub free: Option, + + #[serde(rename = "hosts", default, skip_serializing_if = "Option::is_none")] + pub hosts: Option>, + + #[serde(rename = "labels", default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, + + #[serde(rename = "ratio", default, skip_serializing_if = "Option::is_none")] + pub ratio: Option, + + #[serde(rename = "replicas", default, skip_serializing_if = "Option::is_none")] + pub replicas: Option, + + #[serde(rename = "rules", default, skip_serializing_if = "Option::is_none")] + pub rules: Option>, + + /// How the widget is wired up. + /// + /// Allowed values: ` + "`Static`, `Dynamic`" + `. + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + #[serde(rename = "unknown", default, skip_serializing_if = "Option::is_none")] + pub unknown: Option, + + #[serde(rename = "weight", default, skip_serializing_if = "Option::is_none")] + pub weight: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct WidgetSpecRule { + #[serde(rename = "port", default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} +`}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := rustTestEmit(t, "com.example.v1.Widget", tc.args.schemas) + if diff := cmp.Diff(tc.want.code, got); diff != "" { + t.Errorf("\n%s\nemitted Widget: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// TestRustEmitNonObjectSchemas covers the named schemas that are not objects: +// scalar wrappers such as Time, unstructured types such as RawExtension, and +// the scalar unions Quantity and IntOrString. +func TestRustEmitNonObjectSchemas(t *testing.T) { + t.Parallel() + + schemas := map[string]*spec.Schema{ + "io.k8s.apimachinery.pkg.apis.meta.v1.Time": {SchemaProps: rustTestScalar("string", "date-time").SchemaProps}, + "io.k8s.apimachinery.pkg.runtime.RawExtension": {SchemaProps: rustTestScalar("object", "").SchemaProps}, + "io.k8s.apimachinery.pkg.api.resource.Quantity": {SchemaProps: spec.SchemaProps{OneOf: []spec.Schema{rustTestScalar("string", ""), rustTestScalar("number", "")}}}, + "io.k8s.apimachinery.pkg.util.intstr.IntOrString": {SchemaProps: spec.SchemaProps{ + Format: "int-or-string", + OneOf: []spec.Schema{rustTestScalar("integer", ""), rustTestScalar("string", "")}, + }}, + } + + cases := map[string]struct { + reason string + args struct{ schema string } + want struct{ code string } + }{ + "ScalarWrapperBecomesAlias": { + reason: "A named scalar is an alias for the Rust type the scalar maps to.", + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.apis.meta.v1.Time"}, + want: struct{ code string }{code: "pub type Time = String;\n"}, + }, + "UnstructuredObjectBecomesValue": { + reason: "An object that declares no properties can hold anything, so it is an alias for a JSON value.", + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.runtime.RawExtension"}, + want: struct{ code string }{code: "pub type RawExtension = serde_json::Value;\n"}, + }, + "ScalarUnionBecomesUntaggedEnum": { + reason: "A choice of scalars is an untagged enum with a variant for each.", + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.api.resource.Quantity"}, + want: struct{ code string }{code: `#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum Quantity { + String(String), + Number(f64), +} +`}, + }, + "IntOrStringKeepsVariantOrder": { + reason: "serde tries untagged variants in order, so they keep the order the schema lists them in.", + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.util.intstr.IntOrString"}, + want: struct{ code string }{code: `#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum IntOrString { + Int(i64), + String(String), +} +`}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + code := rustTestEmit(t, tc.args.schema, schemas) + got := struct{ code string }{code: strings.TrimPrefix(code, rustGeneratedHeader+"\n\n")} + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("\n%s\nemitted code for %q: -want, +got:\n%s", tc.reason, tc.args.schema, diff) + } + }) + } +} + +// TestRustBoxesReferenceCycles covers the types that reference each other +// inline. Without a Box they would have infinite size and the crate would not +// compile; a reference through a Vec or a map is already indirect and must not +// be boxed, or the models get needlessly awkward to use. +func TestRustBoxesReferenceCycles(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + edges []rustEdge + // contains is the code the file a schema leads must contain. + contains map[string][]string + } + }{ + "SelfReference": { + reason: "A type that refers to itself inline, the JSONSchemaProps shape, is boxed. Its reference through a Vec is already indirect, and its reference to another type closes no cycle.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "com.example.v1.Node": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "child": rustTestRef("com.example.v1.Node"), + "children": rustTestArray(rustTestRef("com.example.v1.Node")), + "leaf": rustTestRef("com.example.v1.Leaf"), + }).SchemaProps}, + "com.example.v1.Leaf": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "name": rustTestScalar("string", ""), + }).SchemaProps}, + }}, + want: struct { + edges []rustEdge + contains map[string][]string + }{ + edges: []rustEdge{{from: "com.example.v1.Node", to: "com.example.v1.Node"}}, + contains: map[string][]string{"com.example.v1.Node": { + "pub child: Option>,", + "pub children: Option>,", + "pub leaf: Option,", + }}, + }, + }, + "MutualReference": { + reason: "Of two types that refer to each other, only the reference that closes the cycle is boxed. Left is walked first, so that is the one back from Right.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "com.example.v1.Left": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "right": rustTestRef("com.example.v1.Right"), + }).SchemaProps}, + "com.example.v1.Right": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "left": rustTestRef("com.example.v1.Left"), + }).SchemaProps}, + }}, + want: struct { + edges []rustEdge + contains map[string][]string + }{ + edges: []rustEdge{{from: "com.example.v1.Right", to: "com.example.v1.Left"}}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := slices.SortedFunc(maps.Keys(rustDetectCycles(tc.args.schemas)), func(a, b rustEdge) int { + if c := strings.Compare(a.from, b.from); c != 0 { + return c + } + return strings.Compare(a.to, b.to) + }) + if diff := cmp.Diff(tc.want.edges, got, cmp.AllowUnexported(rustEdge{})); diff != "" { + t.Errorf("\n%s\nrustDetectCycles(...): -want boxed edges, +got boxed edges:\n%s", tc.reason, diff) + } + + for schema, contains := range tc.want.contains { + code := rustTestEmit(t, schema, tc.args.schemas) + for _, want := range contains { + if !strings.Contains(code, want) { + t.Errorf("\n%s\ngenerated %s is missing %q:\n%s", tc.reason, schema, want, code) + } + } + } + }) + } +} + +// TestRustReferenceToUnknownSchemaFallsBack covers a reference we cannot +// resolve. Emitting the path anyway would produce a crate that doesn't compile, +// which is worse than an untyped field. +func TestRustReferenceToUnknownSchemaFallsBack(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + contains []string + } + }{ + "MissingSchema": { + reason: "A reference to a schema the source does not define falls back to a JSON value.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "com.example.v1.Widget": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "other": rustTestRef("com.example.v1.Missing"), + }).SchemaProps}, + }}, + want: struct{ contains []string }{contains: []string{"pub other: Option,"}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := rustTestEmit(t, "com.example.v1.Widget", tc.args.schemas) + for _, want := range tc.want.contains { + if !strings.Contains(got, want) { + t.Errorf("\n%s\ngenerated Widget is missing %q:\n%s", tc.reason, want, got) + } + } + }) + } +} + +// TestRustFileOwner covers which file a schema is emitted into. The Kubernetes +// API names a kind's helper types after it, which is what makes grouping by +// name prefix work; the cases that don't are the interesting ones. +func TestRustFileOwner(t *testing.T) { + t.Parallel() + + kinds := map[string]bool{ + "Pod": true, + "PodList": true, + "PodTemplate": true, + "Service": true, + "ServiceList": true, + } + + cases := map[string]struct { + reason string + args struct{ typ string } + want struct{ owner string } + }{ + "KindOwnsItself": { + reason: "A kind is emitted into a file of its own.", + args: struct{ typ string }{typ: "Pod"}, + want: struct{ owner string }{owner: "Pod"}, + }, + "ListJoinsItsKind": { + reason: "A List wraps its kind, so it is emitted with it.", + args: struct{ typ string }{typ: "PodList"}, + want: struct{ owner string }{owner: "Pod"}, + }, + "HelperJoinsItsKind": { + reason: "A type named after a kind is one of its helpers.", + args: struct{ typ string }{typ: "PodSpec"}, + want: struct{ owner string }{owner: "Pod"}, + }, + "LongestKindPrefixWins": { + reason: "PodTemplate is a kind of its own, so its helpers are its own, not Pod's.", + args: struct{ typ string }{typ: "PodTemplateSpec"}, + want: struct{ owner string }{owner: "PodTemplate"}, + }, + "KindIsNotSwallowedByAShorterKind": { + reason: "A kind keeps its own file even when a shorter kind's name starts it.", + args: struct{ typ string }{typ: "PodTemplate"}, + want: struct{ owner string }{owner: "PodTemplate"}, + }, + "PrefixMustEndAWord": { + reason: "A prefix only counts at a word boundary, or Pod would own every type whose name merely starts with those three letters.", + args: struct{ typ string }{typ: "Podium"}, + want: struct{ owner string }{owner: "Podium"}, + }, + "SharedTypeKeepsItsOwnFile": { + reason: "A type named after no kind keeps a file of its own.", + args: struct{ typ string }{typ: "ObjectMeta"}, + want: struct{ owner string }{owner: "ObjectMeta"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := struct{ owner string }{owner: rustFileOwner(tc.args.typ, kinds)} + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { + t.Errorf("\n%s\nrustFileOwner(%q): -want, +got:\n%s", tc.reason, tc.args.typ, diff) + } + }) + } +} + +// TestRustGroupSchemas covers the grouping end to end, on the shape a +// Kubernetes OpenAPI document has: a kind, its list, its spec and status as +// separate component schemas, plus shared types named after no kind. +func TestRustGroupSchemas(t *testing.T) { + t.Parallel() + + kind := func(k string) *spec.Schema { + s := rustTestObject(map[string]spec.Schema{ + "apiVersion": rustTestDefault(rustTestScalar("string", ""), "apps/v1"), + "kind": rustTestDefault(rustTestScalar("string", ""), k), + }) + return &spec.Schema{SchemaProps: s.SchemaProps} + } + helper := func() *spec.Schema { + s := rustTestObject(map[string]spec.Schema{"name": rustTestScalar("string", "")}) + return &spec.Schema{SchemaProps: s.SchemaProps} + } + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + // files is the schemas each file holds, the one it is named after + // first. + files map[string][]string + } + }{ + "KindWithListAndHelpers": { + reason: "A kind's list, spec and status are emitted with it, and a schema named after no kind keeps a file of its own.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "io.k8s.api.apps.v1.Deployment": kind("Deployment"), + "io.k8s.api.apps.v1.DeploymentList": kind("DeploymentList"), + "io.k8s.api.apps.v1.DeploymentSpec": helper(), + "io.k8s.api.apps.v1.DeploymentStatus": helper(), + "io.k8s.api.apps.v1.DeploymentStrategy": helper(), + "io.k8s.api.apps.v1.RollingUpdateDeployment": helper(), + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": helper(), + }}, + want: struct{ files map[string][]string }{files: map[string][]string{ + "src/io/k8s/api/apps/v1/deployment.rs": { + "io.k8s.api.apps.v1.Deployment", + "io.k8s.api.apps.v1.DeploymentList", + "io.k8s.api.apps.v1.DeploymentSpec", + "io.k8s.api.apps.v1.DeploymentStatus", + "io.k8s.api.apps.v1.DeploymentStrategy", + }, + // Named after no kind, so it keeps its own file even though a + // reader might expect it under Deployment. + "src/io/k8s/api/apps/v1/rollingupdatedeployment.rs": { + "io.k8s.api.apps.v1.RollingUpdateDeployment", + }, + "src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs": { + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta", + }, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := make(map[string][]string) + for _, g := range rustGroupSchemas(tc.args.schemas) { + got[path.Join(g.dir, g.stem+".rs")] = g.schemas + } + + if diff := cmp.Diff(tc.want.files, got); diff != "" { + t.Errorf("\n%s\nrustGroupSchemas(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestGenerateFromCRDRust(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + input afero.Fs + } + want struct { + present []string + absent []string + // contains is the code a generated file must contain. + contains map[string][]string + } + }{ + "CRDsAndXRDs": { + reason: "Every CRD and XRD is generated into one valid crate, in which a resource carries its own group, version and kind, and a list is emitted with the kind it wraps.", + args: struct{ input afero.Fs }{input: afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata")}, + want: struct { + present []string + absent []string + contains map[string][]string + }{ + present: []string{ + "models/Cargo.toml", + "models/src/lib.rs", + "models/src/co/acme/platform/v1alpha1/mod.rs", + "models/src/co/acme/platform/v1alpha1/xaccountscaffold.rs", + "models/src/co/acme/platform/v1alpha1/accountscaffold.rs", + "models/src/io/upbound/azure/web/v1beta2/linuxfunctionapp.rs", + "models/src/io/cilium/v2/ciliumclusterwidenetworkpolicy.rs", + "models/src/com/example/v1/widget.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/time.rs", + }, + // The list type is emitted with the kind it wraps rather than in + // a file of its own. + absent: []string{"models/src/co/acme/platform/v1alpha1/xaccountscaffoldlist.rs"}, + contains: map[string][]string{ + "models/src/co/acme/platform/v1alpha1/xaccountscaffold.rs": { + // The XR is a resource, so it carries its own group, + // version and kind. + "pub struct XAccountScaffold {", + `pub const API_VERSION: &'static str = "platform.acme.co/v1alpha1";`, + `pub const KIND: &'static str = "XAccountScaffold";`, + "impl Default for XAccountScaffold {", + "api_version: Some(Self::API_VERSION.to_string()),", + "kind: Some(Self::KIND.to_string()),", + // Crossplane machinery fields come from the XRD expansion. + "pub struct XAccountScaffoldSpec {", + "pub struct XAccountScaffoldSpecParameters {", + // Shared Kubernetes types are referenced, not inlined. + "pub metadata: Option,", + // The list must not become a second XAccountScaffold. + "pub struct XAccountScaffoldList {", + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromCRD(...): %v", tc.reason, err) + } + + for _, p := range tc.want.present { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Errorf("\n%s\nexpected model file %s does not exist", tc.reason, p) + } + } + for _, p := range tc.want.absent { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if exists { + t.Errorf("\n%s\n%s was emitted instead of being grouped with its kind", tc.reason, p) + } + } + + assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + + for p, contains := range tc.want.contains { + contents, err := afero.ReadFile(schemaFS, p) + if err != nil { + t.Fatal(err) + } + for _, want := range contains { + if !strings.Contains(string(contents), want) { + t.Errorf("\n%s\n%s is missing %q", tc.reason, p, want) + } + } + } + }) + } +} + +// TestGenerateFromCRDRustValidationOnlyCombinators covers CRDs that use +// anyOf/oneOf purely for validation, like Cilium's "exactly one of +// endpointSelector and nodeSelector". The fields they constrain must still be +// generated from the schema's regular properties. +func TestGenerateFromCRDRustValidationOnlyCombinators(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + input afero.Fs + } + want struct { + // contains is the code a generated file must contain. + contains map[string][]string + } + }{ + "CiliumExactlyOneOf": { + reason: "The fields a validation-only anyOf or oneOf constrains are still generated from the schema's regular properties.", + args: struct{ input afero.Fs }{input: afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata")}, + want: struct{ contains map[string][]string }{contains: map[string][]string{ + "models/src/io/cilium/v2/ciliumclusterwidenetworkpolicy.rs": { + "pub endpoint_selector:", + "pub node_selector:", + "pub ingress:", + "pub egress:", + }, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromCRD(...): %v", tc.reason, err) + } + + for p, contains := range tc.want.contains { + contents, err := afero.ReadFile(schemaFS, p) + if err != nil { + t.Fatal(err) + } + for _, want := range contains { + if !strings.Contains(string(contents), want) { + t.Errorf("\n%s\n%s is missing %q", tc.reason, p, want) + } + } + } + }) + } +} + +func TestGenerateFromOpenAPIRust(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + input afero.Fs + } + want struct { + present []string + absent []string + // contains is the code a generated file must contain. + contains map[string][]string + } + }{ + "KubernetesDocuments": { + reason: "Documents served by an API server are generated into one valid crate, in which a kind takes its group, version and kind from its extension and is emitted with its helpers.", + args: struct{ input afero.Fs }{input: afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata")}, + want: struct { + present []string + absent []string + contains map[string][]string + }{ + present: []string{ + "models/Cargo.toml", + "models/src/lib.rs", + "models/src/io/k8s/api/core/v1/pod.rs", + "models/src/io/k8s/api/resource/v1/deviceclass.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + "models/src/io/k8s/apimachinery/pkg/api/resource/quantity.rs", + "models/src/io/k8s/apimachinery/pkg/util/intstr/intorstring.rs", + }, + absent: []string{ + "models/src/io/k8s/api/core/v1/podspec.rs", + "models/src/io/k8s/api/core/v1/podlist.rs", + }, + contains: map[string][]string{ + "models/src/io/k8s/api/core/v1/pod.rs": { + "pub struct Pod {", + // Schemas served by an API server declare their GVK in + // an extension, which goAddDefaults turns into the + // defaults we key on. + `pub const API_VERSION: &'static str = "v1";`, + `pub const KIND: &'static str = "Pod";`, + "pub metadata: Option,", + "pub spec: Option,", + // Kubernetes documents declare a kind's helpers as + // component schemas of their own; they are emitted with + // the kind rather than one file each. + "pub struct PodSpec {", + "pub struct PodStatus {", + "pub struct PodList {", + }, + // A kind of its own keeps its own file, and takes its own + // helpers with it. + "models/src/io/k8s/api/core/v1/podtemplate.rs": { + "pub struct PodTemplate {", + "pub struct PodTemplateSpec {", + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaFS, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromOpenAPI(...): %v", tc.reason, err) + } + + for _, p := range tc.want.present { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Errorf("\n%s\nexpected model file %s does not exist", tc.reason, p) + } + } + for _, p := range tc.want.absent { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if exists { + t.Errorf("\n%s\n%s was emitted instead of being grouped with its kind", tc.reason, p) + } + } + + assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + + for p, contains := range tc.want.contains { + contents, err := afero.ReadFile(schemaFS, p) + if err != nil { + t.Fatal(err) + } + for _, want := range contains { + if !strings.Contains(string(contents), want) { + t.Errorf("\n%s\n%s is missing %q", tc.reason, p, want) + } + } + } + }) + } +} + +// TestGenerateRustFlowsAgree covers the two generation flows sharing one crate. +// The schema manager copies each source's output over the last and deletes +// nothing, a CRD source and a Kubernetes source both emit the shared Kubernetes +// types, and which of them is copied last is not under our control. So a file +// both flows write has to come out the same from either, and the merged crate +// has to be valid in both orders. +func TestGenerateRustFlowsAgree(t *testing.T) { + t.Parallel() + + gen := &rustGenerator{} + + fromCRD, err := gen.GenerateFromCRD(t.Context(), afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata"), nil) + if err != nil { + t.Fatal(err) + } + fromOpenAPI, err := gen.GenerateFromOpenAPI(t.Context(), afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata"), nil) + if err != nil { + t.Fatal(err) + } + + crd := rustReadTree(t, afero.NewBasePathFs(fromCRD, rustModelsDir)) + openAPI := rustReadTree(t, afero.NewBasePathFs(fromOpenAPI, rustModelsDir)) + + shared := 0 + for p, want := range crd { + got, ok := openAPI[p] + // The module declarations and the manifest describe a whole crate, and + // are rebuilt from the merged one. + if !ok || path.Base(p) == "mod.rs" || path.Base(p) == "lib.rs" || p == "Cargo.toml" { + continue + } + shared++ + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%s differs between the CRD flow and the OpenAPI flow (-crd +openapi):\n%s", p, diff) + } + } + if shared < 2 { + t.Fatalf("the flows share %d files; the testdata no longer covers the shared Kubernetes types", shared) + } + + cases := map[string]struct { + reason string + args struct { + sources []map[string]string + } + }{ + "CRDThenOpenAPI": { + reason: "A Kubernetes dependency added to a project that already has CRD models leaves a valid crate.", + args: struct{ sources []map[string]string }{ + sources: []map[string]string{crd, openAPI}, + }, + }, + "OpenAPIThenCRD": { + reason: "CRD models generated into a project that already has Kubernetes models leave a valid crate.", + args: struct{ sources []map[string]string }{ + sources: []map[string]string{openAPI, crd}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + crateFS := afero.NewMemMapFs() + for _, source := range tc.args.sources { + for p, contents := range source { + if err := afero.WriteFile(crateFS, p, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + } + if err := BuildRustModuleTree(crateFS); err != nil { + t.Fatalf("\n%s\nBuildRustModuleTree(...): %v", tc.reason, err) + } + } + + assertValidRustCrate(t, crateFS) + }) + } +} + +// TestRustSharedTypesAreNeverKinds pins the rule the flows agree by: a shared +// Kubernetes type is a plain struct in a file of its own even when its schema +// says it is a resource, which the OpenAPI flow's does for Status and the CRD +// flow's does not. +func TestRustSharedTypesAreNeverKinds(t *testing.T) { + t.Parallel() + + object := func(props map[string]spec.Schema) *spec.Schema { + return &spec.Schema{SchemaProps: rustTestObject(props).SchemaProps} + } + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + present []string + // notContains is the code a generated file must not contain. + notContains map[string][]string + } + }{ + "StatusDeclaredAsAResource": { + reason: "A shared type must not be treated as a resource, nor have the types named after it grouped into its file.", + args: struct{ schemas map[string]*spec.Schema }{schemas: map[string]*spec.Schema{ + "io.k8s.apimachinery.pkg.apis.meta.v1.Status": object(map[string]spec.Schema{ + "apiVersion": rustTestDefault(rustTestEnum("v1"), "v1"), + "kind": rustTestDefault(rustTestEnum("Status"), "Status"), + "details": rustTestRef("io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails"), + }), + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails": object(map[string]spec.Schema{ + "name": rustTestScalar("string", ""), + }), + }}, + want: struct { + present []string + notContains map[string][]string + }{ + present: []string{"src/io/k8s/apimachinery/pkg/apis/meta/v1/statusdetails.rs"}, + notContains: map[string][]string{ + "src/io/k8s/apimachinery/pkg/apis/meta/v1/status.rs": { + "pub struct StatusDetails", + "impl Default for Status", + "API_VERSION", + "Allowed values", + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaFS, err := rustGenerateModels(tc.args.schemas) + if err != nil { + t.Fatalf("\n%s\nrustGenerateModels(...): %v", tc.reason, err) + } + crateFS := afero.NewBasePathFs(schemaFS, rustModelsDir) + assertValidRustCrate(t, crateFS) + + for p, notContains := range tc.want.notContains { + contents, err := afero.ReadFile(crateFS, p) + if err != nil { + t.Fatal(err) + } + for _, unwanted := range notContains { + if strings.Contains(string(contents), unwanted) { + t.Errorf("\n%s\n%s contains %q:\n%s", tc.reason, p, unwanted, contents) + } + } + } + for _, p := range tc.want.present { + if ok, _ := afero.Exists(crateFS, p); !ok { + t.Errorf("\n%s\n%s does not exist", tc.reason, p) + } + } + }) + } +} + +// TestRustSkipsSchemasWithoutATypeName covers a CRD that leaves +// spec.names.listKind to the API server's defaulting: its list schema is named +// after the group and version alone. The Go generator drops such a schema, and +// so do we, rather than emit a type, a file and a module called "_". +func TestRustSkipsSchemasWithoutATypeName(t *testing.T) { + t.Parallel() + + task := &spec.Schema{SchemaProps: rustTestObject(map[string]spec.Schema{ + "image": rustTestScalar("string", ""), + }).SchemaProps} + list := &spec.Schema{SchemaProps: rustTestObject(map[string]spec.Schema{ + "items": rustTestArray(rustTestRef("com.example.v1.Task")), + }).SchemaProps} + + cases := map[string]struct { + reason string + args struct { + schemas map[string]*spec.Schema + } + want struct { + files []string + } + }{ + "ListWithoutAName": { + reason: "The nameless list schema is dropped and the kind is generated as usual.", + args: struct{ schemas map[string]*spec.Schema }{ + schemas: map[string]*spec.Schema{"com.example.v1.Task": task, "com.example.v1.": list}, + }, + want: struct{ files []string }{ + files: []string{"Cargo.toml", "src/com/example/mod.rs", "src/com/example/v1/mod.rs", "src/com/example/v1/task.rs", "src/com/mod.rs", "src/lib.rs"}, + }, + }, + "NothingLeft": { + reason: "A source with nothing but nameless schemas generates no crate at all.", + args: struct{ schemas map[string]*spec.Schema }{ + schemas: map[string]*spec.Schema{"com.example.v1.": list}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemaFS, err := rustGenerateModels(maps.Clone(tc.args.schemas)) + if err != nil { + t.Fatalf("\n%s\nrustGenerateModels(...): %v", tc.reason, err) + } + + var got []string + if schemaFS != nil { + crateFS := afero.NewBasePathFs(schemaFS, rustModelsDir) + assertValidRustCrate(t, crateFS) + got = slices.Sorted(maps.Keys(rustReadTree(t, crateFS))) + } + if diff := cmp.Diff(tc.want.files, got); diff != "" { + t.Errorf("\n%s\nrustGenerateModels(...): -want files, +got files:\n%s", tc.reason, diff) + } + }) + } +} + +// TestRustFieldIdentifiers covers properties that map to the same Rust +// identifier, which real CRDs have (Istio's mirrorPercent and mirror_percent, +// prometheus-operator's proxyURL and proxyUrl). Each keeps its own name on the +// wire; the second to ask for an identifier gets a numeric suffix. +func TestRustFieldIdentifiers(t *testing.T) { + t.Parallel() + + fieldRE := regexp.MustCompile(`#\[serde\(rename = "([^"]+)"[^\n]*\n pub ((?:r#)?\w+):`) + + cases := map[string]struct { + reason string + args struct { + properties []string + } + want struct { + fields map[string]string + } + }{ + "SnakeAndCamel": { + reason: "A camelCase and a snake_case spelling of one name.", + args: struct{ properties []string }{properties: []string{"mirrorPercent", "mirror_percent"}}, + want: struct{ fields map[string]string }{fields: map[string]string{ + "mirrorPercent": "mirror_percent", + "mirror_percent": "mirror_percent_2", + }}, + }, + "AcronymCasing": { + reason: "Two casings of an acronym.", + args: struct{ properties []string }{properties: []string{"proxyURL", "proxyUrl"}}, + want: struct{ fields map[string]string }{fields: map[string]string{ + "proxyURL": "proxy_url", + "proxyUrl": "proxy_url_2", + }}, + }, + "KeywordsWithoutARawForm": { + reason: "self and Self both take a trailing underscore.", + args: struct{ properties []string }{properties: []string{"Self", "self"}}, + want: struct{ fields map[string]string }{fields: map[string]string{ + "Self": "self_", + "self": "self__2", + }}, + }, + "NoLetterOrDigit": { + reason: "A property name with nothing to make an identifier of still gets a valid one.", + args: struct{ properties []string }{properties: []string{"-"}}, + want: struct{ fields map[string]string }{fields: map[string]string{"-": "unnamed"}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + props := make(map[string]spec.Schema, len(tc.args.properties)) + for _, p := range tc.args.properties { + props[p] = rustTestScalar("string", "") + } + schemas := map[string]*spec.Schema{ + "com.example.v1.Widget": {SchemaProps: rustTestObject(props).SchemaProps}, + } + + got := make(map[string]string) + for _, m := range fieldRE.FindAllStringSubmatch(rustTestEmit(t, "com.example.v1.Widget", schemas), -1) { + got[m[1]] = m[2] + } + if diff := cmp.Diff(tc.want.fields, got); diff != "" { + t.Errorf("\n%s\n-want identifiers, +got identifiers, by JSON name:\n%s", tc.reason, diff) + } + }) + } +} + +// TestRustAdditionalProperties covers objects that have named properties and +// allow others. The others are kept in a map flattened into the struct, so a +// resource read into a model and written back loses nothing. +func TestRustAdditionalProperties(t *testing.T) { + t.Parallel() + + withExtension := func(s spec.Schema) spec.Schema { + s.AddExtension(rustPreserveUnknownFields, true) + return s + } + withAdditional := func(s spec.Schema, ap *spec.SchemaOrBool) spec.Schema { + s.AdditionalProperties = ap + return s + } + known := map[string]spec.Schema{"known": rustTestScalar("string", "")} + values := rustTestScalar("string", "") + + cases := map[string]struct { + reason string + args struct { + schema spec.Schema + } + want struct { + contains []string + notContains []string + } + }{ + "TypedValues": { + reason: "additionalProperties with a schema gives the map that schema's type, as the Go models do.", + args: struct{ schema spec.Schema }{schema: withAdditional(rustTestObject(known), &spec.SchemaOrBool{Allows: true, Schema: &values})}, + want: struct{ contains, notContains []string }{contains: []string{ + " /// Properties the schema allows without naming them.\n" + + " #[serde(flatten, default, skip_serializing_if = \"std::collections::BTreeMap::is_empty\")]\n" + + " pub additional_properties: std::collections::BTreeMap,\n", + }}, + }, + "AnyValues": { + reason: "additionalProperties: true allows values of any type.", + args: struct{ schema spec.Schema }{schema: withAdditional(rustTestObject(known), &spec.SchemaOrBool{Allows: true})}, + want: struct{ contains, notContains []string }{contains: []string{ + " pub additional_properties: std::collections::BTreeMap,\n", + }}, + }, + "PreserveUnknownFields": { + reason: "The API server keeps the unknown fields of such an object, so the model has to as well.", + args: struct{ schema spec.Schema }{schema: withExtension(rustTestObject(known))}, + want: struct{ contains, notContains []string }{contains: []string{ + " pub additional_properties: std::collections::BTreeMap,\n", + }}, + }, + "NameTaken": { + reason: "A property with the map's name keeps it, and the map gives way.", + args: struct{ schema spec.Schema }{schema: withExtension(rustTestObject(map[string]spec.Schema{ + "additionalProperties": rustTestScalar("string", ""), + }))}, + want: struct{ contains, notContains []string }{contains: []string{ + " pub additional_properties: Option,\n", + " pub additional_properties_2: std::collections::BTreeMap,\n", + }}, + }, + "Resource": { + reason: "A resource's hand-written Default starts the map out empty.", + args: struct{ schema spec.Schema }{schema: withExtension(rustTestObject(map[string]spec.Schema{ + "apiVersion": rustTestDefault(rustTestScalar("string", ""), "example.com/v1"), + "kind": rustTestDefault(rustTestScalar("string", ""), "Widget"), + }))}, + want: struct{ contains, notContains []string }{contains: []string{ + " additional_properties: std::collections::BTreeMap::new(),\n", + }}, + }, + "ClosedObject": { + reason: "An object that allows nothing beyond its properties gets no map.", + args: struct{ schema spec.Schema }{schema: rustTestObject(known)}, + want: struct{ contains, notContains []string }{notContains: []string{"additional_properties", "flatten"}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + schemas := map[string]*spec.Schema{"com.example.v1.Widget": { + SchemaProps: tc.args.schema.SchemaProps, + VendorExtensible: tc.args.schema.VendorExtensible, + }} + got := rustTestEmit(t, "com.example.v1.Widget", schemas) + + for _, want := range tc.want.contains { + if !strings.Contains(got, want) { + t.Errorf("\n%s\nmissing:\n%s\ngot:\n%s", tc.reason, want, got) + } + } + for _, unwanted := range tc.want.notContains { + if strings.Contains(got, unwanted) { + t.Errorf("\n%s\nshould not contain %q, got:\n%s", tc.reason, unwanted, got) + } + } + }) + } +} + +// TestRustWriteDoc covers the line endings a description can carry. A bare +// carriage return is not allowed in a Rust doc comment, so it ends a line, the +// same way it does in the Go models. +func TestRustWriteDoc(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + doc string + } + want struct { + out string + } + }{ + "LineFeed": { + reason: "One comment line per line of the description.", + args: struct{ doc string }{doc: "first\nsecond\n"}, + want: struct{ out string }{out: "/// first\n/// second\n"}, + }, + "CarriageReturnLineFeed": { + reason: "A Windows line ending is one line ending.", + args: struct{ doc string }{doc: "first\r\nsecond"}, + want: struct{ out string }{out: "/// first\n/// second\n"}, + }, + "BareCarriageReturn": { + reason: "A carriage return on its own ends a line too.", + args: struct{ doc string }{doc: "first\rsecond"}, + want: struct{ out string }{out: "/// first\n/// second\n"}, + }, + "Empty": { + reason: "No description, no comment.", + args: struct{ doc string }{doc: " \n"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var sb strings.Builder + rustWriteDoc(&sb, "", tc.args.doc) + if diff := cmp.Diff(tc.want.out, sb.String()); diff != "" { + t.Errorf("\n%s\nrustWriteDoc(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// TestRustFeatures covers the features that let a function compile only the +// modules of models it imports: which modules get one, and what each has to +// enable for the crate to compile with it alone. +func TestRustFeatures(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + files map[string]string + } + want struct { + features map[string][]string + } + }{ + "ReferencedModule": { + reason: "A module enables the modules its models refer to, and not itself.", + args: struct{ files map[string]string }{files: map[string]string{ + "src/io/upbound/s3/v1beta1/bucket.rs": "pub struct Bucket {\n pub metadata: Option,\n" + + " pub items: Option>,\n}\n", + "src/io/k8s/meta/v1/objectmeta.rs": "pub struct ObjectMeta {}\n", + }}, + want: struct{ features map[string][]string }{features: map[string][]string{ + "io-upbound-s3-v1beta1": {"io-k8s-meta-v1"}, + "io-k8s-meta-v1": {}, + }}, + }, + "ReferenceInADescription": { + reason: "A path in a doc comment is prose from a schema, not a reference.", + args: struct{ files map[string]string }{files: map[string]string{ + "src/com/example/v1/widget.rs": "/// See crate::io::k8s::meta::v1::ObjectMeta.\npub struct Widget {}\n", + "src/io/k8s/meta/v1/objectmeta.rs": "pub struct ObjectMeta {}\n", + }}, + want: struct{ features map[string][]string }{features: map[string][]string{ + "com-example-v1": {}, + "io-k8s-meta-v1": {}, + }}, + }, + "ModelsInsideAModuleOfModels": { + reason: "A module is declared by the one above it, so it needs that one compiled.", + args: struct{ files map[string]string }{files: map[string]string{ + "src/com/example/widget.rs": "pub struct Widget {}\n", + "src/com/example/v1/gadget.rs": "pub struct Gadget {}\n", + }}, + want: struct{ features map[string][]string }{features: map[string][]string{ + "com-example": {}, + "com-example-v1": {"com-example"}, + }}, + }, + "ModelsInTheCrateRoot": { + reason: "Models with no module around them cannot be gated, and referring to them needs no feature.", + args: struct{ files map[string]string }{files: map[string]string{ + "src/widget.rs": "pub struct Widget {}\n", + "src/com/example/v1/gadget.rs": "pub struct Gadget {\n pub widget: Option,\n}\n", + }}, + want: struct{ features map[string][]string }{features: map[string][]string{ + "com-example-v1": {}, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + crateFS := afero.NewMemMapFs() + for p, contents := range tc.args.files { + if err := afero.WriteFile(crateFS, p, []byte(rustGeneratedHeader+"\n\n"+contents), 0o644); err != nil { + t.Fatal(err) + } + } + if err := BuildRustModuleTree(crateFS); err != nil { + t.Fatalf("\n%s\nBuildRustModuleTree(...): %v", tc.reason, err) + } + + features, err := rustCollectFeatures(crateFS) + if err != nil { + t.Fatal(err) + } + got := make(map[string][]string, len(features)) + for _, f := range features { + got[f.name] = f.deps + } + if diff := cmp.Diff(tc.want.features, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\n-want features, +got features:\n%s", tc.reason, diff) + } + + assertRustFeatures(t, crateFS) + }) + } +} + +// TestGenerateRustIsDeterministic guards the property that makes committing +// generated schemas practical: the same input produces byte-identical output. +func TestGenerateRustIsDeterministic(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + input afero.Fs + } + }{ + "CRDsAndXRDs": { + reason: "Two runs over the same input produce byte-identical crates.", + args: struct{ input afero.Fs }{input: afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata")}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + first, err := rustGenerator{}.GenerateFromCRD(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromCRD(...): %v", tc.reason, err) + } + second, err := rustGenerator{}.GenerateFromCRD(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromCRD(...): %v", tc.reason, err) + } + + if diff := cmp.Diff(rustReadTree(t, first), rustReadTree(t, second)); diff != "" { + t.Errorf("\n%s\n-first run, +second run:\n%s", tc.reason, diff) + } + }) + } +} + +func TestGenerateRustNoInput(t *testing.T) { + t.Parallel() + + type generate func(context.Context, afero.Fs, runner.SchemaRunner) (afero.Fs, error) + + cases := map[string]struct { + reason string + args struct { + generate generate + } + }{ + "NoCRDs": { + reason: "An input with no CRDs generates nothing, rather than an empty crate.", + args: struct{ generate generate }{generate: rustGenerator{}.GenerateFromCRD}, + }, + "NoOpenAPIDocuments": { + reason: "An input with no OpenAPI documents generates nothing, rather than an empty crate.", + args: struct{ generate generate }{generate: rustGenerator{}.GenerateFromOpenAPI}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tc.args.generate(t.Context(), afero.NewMemMapFs(), nil) + if err != nil { + t.Fatalf("\n%s\ngenerate(...): %v", tc.reason, err) + } + if got != nil { + t.Errorf("\n%s\ngenerate(...) returned a filesystem", tc.reason) + } + }) + } +} + +// TestBuildRustModuleTree covers what the schema manager relies on: the module +// declarations describe whatever is in the crate, so a second source adding +// files to a module the first source created leaves a valid crate. +func TestBuildRustModuleTree(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + // sources is the files each source adds to the crate, in the + // order the sources are generated. + sources [][]string + } + want struct { + files map[string]string + } + }{ + "SecondSourceExtendsTheCrate": { + reason: "The declarations are rebuilt from whatever the crate holds, so a source that only saw its own schemas still leaves a valid crate.", + args: struct{ sources [][]string }{sources: [][]string{ + // The first source generates one kind, plus a shared + // Kubernetes type. + { + "src/com/example/v1/widget.rs", + "src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + }, + // A second source adds a kind to a module that already exists, + // and a module of its own. + { + "src/com/example/v1/gadget.rs", + "src/co/acme/platform/v1alpha1/xaccountscaffold.rs", + }, + }}, + want: struct{ files map[string]string }{files: map[string]string{ + "src/lib.rs": rustGeneratedHeader + "\n" + rustCrateAttributes + ` +pub mod co; +pub mod com; +pub mod io; +`, + "src/com/example/v1/mod.rs": rustGeneratedHeader + ` + +mod gadget; +pub use gadget::*; +mod widget; +pub use widget::*; +`, + "src/com/example/mod.rs": rustGeneratedHeader + ` + +#[cfg(feature = "com-example-v1")] +pub mod v1; +`, + // The manifest is rebuilt with the tree: a feature per module + // of models, all of them on by default. + "Cargo.toml": rustCargoToml + rustFeaturesHeader + `default = ["all"] +all = [ + "co-acme-platform-v1alpha1", + "com-example-v1", + "io-k8s-apimachinery-pkg-apis-meta-v1", +] +co-acme-platform-v1alpha1 = [] +com-example-v1 = [] +io-k8s-apimachinery-pkg-apis-meta-v1 = [] +`, + "src/co/acme/platform/v1alpha1/mod.rs": rustGeneratedHeader + ` + +mod xaccountscaffold; +pub use xaccountscaffold::*; +`, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + crateFS := afero.NewMemMapFs() + for _, source := range tc.args.sources { + for _, p := range source { + if err := afero.WriteFile(crateFS, p, []byte(rustGeneratedHeader+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := BuildRustModuleTree(crateFS); err != nil { + t.Fatalf("\n%s\nBuildRustModuleTree(...): %v", tc.reason, err) + } + } + + for p, want := range tc.want.files { + got, err := afero.ReadFile(crateFS, p) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(want, string(got)); diff != "" { + t.Errorf("\n%s\n%s: -want, +got:\n%s", tc.reason, p, diff) + } + } + + assertValidRustCrate(t, crateFS) + }) + } +} + +var ( + rustTypeDeclRE = regexp.MustCompile(`(?m)^pub (?:struct|enum|type) (\w+)`) + rustFieldRE = regexp.MustCompile(`(?m)^ pub ((?:r#)?\w+): `) + rustModDeclRE = regexp.MustCompile(`(?m)^(?:pub )?mod (\w+);`) +) + +// assertValidRustCrate checks the properties a Rust compiler would catch but a +// unit test in Go cannot: that no module declares the same type twice, whatever +// file it is in, that no struct declares the same field twice, that nothing is +// named with a bare underscore, that every struct field carries the serde +// attributes the round trip depends on, and that every module declares exactly +// the children of its directory. +func assertValidRustCrate(t *testing.T, crateFS afero.Fs) { + t.Helper() + + // A module re-exports every file of its directory, so a type name has to be + // unique in the directory, not just in its file. + declaredIn := make(map[string]string) + + err := afero.Walk(crateFS, "src", func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if strings.TrimSuffix(info.Name(), ".rs") == "_" { + t.Errorf("%s is named with a bare underscore, which is not a Rust identifier", p) + } + if info.IsDir() { + assertRustModuleDeclarations(t, crateFS, p) + return nil + } + if !strings.HasSuffix(p, ".rs") || info.Name() == "mod.rs" || info.Name() == "lib.rs" { + return nil + } + + contents, err := afero.ReadFile(crateFS, p) + if err != nil { + return err + } + code := string(contents) + + if !strings.HasPrefix(code, rustGeneratedHeader) { + t.Errorf("%s does not start with the generated code header", p) + } + + for _, m := range rustTypeDeclRE.FindAllStringSubmatch(code, -1) { + if m[1] == "_" { + t.Errorf("%s declares a type named with a bare underscore", p) + } + key := path.Join(path.Dir(p), m[1]) + if other, ok := declaredIn[key]; ok { + t.Errorf("%s declares type %s, which %s declares too", p, m[1], other) + } + declaredIn[key] = p + } + + // Every field is optional and renamed, and skips serialization when + // unset, so a function's desired state carries only what it set. The + // one exception is the map of additional properties, which is flattened + // into its struct and skipped when empty. + fields := make(map[string]bool) + lines := strings.Split(code, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "pub struct ") { + fields = make(map[string]bool) + continue + } + + m := rustFieldRE.FindStringSubmatch(line + "\n") + if m == nil { + continue + } + if m[1] == "_" { + t.Errorf("%s:%d declares a field named with a bare underscore", p, i+1) + } + if fields[m[1]] { + t.Errorf("%s:%d declares field %s twice in one struct", p, i+1, m[1]) + } + fields[m[1]] = true + + if i > 0 && strings.HasPrefix(lines[i-1], ` #[serde(flatten, default, skip_serializing_if = `) { + continue + } + if !strings.HasSuffix(line, ",") || !strings.Contains(line, "Option<") { + t.Errorf("%s:%d field %s is not optional: %s", p, i+1, m[1], line) + } + if i == 0 || !strings.HasPrefix(lines[i-1], ` #[serde(rename = `) { + t.Errorf("%s:%d field %s is not preceded by a serde rename attribute", p, i+1, m[1]) + } + } + + return nil + }) + if err != nil { + t.Fatal(err) + } + + assertRustFeatures(t, crateFS) +} + +var ( + rustFeatureTableRE = regexp.MustCompile(`(?m)^([\w-]+) = \[([^\]]*)\]`) + rustFeatureNameRE = regexp.MustCompile(`"([^"]+)"`) + rustGatedModRE = regexp.MustCompile(`(?m)^(?:#\[cfg\(feature = "([^"]+)"\)\]\n)?pub mod (\w+);`) +) + +// assertRustFeatures checks the features the way cargo and rustc would when a +// function picks some of them: every module that holds models is gated by a +// feature of its own, every feature named anywhere is declared, all of them are +// on by default, and a feature enables the feature of every module its models +// refer to, or the crate would not compile with that feature alone. +func assertRustFeatures(t *testing.T, crateFS afero.Fs) { + t.Helper() + + manifest, err := afero.ReadFile(crateFS, "Cargo.toml") + if err != nil { + t.Fatal(err) + } + _, table, _ := strings.Cut(string(manifest), "[features]\n") + + declared := make(map[string][]string) + for _, m := range rustFeatureTableRE.FindAllStringSubmatch(table, -1) { + names := rustFeatureNameRE.FindAllStringSubmatch(m[2], -1) + deps := make([]string, len(names)) + for i, d := range names { + deps[i] = d[1] + } + declared[m[1]] = deps + } + for _, deps := range declared { + for _, d := range deps { + if _, ok := declared[d]; !ok { + t.Errorf("Cargo.toml: feature %q is enabled by another but not declared", d) + } + } + } + + // The feature gating each directory, from the module declarations. + gates := make(map[string]string) + holdsModels := make(map[string]bool) + err = afero.Walk(crateFS, "src", func(p string, info fs.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + if rustIsTypeFile(info.Name()) { + holdsModels[path.Dir(p)] = true + return nil + } + contents, err := afero.ReadFile(crateFS, p) + if err != nil { + return err + } + for _, m := range rustGatedModRE.FindAllStringSubmatch(string(contents), -1) { + if m[1] != "" { + gates[path.Join(path.Dir(p), m[2])] = m[1] + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + var all []string + for dir := range holdsModels { + if dir == "src" { + continue + } + feature, ok := gates[dir] + if !ok { + t.Errorf("%s holds models but no feature gates it", dir) + continue + } + if _, ok := declared[feature]; !ok { + t.Errorf("%s is gated by feature %q, which Cargo.toml does not declare", dir, feature) + } + all = append(all, feature) + } + for dir := range gates { + if !holdsModels[dir] { + t.Errorf("%s is gated but holds no models", dir) + } + } + slices.Sort(all) + + if len(all) == 0 { + return + } + if diff := cmp.Diff([]string{"all"}, declared["default"]); diff != "" { + t.Errorf("Cargo.toml: default features (-want +got):\n%s", diff) + } + if diff := cmp.Diff(all, declared["all"]); diff != "" { + t.Errorf("Cargo.toml: the all feature does not enable exactly the gated modules (-want +got):\n%s", diff) + } + + // What a feature enables, itself included, following the table the way + // cargo does. + var enabled func(feature string, seen map[string]bool) + enabled = func(feature string, seen map[string]bool) { + if seen[feature] { + return + } + seen[feature] = true + for _, d := range declared[feature] { + enabled(d, seen) + } + } + + for dir := range holdsModels { + feature, ok := gates[dir] + if !ok { + continue + } + on := make(map[string]bool) + enabled(feature, on) + + // Every gated module on the path to this one, and every module its + // models refer to, has to be compiled along with it. + needed := make(map[string]string) + for parent := path.Dir(dir); parent != "src" && parent != "."; parent = path.Dir(parent) { + if f, ok := gates[parent]; ok { + needed[f] = "it is declared inside " + parent + } + } + entries, err := afero.ReadDir(crateFS, dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() || !rustIsTypeFile(e.Name()) { + continue + } + contents, err := afero.ReadFile(crateFS, path.Join(dir, e.Name())) + if err != nil { + t.Fatal(err) + } + for _, target := range rustReferencedModules(string(contents)) { + if f, ok := gates[target]; ok { + needed[f] = e.Name() + " refers to " + target + } + } + } + + for f, why := range needed { + if !on[f] { + t.Errorf("Cargo.toml: feature %q does not enable %q, but %s", feature, f, why) + } + } + } +} + +// assertRustModuleDeclarations checks that the mod.rs (or lib.rs) of a +// directory declares exactly its subdirectories and files. +func assertRustModuleDeclarations(t *testing.T, crateFS afero.Fs, dir string) { + t.Helper() + + name := "mod.rs" + if dir == "src" { + name = "lib.rs" + } + + contents, err := afero.ReadFile(crateFS, path.Join(dir, name)) + if err != nil { + t.Errorf("directory %s has no %s: %v", dir, name, err) + return + } + + var declared []string + for _, m := range rustModDeclRE.FindAllStringSubmatch(string(contents), -1) { + if m[1] == "_" { + t.Errorf("%s declares a module named with a bare underscore", path.Join(dir, name)) + } + declared = append(declared, m[1]) + } + slices.Sort(declared) + + entries, err := afero.ReadDir(crateFS, dir) + if err != nil { + t.Fatal(err) + } + var want []string + for _, e := range entries { + if e.IsDir() { + want = append(want, e.Name()) + continue + } + if stem, ok := strings.CutSuffix(e.Name(), ".rs"); ok && e.Name() != name { + want = append(want, stem) + } + } + slices.Sort(want) + + if diff := cmp.Diff(want, declared); diff != "" { + t.Errorf("%s does not declare its directory's children (-want +got):\n%s", path.Join(dir, name), diff) + } +} + +// rustReadTree reads every file of a generated filesystem into a map, for +// comparing two runs. +func rustReadTree(t *testing.T, from afero.Fs) map[string]string { + t.Helper() + + tree := make(map[string]string) + err := afero.Walk(from, "", func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + contents, err := afero.ReadFile(from, p) + if err != nil { + return err + } + tree[p] = string(contents) + return nil + }) + if err != nil { + t.Fatal(err) + } + + return tree +} diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 3fe60e7c..72a5331c 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -153,6 +153,12 @@ func postProcessForLanguage(language string, langFS afero.Fs) error { } return nil + case devv1alpha1.SchemaLanguageRust: + if err := generator.BuildRustModuleTree(langFS); err != nil { + return errors.Wrap(err, "cannot finish generating Rust models; check the reported file and run schema generation again") + } + return nil + default: return nil } diff --git a/internal/schemas/manager/manager_rust_test.go b/internal/schemas/manager/manager_rust_test.go new file mode 100644 index 00000000..f83afaf5 --- /dev/null +++ b/internal/schemas/manager/manager_rust_test.go @@ -0,0 +1,133 @@ +/* +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 manager + +import ( + "context" + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +var rustModDeclRE = regexp.MustCompile(`(?m)^(?:pub )?mod (\w+);`) + +// TestGenerateRustModuleTreeAcrossSources covers the reason the Rust generator +// leaves its module declarations to the manager: every source's models are +// copied into one crate, and a generator run only ever sees its own source. The +// declarations have to describe the merged crate, or the second source to +// generate leaves a crate that doesn't compile. +func TestGenerateRustModuleTreeAcrossSources(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + args struct { + // sources is the files the generator writes for each source, in + // the order the sources are generated. + sources [][]string + } + want struct { + // modules is the modules each file declares. + modules map[string][]string + } + }{ + "DependencyExtendsTheProjectsModules": { + reason: "The declarations describe the merged crate, not only the source generated last.", + args: struct{ sources [][]string }{sources: [][]string{ + // The project's own XRDs. + { + "models/src/com/example/v1/widget.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + }, + // A dependency that adds a kind to a module the project already + // generated, plus a module of its own. + { + "models/src/com/example/v1/gadget.rs", + "models/src/io/upbound/aws/s3/v1beta2/bucket.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + }, + }}, + want: struct{ modules map[string][]string }{modules: map[string][]string{ + "rust/src/lib.rs": {"com", "io"}, + "rust/src/com/example/v1/mod.rs": {"gadget", "widget"}, + "rust/src/io/mod.rs": {"k8s", "upbound"}, + "rust/src/io/k8s/apimachinery/pkg/apis/meta/v1/mod.rs": {"objectmeta"}, + "rust/src/io/upbound/aws/s3/v1beta2/mod.rs": {"bucket"}, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + testFS := afero.NewMemMapFs() + for i, files := range tc.args.sources { + m := New(testFS, []generator.Interface{&rustMockGenerator{files: files}}, nil) + if _, err := m.Generate(t.Context(), &mockSource{id: "source", version: "v1.0.0"}); err != nil { + t.Fatalf("\n%s\nGenerate(...) for source %d: %v", tc.reason, i, err) + } + } + + for path, want := range tc.want.modules { + contents, err := afero.ReadFile(testFS, path) + if err != nil { + t.Errorf("\n%s\nreading %s: %v", tc.reason, path, err) + continue + } + decls := rustModDeclRE.FindAllStringSubmatch(string(contents), -1) + got := make([]string, 0, len(decls)) + for _, m := range decls { + got = append(got, m[1]) + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("\n%s\n%s: -want modules, +got modules:\n%s", tc.reason, path, diff) + } + } + }) + } +} + +// rustMockGenerator writes the given files, as the Rust generator does for the +// schemas of one source. +type rustMockGenerator struct { + files []string +} + +func (g *rustMockGenerator) Language() string { + return devv1alpha1.SchemaLanguageRust +} + +func (g *rustMockGenerator) GenerateFromCRD(_ context.Context, _ afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + fs := afero.NewMemMapFs() + for _, path := range g.files { + if err := afero.WriteFile(fs, path, []byte("// Code generated by github.com/crossplane/cli/v2 DO NOT EDIT.\n"), 0o600); err != nil { + return nil, err + } + } + return fs, nil +} + +func (g *rustMockGenerator) GenerateFromOpenAPI(_ context.Context, _ afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + return nil, nil +}