From 8a6a64ff6ee9dddf42d04fd2c7b2f2edf1419fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 17 Sep 2026 17:21:23 +0200 Subject: [PATCH 1/5] feat(schemas): generate Rust models for project schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `rust` as a schema language, so a project's XRDs, its dependencies' CRDs and the Kubernetes built-ins it depends on are generated into a serde-typed Cargo crate under schemas/rust. A composition function written with function-sdk-rust consumes it as a path dependency and needs nothing from the SDK to do so: each resource carries its own apiVersion and kind through API_VERSION/KIND consts and a Default impl, rather than a trait the SDK would have to define. The emitter is native Go over the existing OpenAPI pipeline, like the Go and KCL generators, so generating Rust needs no Docker. It reuses goCollectOpenAPIs, goAddDefaults and goRemoveValidationOnlyCombinators. Shape of the generated crate: - One crate, with module paths mirroring the component schema names, so Kubernetes types land where a k8s-openapi user expects them. - One file per kind: a kind, its list and the types named after it (PodSpec, PodStatus) share a file, and the module re-exports every file, so the layout is invisible to users. lib.rs and every mod.rs are rebuilt from the directory in the manager's post-processing, because every source's models are copied into one crate and a generator run only ever sees its own source. - The shared Kubernetes types are the exception. Every source writes them, the CRD flow and the OpenAPI flow alike, and the manager copies one source over another without deleting. So they are emitted the same way by both flows: never as a resource, each in a file of its own. - Every field is an Option that skips serialization when unset, so a function's desired state claims only the fields it set. Enums stay strings, so a value a newer server starts sending cannot break deserializing an observed resource. - An object with named properties that allows others, through additionalProperties or x-kubernetes-preserve-unknown-fields, keeps them in a flattened additional_properties map, so reading a resource into a model and writing it back loses nothing. - Properties that map to one Rust identifier (proxyURL and proxyUrl) get a numeric suffix and keep their own names on the wire. - A schema without a type name, which is what a CRD without listKind gives its list, is dropped, as the Go generator drops it. - Every module of models is behind a Cargo feature named after its path (io-upbound-m-aws-s3-v1beta1), all of them on by default. A function that imports a few groups from a crate holding many can turn the defaults off and list those: nine provider-aws packages take 2m46s to check, the one group a function typically imports 8s. A feature enables the features of the modules its models refer to, found by scanning the absolute crate paths the emitter writes, so the shared Kubernetes types never need listing. The module is what is gated, rather than the types in it, which is what lets rustc tell a function importing from a disabled module which feature to enable. The manifest is rebuilt along with the module declarations, since both describe the merged crate. - References that close a cycle are boxed, which is what the recursive JSONSchemaProps needs to have a finite size. The tests check in Go what a Rust compiler would reject: a type declared twice in a module, a field declared twice in a struct, an identifier that is a bare underscore, module declarations that do not match the tree, and a feature that does not enable every module its models refer to. They run the repository's CRD and OpenAPI test data through both flows into one crate, in both orders. Signed-off-by: Jonasz Łasut-Balcerzak --- apis/dev/v1alpha1/project_types.go | 4 +- internal/schemas/generator/interface.go | 1 + internal/schemas/generator/rust.go | 1098 +++++++++++ internal/schemas/generator/rust_features.go | 178 ++ internal/schemas/generator/rust_naming.go | 324 ++++ .../schemas/generator/rust_naming_test.go | 339 ++++ internal/schemas/generator/rust_test.go | 1607 +++++++++++++++++ internal/schemas/manager/manager.go | 6 + internal/schemas/manager/manager_rust_test.go | 111 ++ 9 files changed, 3667 insertions(+), 1 deletion(-) create mode 100644 internal/schemas/generator/rust.go create mode 100644 internal/schemas/generator/rust_features.go create mode 100644 internal/schemas/generator/rust_naming.go create mode 100644 internal/schemas/generator/rust_naming_test.go create mode 100644 internal/schemas/generator/rust_test.go create mode 100644 internal/schemas/manager/manager_rust_test.go 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/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..e8fb419c --- /dev/null +++ b/internal/schemas/generator/rust_test.go @@ -0,0 +1,1607 @@ +/* +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" + "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" +) + +// 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() + + 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 := `// 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, +} +` + + got := rustTestEmit(t, "com.example.v1.Widget", schemas) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("emitted Widget: -want, +got:\n%s", 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 { + args struct{ schema string } + want struct{ code string } + }{ + "ScalarWrapperBecomesAlias": { + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.apis.meta.v1.Time"}, + want: struct{ code string }{code: "pub type Time = String;\n"}, + }, + "UnstructuredObjectBecomesValue": { + args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.runtime.RawExtension"}, + want: struct{ code string }{code: "pub type RawExtension = serde_json::Value;\n"}, + }, + "ScalarUnionBecomesUntaggedEnum": { + 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": { + 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("emitted code for %q: -want, +got:\n%s", 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() + + schemas := map[string]*spec.Schema{ + // Self reference, the JSONSchemaProps shape. + "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}, + // Mutual references between two schemas. + "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}, + } + + boxed := rustDetectCycles(schemas) + + wantEdges := []rustEdge{ + {from: "com.example.v1.Node", to: "com.example.v1.Node"}, + // Left is walked first, so the reference back from Right closes the + // cycle. + {from: "com.example.v1.Right", to: "com.example.v1.Left"}, + } + gotEdges := slices.SortedFunc(maps.Keys(boxed), 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(wantEdges, gotEdges, cmp.AllowUnexported(rustEdge{})); diff != "" { + t.Errorf("boxed edges: -want, +got:\n%s", diff) + } + + node := rustTestEmit(t, "com.example.v1.Node", schemas) + for _, want := range []string{ + "pub child: Option>,", + "pub children: Option>,", + "pub leaf: Option,", + } { + if !strings.Contains(node, want) { + t.Errorf("generated Node is missing %q:\n%s", want, node) + } + } +} + +// 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() + + schemas := map[string]*spec.Schema{ + "com.example.v1.Widget": {SchemaProps: rustTestObject(map[string]spec.Schema{ + "other": rustTestRef("com.example.v1.Missing"), + }).SchemaProps}, + } + + got := rustTestEmit(t, "com.example.v1.Widget", schemas) + if !strings.Contains(got, "pub other: Option,") { + t.Errorf("unresolved reference did not fall back to a JSON value:\n%s", 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 { + args struct{ typ string } + want struct{ owner string } + }{ + "KindOwnsItself": { + args: struct{ typ string }{typ: "Pod"}, + want: struct{ owner string }{owner: "Pod"}, + }, + "ListJoinsItsKind": { + args: struct{ typ string }{typ: "PodList"}, + want: struct{ owner string }{owner: "Pod"}, + }, + "HelperJoinsItsKind": { + args: struct{ typ string }{typ: "PodSpec"}, + want: struct{ owner string }{owner: "Pod"}, + }, + // PodTemplate is a kind of its own, so its helpers are its own, not + // Pod's. + "LongestKindPrefixWins": { + args: struct{ typ string }{typ: "PodTemplateSpec"}, + want: struct{ owner string }{owner: "PodTemplate"}, + }, + "KindIsNotSwallowedByAShorterKind": { + args: struct{ typ string }{typ: "PodTemplate"}, + want: struct{ owner string }{owner: "PodTemplate"}, + }, + // A prefix only counts at a word boundary, or Pod would own every type + // whose name merely starts with those three letters. + "PrefixMustEndAWord": { + args: struct{ typ string }{typ: "Podium"}, + want: struct{ owner string }{owner: "Podium"}, + }, + "SharedTypeKeepsItsOwnFile": { + 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("rustFileOwner(%q): -want, +got:\n%s", 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} + } + + 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 := 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", + }, + } + + got := make(map[string][]string) + for _, g := range rustGroupSchemas(schemas) { + got[path.Join(g.dir, g.stem+".rs")] = g.schemas + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("rustGroupSchemas(): -want, +got:\n%s", diff) + } +} + +func TestGenerateFromCRDRust(t *testing.T) { + t.Parallel() + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + expectedFiles := []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", + } + for _, p := range expectedFiles { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Errorf("expected model file %s does not exist", p) + } + } + + assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + + // The XR is a resource, so it carries its own group, version and kind. + xr, err := afero.ReadFile(schemaFS, "models/src/co/acme/platform/v1alpha1/xaccountscaffold.rs") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "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,", + } { + if !strings.Contains(string(xr), want) { + t.Errorf("generated XAccountScaffold is missing %q", want) + } + } + + // The list type is emitted with the kind it wraps rather than in a file of + // its own, and must not become a second XAccountScaffold. + if !strings.Contains(string(xr), "pub struct XAccountScaffoldList {") { + t.Error("XAccountScaffoldList was not emitted with XAccountScaffold") + } + exists, err := afero.Exists(schemaFS, "models/src/co/acme/platform/v1alpha1/xaccountscaffoldlist.rs") + if err != nil { + t.Fatal(err) + } + if exists { + t.Error("XAccountScaffoldList still has a file of its own") + } +} + +// 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() + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + contents, err := afero.ReadFile(schemaFS, "models/src/io/cilium/v2/ciliumclusterwidenetworkpolicy.rs") + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{ + "pub endpoint_selector:", + "pub node_selector:", + "pub ingress:", + "pub egress:", + } { + if !strings.Contains(string(contents), want) { + t.Errorf("generated Cilium policy is missing %q", want) + } + } +} + +func TestGenerateFromOpenAPIRust(t *testing.T) { + t.Parallel() + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") + schemaFS, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + expectedFiles := []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", + } + for _, p := range expectedFiles { + exists, err := afero.Exists(schemaFS, p) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Errorf("expected model file %s does not exist", p) + } + } + + assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + + pod, err := afero.ReadFile(schemaFS, "models/src/io/k8s/api/core/v1/pod.rs") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "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 {", + } { + if !strings.Contains(string(pod), want) { + t.Errorf("generated Pod is missing %q", want) + } + } + + for _, gone := range []string{ + "models/src/io/k8s/api/core/v1/podspec.rs", + "models/src/io/k8s/api/core/v1/podlist.rs", + } { + exists, err := afero.Exists(schemaFS, gone) + if err != nil { + t.Fatal(err) + } + if exists { + t.Errorf("%s was emitted instead of being grouped with its kind", gone) + } + } + + // A kind of its own keeps its own file, and takes its own helpers with it. + template, err := afero.ReadFile(schemaFS, "models/src/io/k8s/api/core/v1/podtemplate.rs") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"pub struct PodTemplate {", "pub struct PodTemplateSpec {"} { + if !strings.Contains(string(template), want) { + t.Errorf("generated PodTemplate is missing %q", 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} + } + + 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", ""), + }), + } + + schemaFS, err := rustGenerateModels(schemas) + if err != nil { + t.Fatal(err) + } + crateFS := afero.NewBasePathFs(schemaFS, rustModelsDir) + assertValidRustCrate(t, crateFS) + + dir := "src/io/k8s/apimachinery/pkg/apis/meta/v1" + status, err := afero.ReadFile(crateFS, path.Join(dir, "status.rs")) + if err != nil { + t.Fatal(err) + } + for _, unwanted := range []string{"pub struct StatusDetails", "impl Default for Status", "API_VERSION", "Allowed values"} { + if strings.Contains(string(status), unwanted) { + t.Errorf("status.rs contains %q; a shared type must not be treated as a resource:\n%s", unwanted, status) + } + } + if ok, _ := afero.Exists(crateFS, path.Join(dir, "statusdetails.rs")); !ok { + t.Errorf("StatusDetails has no file of its own; a shared type must not be grouped") + } +} + +// 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() + + inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") + + first, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + second, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(rustReadTree(t, first), rustReadTree(t, second)); diff != "" { + t.Errorf("two runs over the same input differ: -first, +second:\n%s", diff) + } +} + +func TestGenerateRustNoInput(t *testing.T) { + t.Parallel() + + empty := afero.NewMemMapFs() + + crd, err := rustGenerator{}.GenerateFromCRD(t.Context(), empty, nil) + if err != nil { + t.Fatal(err) + } + if crd != nil { + t.Error("GenerateFromCRD returned a filesystem for an input with no CRDs") + } + + openAPI, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), empty, nil) + if err != nil { + t.Fatal(err) + } + if openAPI != nil { + t.Error("GenerateFromOpenAPI returned a filesystem for an input with no OpenAPI documents") + } +} + +// 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() + + crateFS := afero.NewMemMapFs() + stub := []byte(rustGeneratedHeader + "\n") + + // The first source generates one kind, plus a shared Kubernetes type. + for _, p := range []string{ + "src/com/example/v1/widget.rs", + "src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + } { + if err := afero.WriteFile(crateFS, p, stub, 0o644); err != nil { + t.Fatal(err) + } + } + if err := BuildRustModuleTree(crateFS); err != nil { + t.Fatal(err) + } + + // A second source adds a kind to a module that already exists, and a module + // of its own. Its own run of the generator only saw its own schemas. + for _, p := range []string{ + "src/com/example/v1/gadget.rs", + "src/co/acme/platform/v1alpha1/xaccountscaffold.rs", + } { + if err := afero.WriteFile(crateFS, p, stub, 0o644); err != nil { + t.Fatal(err) + } + } + if err := BuildRustModuleTree(crateFS); err != nil { + t.Fatal(err) + } + + want := 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 p, contents := range want { + got, err := afero.ReadFile(crateFS, p) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(contents, string(got)); diff != "" { + t.Errorf("%s (-want +got):\n%s", 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..3a41103a 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, "failed to build the module tree for Rust") + } + 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..bd791366 --- /dev/null +++ b/internal/schemas/manager/manager_rust_test.go @@ -0,0 +1,111 @@ +/* +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() + + testFS := afero.NewMemMapFs() + + // The project's own XRDs, and a dependency that adds a kind to a module the + // project already generated plus a module of its own. + sources := []*rustMockGenerator{ + {files: []string{ + "models/src/com/example/v1/widget.rs", + "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", + }}, + {files: []string{ + "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", + }}, + } + + for i, gen := range sources { + m := New(testFS, []generator.Interface{gen}, nil) + if _, err := m.Generate(t.Context(), &mockSource{id: "source", version: "v1.0.0"}); err != nil { + t.Fatalf("source %d: %v", i, err) + } + } + + want := 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 path, children := range want { + contents, err := afero.ReadFile(testFS, path) + if err != nil { + t.Errorf("reading %s: %v", path, err) + continue + } + var got []string + for _, m := range rustModDeclRE.FindAllStringSubmatch(string(contents), -1) { + got = append(got, m[1]) + } + if diff := cmp.Diff(children, got); diff != "" { + t.Errorf("%s does not declare the merged crate's children (-want +got):\n%s", 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 +} From 25d17c067d30ac1eb2f43288e00ccecca6327a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 17 Sep 2026 17:21:45 +0200 Subject: [PATCH 2/5] feat(function): scaffold Rust functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rust as a language of crossplane function generate. The scaffold is a function-sdk-rust project: a Cargo package named after the function with a binary called function, a FunctionRunnerService that reports a result, a test for it, and a path dependency on the crossplane-models crate the rust schema language generates into schemas/rust, when that crate exists. The path dependency plays the role of the replace directive in the Go template and the file: reference in the Python one, so the function needs no models symlink. A comment on it says how to compile only the models the function imports. The scaffold also carries the project's Rust configuration: a rust-toolchain.toml naming the components that format and lint it, and a lints table in Cargo.toml. It passes cargo fmt, cargo test and cargo clippy with warnings denied as generated. No Cargo.lock is shipped. Cargo writes one on the first build, and a lock file in the template would have to track every SDK release. The README says to commit it: the build uses the lock file when there is one. Signed-off-by: Jonasz Łasut-Balcerzak --- cmd/crossplane/function/generate.go | 52 +++++++++++- cmd/crossplane/function/generate_test.go | 69 +++++++++++++++ cmd/crossplane/function/help/generate.md | 7 ++ .../function/templates/rust/.gitignore.tmpl | 1 + .../function/templates/rust/Cargo.toml.tmpl | 37 ++++++++ .../function/templates/rust/README.md.tmpl | 30 +++++++ .../templates/rust/rust-toolchain.toml | 6 ++ .../templates/rust/src/function.rs.tmpl | 84 +++++++++++++++++++ .../function/templates/rust/src/main.rs | 13 +++ 9 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 cmd/crossplane/function/templates/rust/.gitignore.tmpl create mode 100644 cmd/crossplane/function/templates/rust/Cargo.toml.tmpl create mode 100644 cmd/crossplane/function/templates/rust/README.md.tmpl create mode 100644 cmd/crossplane/function/templates/rust/rust-toolchain.toml create mode 100644 cmd/crossplane/function/templates/rust/src/function.rs.tmpl create mode 100644 cmd/crossplane/function/templates/rust/src/main.rs 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 +} From 08521285b2b8e686c05c3fb8625fd5ba53f6591d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 17 Sep 2026 17:21:57 +0200 Subject: [PATCH 3/5] feat(project): build Rust functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teach crossplane project build to build a function-sdk-rust project, identified by its Cargo.toml. The function is compiled with cargo in the official Rust image, along with the crossplane-models crate it depends on by path, and the binary is appended to distroless cc as /function. Every architecture is built in one container of the host's architecture: an architecture other than the container's own gets Debian's cross gcc, which is what linking and crates with C code (aws-lc-sys, pulled in by the SDK's TLS stack) need. Nothing runs under emulation. The build image is Debian bookworm because the runtime image is, and the binary links against the build image's glibc. Cargo decides what the function's binary is, since a binary can come from [[bin]], src/main.rs or src/bin. A package that builds anything other than exactly one is an error. The build always uses the image's toolchain: a rust-toolchain.toml in the function, which the scaffold ships, would otherwise have rustup download another one on every build. An architecture the build script does not know is rejected before the container starts, rather than after the ones it does know have been compiled. Symlinks in the function directory are followed, which is how functions can share source; target is the one name that must not be a symlink, since only what is under it is left out. The build container is stopped with a context that cannot be cancelled. A build usually ends early because its context expired, as crossplane render's does after a minute, and the container would otherwise be left compiling. Signed-off-by: Jonasz Łasut-Balcerzak --- internal/project/functions/build.go | 9 +- internal/project/functions/build_test.go | 23 ++ internal/project/functions/rust.go | 337 +++++++++++++++++++++++ 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 internal/project/functions/rust.go 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..d306a444 --- /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("unable to determine rust target for architecture %s", 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), + } +} From a44b48f3921be779f53c24b7f40d2cff3b0ac82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Thu, 17 Sep 2026 17:22:09 +0200 Subject: [PATCH 4/5] docs(rust): add testing guide for Rust support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A walkthrough for reviewers: build the CLI, create a project that composes an S3 bucket with a Rust function, generate its models, render it, and deploy it. It covers what is specific to Rust, such as the layout of the crossplane-models crate, the namespaced and cluster-scoped model groups, rendering against a function running locally to avoid rebuilding, and the render timeout. The troubleshooting section records the limits of the schema manager on main that a Rust project runs into, with the workaround. Signed-off-by: Jonasz Łasut-Balcerzak --- docs/rust-testing-guide.md | 863 +++++++++++++++++++++++++++++++++++++ 1 file changed, 863 insertions(+) create mode 100644 docs/rust-testing-guide.md 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. From ec20721f08e2c3960957955343e04ab11917fbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonasz=20=C5=81asut-Balcerzak?= Date: Fri, 18 Sep 2026 15:38:27 +0200 Subject: [PATCH 5/5] fix(rust): address review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the unsupported-architecture and Rust model generation errors actionable, and convert the single-scenario Rust generator and manager tests to the table-driven reason/args/want format. Signed-off-by: Jonasz Łasut-Balcerzak --- internal/project/functions/rust.go | 2 +- internal/schemas/generator/rust_test.go | 1038 +++++++++++------ internal/schemas/manager/manager.go | 2 +- internal/schemas/manager/manager_rust_test.go | 104 +- 4 files changed, 724 insertions(+), 422 deletions(-) diff --git a/internal/project/functions/rust.go b/internal/project/functions/rust.go index d306a444..71b77e6a 100644 --- a/internal/project/functions/rust.go +++ b/internal/project/functions/rust.go @@ -212,7 +212,7 @@ func rustCheckArchitecture(arch string) error { case "amd64", "arm64": return nil default: - return errors.Errorf("unable to determine rust target for architecture %s", arch) + return errors.Errorf("cannot build a Rust function for architecture %q; use amd64 or arm64", arch) } } diff --git a/internal/schemas/generator/rust_test.go b/internal/schemas/generator/rust_test.go index e8fb419c..2d9ac806 100644 --- a/internal/schemas/generator/rust_test.go +++ b/internal/schemas/generator/rust_test.go @@ -17,6 +17,7 @@ limitations under the License. package generator import ( + "context" "io/fs" "maps" "path" @@ -29,6 +30,8 @@ import ( "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. @@ -105,32 +108,42 @@ func rustTestEmit(t *testing.T, name string, schemas map[string]*spec.Schema) st func TestRustEmitSchemaFile(t *testing.T) { t.Parallel() - 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 := `// Code generated by github.com/crossplane/cli/v2 DO NOT EDIT. + 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)] @@ -210,11 +223,19 @@ 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", schemas) - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("emitted Widget: -want, +got:\n%s", diff) + 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) + } + }) } } @@ -235,19 +256,23 @@ func TestRustEmitNonObjectSchemas(t *testing.T) { } cases := map[string]struct { - args struct{ schema string } - want struct{ code string } + reason string + args struct{ schema string } + want struct{ code string } }{ "ScalarWrapperBecomesAlias": { - args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.apis.meta.v1.Time"}, - want: struct{ code string }{code: "pub type Time = String;\n"}, + 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": { - args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.runtime.RawExtension"}, - want: struct{ code string }{code: "pub type RawExtension = serde_json::Value;\n"}, + 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": { - args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.api.resource.Quantity"}, + 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 { @@ -257,7 +282,8 @@ pub enum Quantity { `}, }, "IntOrStringKeepsVariantOrder": { - args: struct{ schema string }{schema: "io.k8s.apimachinery.pkg.util.intstr.IntOrString"}, + 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 { @@ -275,7 +301,7 @@ pub enum IntOrString { 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("emitted code for %q: -want, +got:\n%s", tc.args.schema, diff) + t.Errorf("\n%s\nemitted code for %q: -want, +got:\n%s", tc.reason, tc.args.schema, diff) } }) } @@ -288,52 +314,83 @@ pub enum IntOrString { func TestRustBoxesReferenceCycles(t *testing.T) { t.Parallel() - schemas := map[string]*spec.Schema{ - // Self reference, the JSONSchemaProps shape. - "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}, - // Mutual references between two schemas. - "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}, + 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"}}, + }, + }, } - boxed := rustDetectCycles(schemas) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - wantEdges := []rustEdge{ - {from: "com.example.v1.Node", to: "com.example.v1.Node"}, - // Left is walked first, so the reference back from Right closes the - // cycle. - {from: "com.example.v1.Right", to: "com.example.v1.Left"}, - } - gotEdges := slices.SortedFunc(maps.Keys(boxed), 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(wantEdges, gotEdges, cmp.AllowUnexported(rustEdge{})); diff != "" { - t.Errorf("boxed edges: -want, +got:\n%s", diff) - } + 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) + } - node := rustTestEmit(t, "com.example.v1.Node", schemas) - for _, want := range []string{ - "pub child: Option>,", - "pub children: Option>,", - "pub leaf: Option,", - } { - if !strings.Contains(node, want) { - t.Errorf("generated Node is missing %q:\n%s", want, node) - } + 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) + } + } + } + }) } } @@ -343,15 +400,37 @@ func TestRustBoxesReferenceCycles(t *testing.T) { func TestRustReferenceToUnknownSchemaFallsBack(t *testing.T) { t.Parallel() - schemas := map[string]*spec.Schema{ - "com.example.v1.Widget": {SchemaProps: rustTestObject(map[string]spec.Schema{ - "other": rustTestRef("com.example.v1.Missing"), - }).SchemaProps}, + 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,"}}, + }, } - got := rustTestEmit(t, "com.example.v1.Widget", schemas) - if !strings.Contains(got, "pub other: Option,") { - t.Errorf("unresolved reference did not fall back to a JSON value:\n%s", got) + 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) + } + } + }) } } @@ -370,40 +449,44 @@ func TestRustFileOwner(t *testing.T) { } cases := map[string]struct { - args struct{ typ string } - want struct{ owner string } + reason string + args struct{ typ string } + want struct{ owner string } }{ "KindOwnsItself": { - args: struct{ typ string }{typ: "Pod"}, - want: struct{ owner string }{owner: "Pod"}, + reason: "A kind is emitted into a file of its own.", + args: struct{ typ string }{typ: "Pod"}, + want: struct{ owner string }{owner: "Pod"}, }, "ListJoinsItsKind": { - args: struct{ typ string }{typ: "PodList"}, - want: struct{ owner string }{owner: "Pod"}, + 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": { - args: struct{ typ string }{typ: "PodSpec"}, - want: struct{ owner string }{owner: "Pod"}, + reason: "A type named after a kind is one of its helpers.", + args: struct{ typ string }{typ: "PodSpec"}, + want: struct{ owner string }{owner: "Pod"}, }, - // PodTemplate is a kind of its own, so its helpers are its own, not - // Pod's. "LongestKindPrefixWins": { - args: struct{ typ string }{typ: "PodTemplateSpec"}, - want: struct{ owner string }{owner: "PodTemplate"}, + 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": { - args: struct{ typ string }{typ: "PodTemplate"}, - want: struct{ owner string }{owner: "PodTemplate"}, + 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"}, }, - // A prefix only counts at a word boundary, or Pod would own every type - // whose name merely starts with those three letters. "PrefixMustEndAWord": { - args: struct{ typ string }{typ: "Podium"}, - want: struct{ owner string }{owner: "Podium"}, + 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": { - args: struct{ typ string }{typ: "ObjectMeta"}, - want: struct{ owner string }{owner: "ObjectMeta"}, + 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"}, }, } @@ -413,7 +496,7 @@ func TestRustFileOwner(t *testing.T) { got := struct{ owner string }{owner: rustFileOwner(tc.args.typ, kinds)} if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(got)); diff != "" { - t.Errorf("rustFileOwner(%q): -want, +got:\n%s", tc.args.typ, diff) + t.Errorf("\n%s\nrustFileOwner(%q): -want, +got:\n%s", tc.reason, tc.args.typ, diff) } }) } @@ -437,111 +520,167 @@ func TestRustGroupSchemas(t *testing.T) { return &spec.Schema{SchemaProps: s.SchemaProps} } - 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 := 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", + 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", + }, + }}, }, } - got := make(map[string][]string) - for _, g := range rustGroupSchemas(schemas) { - got[path.Join(g.dir, g.stem+".rs")] = g.schemas - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("rustGroupSchemas(): -want, +got:\n%s", diff) + 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() - inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") - schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) - if err != nil { - t.Fatal(err) - } - - expectedFiles := []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", - } - for _, p := range expectedFiles { - exists, err := afero.Exists(schemaFS, p) - if err != nil { - t.Fatal(err) + cases := map[string]struct { + reason string + args struct { + input afero.Fs } - if !exists { - t.Errorf("expected model file %s does not exist", p) + 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 {", + }, + }, + }, + }, } - assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - // The XR is a resource, so it carries its own group, version and kind. - xr, err := afero.ReadFile(schemaFS, "models/src/co/acme/platform/v1alpha1/xaccountscaffold.rs") - if err != nil { - t.Fatal(err) - } - for _, want := range []string{ - "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,", - } { - if !strings.Contains(string(xr), want) { - t.Errorf("generated XAccountScaffold is missing %q", want) - } - } - - // The list type is emitted with the kind it wraps rather than in a file of - // its own, and must not become a second XAccountScaffold. - if !strings.Contains(string(xr), "pub struct XAccountScaffoldList {") { - t.Error("XAccountScaffoldList was not emitted with XAccountScaffold") - } - exists, err := afero.Exists(schemaFS, "models/src/co/acme/platform/v1alpha1/xaccountscaffoldlist.rs") - if err != nil { - t.Fatal(err) - } - if exists { - t.Error("XAccountScaffoldList still has a file of its own") + 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) + } + } + } + }) } } @@ -552,104 +691,160 @@ func TestGenerateFromCRDRust(t *testing.T) { func TestGenerateFromCRDRustValidationOnlyCombinators(t *testing.T) { t.Parallel() - inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") - schemaFS, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) - if err != nil { - t.Fatal(err) + 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:", + }, + }}, + }, } - contents, err := afero.ReadFile(schemaFS, "models/src/io/cilium/v2/ciliumclusterwidenetworkpolicy.rs") - if err != nil { - t.Fatal(err) - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - for _, want := range []string{ - "pub endpoint_selector:", - "pub node_selector:", - "pub ingress:", - "pub egress:", - } { - if !strings.Contains(string(contents), want) { - t.Errorf("generated Cilium policy is missing %q", want) - } + 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() - inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataJSONFS}, "testdata") - schemaFS, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), inputFS, nil) - if err != nil { - t.Fatal(err) - } - - expectedFiles := []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", - } - for _, p := range expectedFiles { - exists, err := afero.Exists(schemaFS, p) - if err != nil { - t.Fatal(err) + cases := map[string]struct { + reason string + args struct { + input afero.Fs } - if !exists { - t.Errorf("expected model file %s does not exist", p) + 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 {", + }, + }, + }, + }, } - assertValidRustCrate(t, afero.NewBasePathFs(schemaFS, "models")) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - pod, err := afero.ReadFile(schemaFS, "models/src/io/k8s/api/core/v1/pod.rs") - if err != nil { - t.Fatal(err) - } - for _, want := range []string{ - "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 {", - } { - if !strings.Contains(string(pod), want) { - t.Errorf("generated Pod is missing %q", want) - } - } - - for _, gone := range []string{ - "models/src/io/k8s/api/core/v1/podspec.rs", - "models/src/io/k8s/api/core/v1/podlist.rs", - } { - exists, err := afero.Exists(schemaFS, gone) - if err != nil { - t.Fatal(err) - } - if exists { - t.Errorf("%s was emitted instead of being grouped with its kind", gone) - } - } + schemaFS, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), tc.args.input, nil) + if err != nil { + t.Fatalf("\n%s\nGenerateFromOpenAPI(...): %v", tc.reason, err) + } - // A kind of its own keeps its own file, and takes its own helpers with it. - template, err := afero.ReadFile(schemaFS, "models/src/io/k8s/api/core/v1/podtemplate.rs") - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"pub struct PodTemplate {", "pub struct PodTemplateSpec {"} { - if !strings.Contains(string(template), want) { - t.Errorf("generated PodTemplate is missing %q", want) - } + 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) + } + } + } + }) } } @@ -745,36 +940,74 @@ func TestRustSharedTypesAreNeverKinds(t *testing.T) { return &spec.Schema{SchemaProps: rustTestObject(props).SchemaProps} } - 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", ""), - }), + 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", + }, + }, + }, + }, } - schemaFS, err := rustGenerateModels(schemas) - if err != nil { - t.Fatal(err) - } - crateFS := afero.NewBasePathFs(schemaFS, rustModelsDir) - assertValidRustCrate(t, crateFS) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - dir := "src/io/k8s/apimachinery/pkg/apis/meta/v1" - status, err := afero.ReadFile(crateFS, path.Join(dir, "status.rs")) - if err != nil { - t.Fatal(err) - } - for _, unwanted := range []string{"pub struct StatusDetails", "impl Default for Status", "API_VERSION", "Allowed values"} { - if strings.Contains(string(status), unwanted) { - t.Errorf("status.rs contains %q; a shared type must not be treated as a resource:\n%s", unwanted, status) - } - } - if ok, _ := afero.Exists(crateFS, path.Join(dir, "statusdetails.rs")); !ok { - t.Errorf("StatusDetails has no file of its own; a shared type must not be grouped") + 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) + } + } + }) } } @@ -1159,41 +1392,71 @@ func TestRustFeatures(t *testing.T) { func TestGenerateRustIsDeterministic(t *testing.T) { t.Parallel() - inputFS := afero.NewBasePathFs(afero.FromIOFS{FS: testdataFS}, "testdata") - - first, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) - if err != nil { - t.Fatal(err) - } - second, err := rustGenerator{}.GenerateFromCRD(t.Context(), inputFS, nil) - if err != nil { - t.Fatal(err) + 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")}, + }, } - if diff := cmp.Diff(rustReadTree(t, first), rustReadTree(t, second)); diff != "" { - t.Errorf("two runs over the same input differ: -first, +second:\n%s", diff) + 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() - empty := afero.NewMemMapFs() + type generate func(context.Context, afero.Fs, runner.SchemaRunner) (afero.Fs, error) - crd, err := rustGenerator{}.GenerateFromCRD(t.Context(), empty, nil) - if err != nil { - t.Fatal(err) - } - if crd != nil { - t.Error("GenerateFromCRD returned a filesystem for an input with no CRDs") + 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}, + }, } - openAPI, err := rustGenerator{}.GenerateFromOpenAPI(t.Context(), empty, nil) - if err != nil { - t.Fatal(err) - } - if openAPI != nil { - t.Error("GenerateFromOpenAPI returned a filesystem for an input with no OpenAPI documents") + 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) + } + }) } } @@ -1203,57 +1466,54 @@ func TestGenerateRustNoInput(t *testing.T) { func TestBuildRustModuleTree(t *testing.T) { t.Parallel() - crateFS := afero.NewMemMapFs() - stub := []byte(rustGeneratedHeader + "\n") - - // The first source generates one kind, plus a shared Kubernetes type. - for _, p := range []string{ - "src/com/example/v1/widget.rs", - "src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", - } { - if err := afero.WriteFile(crateFS, p, stub, 0o644); err != nil { - t.Fatal(err) + 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 } - } - if err := BuildRustModuleTree(crateFS); err != nil { - t.Fatal(err) - } - - // A second source adds a kind to a module that already exists, and a module - // of its own. Its own run of the generator only saw its own schemas. - for _, p := range []string{ - "src/com/example/v1/gadget.rs", - "src/co/acme/platform/v1alpha1/xaccountscaffold.rs", - } { - if err := afero.WriteFile(crateFS, p, stub, 0o644); err != nil { - t.Fatal(err) + want struct { + files map[string]string } - } - if err := BuildRustModuleTree(crateFS); err != nil { - t.Fatal(err) - } - - want := map[string]string{ - "src/lib.rs": rustGeneratedHeader + "\n" + rustCrateAttributes + ` + }{ + "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 + ` + "src/com/example/v1/mod.rs": rustGeneratedHeader + ` mod gadget; pub use gadget::*; mod widget; pub use widget::*; `, - "src/com/example/mod.rs": rustGeneratedHeader + ` + "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"] + // 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", @@ -1263,24 +1523,44 @@ co-acme-platform-v1alpha1 = [] com-example-v1 = [] io-k8s-apimachinery-pkg-apis-meta-v1 = [] `, - "src/co/acme/platform/v1alpha1/mod.rs": rustGeneratedHeader + ` + "src/co/acme/platform/v1alpha1/mod.rs": rustGeneratedHeader + ` mod xaccountscaffold; pub use xaccountscaffold::*; `, + }}, + }, } - for p, contents := range want { - got, err := afero.ReadFile(crateFS, p) - if err != nil { - t.Fatal(err) - } - if diff := cmp.Diff(contents, string(got)); diff != "" { - t.Errorf("%s (-want +got):\n%s", p, diff) - } - } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() - assertValidRustCrate(t, crateFS) + 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 ( diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go index 3a41103a..72a5331c 100644 --- a/internal/schemas/manager/manager.go +++ b/internal/schemas/manager/manager.go @@ -155,7 +155,7 @@ func postProcessForLanguage(language string, langFS afero.Fs) error { case devv1alpha1.SchemaLanguageRust: if err := generator.BuildRustModuleTree(langFS); err != nil { - return errors.Wrap(err, "failed to build the module tree for Rust") + return errors.Wrap(err, "cannot finish generating Rust models; check the reported file and run schema generation again") } return nil diff --git a/internal/schemas/manager/manager_rust_test.go b/internal/schemas/manager/manager_rust_test.go index bd791366..f83afaf5 100644 --- a/internal/schemas/manager/manager_rust_test.go +++ b/internal/schemas/manager/manager_rust_test.go @@ -39,50 +39,72 @@ var rustModDeclRE = regexp.MustCompile(`(?m)^(?:pub )?mod (\w+);`) func TestGenerateRustModuleTreeAcrossSources(t *testing.T) { t.Parallel() - testFS := afero.NewMemMapFs() - - // The project's own XRDs, and a dependency that adds a kind to a module the - // project already generated plus a module of its own. - sources := []*rustMockGenerator{ - {files: []string{ - "models/src/com/example/v1/widget.rs", - "models/src/io/k8s/apimachinery/pkg/apis/meta/v1/objectmeta.rs", - }}, - {files: []string{ - "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", - }}, - } - - for i, gen := range sources { - m := New(testFS, []generator.Interface{gen}, nil) - if _, err := m.Generate(t.Context(), &mockSource{id: "source", version: "v1.0.0"}); err != nil { - t.Fatalf("source %d: %v", i, err) + 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"}, + }}, + }, } - want := 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 path, children := range want { - contents, err := afero.ReadFile(testFS, path) - if err != nil { - t.Errorf("reading %s: %v", path, err) - continue - } - var got []string - for _, m := range rustModDeclRE.FindAllStringSubmatch(string(contents), -1) { - got = append(got, m[1]) - } - if diff := cmp.Diff(children, got); diff != "" { - t.Errorf("%s does not declare the merged crate's children (-want +got):\n%s", path, diff) - } + 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) + } + } + }) } }