diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go index dcd87ddd..31bc1302 100644 --- a/internal/dependency/manager_test.go +++ b/internal/dependency/manager_test.go @@ -71,6 +71,46 @@ spec: type: object ` +// configurationWithXRDPackageYAML is a Configuration package that bundles an +// XRD (rather than a raw CRD, like configurationPackageYAML above) - the +// shape that previously produced zero schemas. +const configurationWithXRDPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + claimNames: + kind: Database + plural: databases + singular: database + listKind: DatabaseList + scope: LegacyCluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + const providerPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 kind: Provider metadata: @@ -947,3 +987,53 @@ func TestManager_AddDependency_TransitiveNotPersisted(t *testing.T) { t.Errorf("on-disk project deps (-want +got):\n%s", diff) } } + +// TestManager_AddPackage_ConfigurationXRD verifies that adding a +// Configuration dependency that bundles XRDs (rather than raw CRDs) +// generates real schema output, not just an empty successful pass. Before +// internal/xpkg.CRDFilesystem learned to convert XRDs to their derived CRD +// form, this produced a lock entry but zero schema content. +func TestManager_AddPackage_ConfigurationXRD(t *testing.T) { + const ( + cfgPkg = "xpkg.crossplane.io/crossplane-contrib/configuration-xrd" + cfgTag = "v0.1.0" + ) + + fc := &fakeClient{ + packages: map[string]*runtimexpkg.Package{ + cfgPkg + ":" + cfgTag: makePackageWithBody(t, cfgPkg, "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", "", configurationWithXRDPackageYAML), + }, + tags: []string{cfgTag}, + } + + schemaFS := afero.NewMemMapFs() + m := NewManager( + &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Paths: &v1alpha1.ProjectPaths{Schemas: "schemas"}, + }, + }, + afero.NewMemMapFs(), + WithSchemaFS(schemaFS), + WithSchemaGenerators(generator.Filter(generator.AllLanguages(), []string{v1alpha1.SchemaLanguageJSON})), + WithXpkgClient(fc), + WithResolver(clixpkg.NewResolver(fc)), + ) + + if _, err := m.AddPackage(context.Background(), cfgPkg+":"+cfgTag, false); err != nil { + t.Fatalf("AddPackage: %v", err) + } + + wantKey := "xpkg://" + cfgPkg + ":" + cfgTag + if diff := cmp.Diff([]string{wantKey}, readLockKeys(t, schemaFS)); diff != "" { + t.Errorf("lock keys (-want +got):\n%s", diff) + } + + files, err := afero.Glob(schemaFS, "json/*.schema.json") + if err != nil { + t.Fatalf("glob generated schemas: %v", err) + } + if len(files) == 0 { + t.Fatal("no JSON schemas were generated for the XRD-bundling Configuration dependency") + } +} diff --git a/internal/project/build_test.go b/internal/project/build_test.go index a027bb28..d931cbce 100644 --- a/internal/project/build_test.go +++ b/internal/project/build_test.go @@ -18,6 +18,7 @@ package project import ( "compress/gzip" + "context" "fmt" "io" "os" @@ -36,9 +37,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project/functions" + "github.com/crossplane/cli/v2/internal/schemas/generator" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) // xrdYAML returns an XRD manifest for a resource with the given group/kind. @@ -297,6 +302,165 @@ func TestBuilderDependsOn(t *testing.T) { } } +// configurationWithXRDPackageYAML is a Configuration package that bundles an +// XRD (rather than a raw CRD) - the shape that, before internal/xpkg learned +// to convert XRDs to their derived CRD form, produced zero schemas. +const configurationWithXRDPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + claimNames: + kind: Database + plural: databases + singular: database + listKind: DatabaseList + scope: LegacyCluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + +// fakePkgClient is a minimal fake xpkg.Client that serves one pre-parsed +// package per exact ref, used to drive a real dependency.Manager in tests +// without a network or registry. +type fakePkgClient struct { + packages map[string]*xpkg.Package + tags []string +} + +func (f *fakePkgClient) Get(_ context.Context, ref string, _ ...xpkg.GetOption) (*xpkg.Package, error) { + pkg, ok := f.packages[ref] + if !ok { + return nil, fmt.Errorf("package not found: %s", ref) //nolint:err113 // test-only fake. + } + return pkg, nil +} + +func (f *fakePkgClient) ListVersions(_ context.Context, _ string, _ ...xpkg.GetOption) ([]string, error) { + return f.tags, nil +} + +// parseFixturePackage parses body into a *parser.Package using the real +// runtime schemes, the same way the xpkg client parses a fetched package. +func parseFixturePackage(t *testing.T, body string) *parser.Package { + t.Helper() + metaScheme, err := xpkg.BuildMetaScheme() + if err != nil { + t.Fatalf("build meta scheme: %v", err) + } + objScheme, err := xpkg.BuildObjectScheme() + if err != nil { + t.Fatalf("build object scheme: %v", err) + } + pkg, err := parser.New(metaScheme, objScheme).Parse(context.Background(), io.NopCloser(strings.NewReader(body))) + if err != nil { + t.Fatalf("parse package: %v", err) + } + return pkg +} + +// TestBuilderBuild_DependencyManagerGeneratesXRDSchemas verifies that +// Builder.Build, wired with a real dependency.Manager (the same +// addPackage/CRDFilesystem path dependency add and update-cache use), drives +// schema generation for a Configuration dependency that bundles XRDs. This +// exercises BuildWithDependencyManager directly, independent of +// internal/dependency's own tests - before internal/xpkg.CRDFilesystem +// learned to convert XRDs, this would have completed the build without +// generating any schema for the dependency. +func TestBuilderBuild_DependencyManagerGeneratesXRDSchemas(t *testing.T) { + t.Parallel() + + const ( + cfgPkg = "xpkg.crossplane.io/example/configuration-xrd" + cfgTag = "v0.1.0" + ) + + projFS := afero.NewMemMapFs() + writeProject(t, projFS, + map[string]string{ + "db.yaml": xrdYAML("acme.example.com", "xwidgets", "xwidget", "XWidget"), + }, + nil, + ) + + proj := &devv1alpha1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + }, + Spec: devv1alpha1.ProjectSpec{ + Repository: "xpkg.crossplane.io/example/test", + Dependencies: []devv1alpha1.Dependency{{ + Type: devv1alpha1.DependencyTypeXpkg, + Xpkg: &devv1alpha1.XpkgDependency{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Configuration", + Package: cfgPkg, + Version: cfgTag, + }, + }}, + }, + } + proj.Default() + + fc := &fakePkgClient{ + packages: map[string]*xpkg.Package{ + cfgPkg + ":" + cfgTag: { + Package: parseFixturePackage(t, configurationWithXRDPackageYAML), + Source: cfgPkg, + Digest: "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + }, + }, + tags: []string{cfgTag}, + } + + schemaFS := afero.NewMemMapFs() + depMgr := dependency.NewManager(proj, projFS, + dependency.WithSchemaFS(schemaFS), + dependency.WithSchemaGenerators(generator.Filter(generator.AllLanguages(), []string{devv1alpha1.SchemaLanguageJSON})), + dependency.WithXpkgClient(fc), + dependency.WithResolver(clixpkg.NewResolver(fc)), + ) + + b := NewBuilder( + BuildWithFunctionIdentifier(functions.FakeIdentifier), + BuildWithDependencyManager(depMgr), + ) + + if _, err := b.Build(t.Context(), proj, projFS); err != nil { + t.Fatalf("Build: %v", err) + } + + files, err := afero.Glob(schemaFS, "json/*.schema.json") + if err != nil { + t.Fatalf("glob generated schemas: %v", err) + } + if len(files) == 0 { + t.Fatal("no JSON schemas were generated for the XRD-bundling Configuration dependency during Build") + } +} + func constructTag(repo, tag string) (name.Tag, error) { return name.NewTag(fmt.Sprintf("%s:%s", repo, tag)) } diff --git a/internal/xpkg/metadata.go b/internal/xpkg/metadata.go index c42b3226..f3b61e23 100644 --- a/internal/xpkg/metadata.go +++ b/internal/xpkg/metadata.go @@ -26,38 +26,121 @@ import ( "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + + xrdv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + xrdv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" ) // CRDFilesystem writes each CRD object in the package to a separate // YAML file in an in-memory filesystem. Files are named // ..yaml so the schema generator sees per-CRD inputs. -// Non-CRD objects in the package are skipped. +// CompositeResourceDefinitions (XRDs) are converted to the +// CustomResourceDefinition(s) Crossplane derives from them - the composite +// resource CRD, and the claim CRD if the XRD offers one. Any other object in +// the package is skipped. func CRDFilesystem(pkg *parser.Package) (afero.Fs, error) { fs := afero.NewMemMapFs() for _, obj := range pkg.GetObjects() { - name, ok := crdFilename(obj) - if !ok { - continue - } - bs, err := yaml.Marshal(obj) + docs, err := crdDocuments(obj) if err != nil { - return nil, errors.Wrapf(err, "cannot marshal CRD %s", name) + return nil, err } - if err := afero.WriteFile(fs, name, bs, 0o644); err != nil { - return nil, errors.Wrapf(err, "cannot write CRD %s", name) + for _, d := range docs { + bs, err := yaml.Marshal(d.obj) + if err != nil { + return nil, errors.Wrapf(err, "cannot marshal CRD %s", d.name) + } + if err := afero.WriteFile(fs, d.name, bs, 0o644); err != nil { + return nil, errors.Wrapf(err, "cannot write CRD %s", d.name) + } } } return fs, nil } -func crdFilename(obj runtime.Object) (string, bool) { +// crdDocument is a single CRD YAML file to write to the output filesystem, +// and the name to write it under. +type crdDocument struct { + name string + obj runtime.Object +} + +// crdDocuments returns the CRD documents obj represents: itself, if it's +// already a CustomResourceDefinition, or the CRD(s) Crossplane derives from +// it, if it's a CompositeResourceDefinition. It returns no documents (and no +// error) for any other kind of object. +func crdDocuments(obj runtime.Object) ([]crdDocument, error) { switch c := obj.(type) { case *apiextv1.CustomResourceDefinition: - return fmt.Sprintf("%s.%s.yaml", c.Spec.Names.Plural, c.Spec.Group), true + return []crdDocument{{name: crdFilename(c.Spec.Names.Plural, c.Spec.Group), obj: c}}, nil case *apiextv1beta1.CustomResourceDefinition: - return fmt.Sprintf("%s.%s.yaml", c.Spec.Names.Plural, c.Spec.Group), true + return []crdDocument{{name: crdFilename(c.Spec.Names.Plural, c.Spec.Group), obj: c}}, nil + case *xrdv1.CompositeResourceDefinition: + return xrdCRDDocuments(c) + case *xrdv2.CompositeResourceDefinition: + v1XRD, err := convertXRDv2ToV1(c) + if err != nil { + return nil, errors.Wrapf(err, "cannot convert XRD %s", c.GetName()) + } + return xrdCRDDocuments(v1XRD) default: - return "", false + return nil, nil } } + +// xrdCRDDocuments derives the CustomResourceDefinition(s) Crossplane +// generates for an XRD: the composite resource CRD, and the claim CRD if +// the XRD offers one. +func xrdCRDDocuments(xrd *xrdv1.CompositeResourceDefinition) ([]crdDocument, error) { + xr, err := xcrd.ForCompositeResource(xrd) + if err != nil { + return nil, errors.Wrapf(err, "cannot derive composite CRD from XRD %s", xrd.GetName()) + } + setCRDTypeMeta(xr) + docs := []crdDocument{{name: crdFilename(xr.Spec.Names.Plural, xr.Spec.Group), obj: xr}} + + if xrd.OffersClaim() { + claim, err := xcrd.ForCompositeResourceClaim(xrd) + if err != nil { + return nil, errors.Wrapf(err, "cannot derive claim CRD from XRD %s", xrd.GetName()) + } + setCRDTypeMeta(claim) + docs = append(docs, crdDocument{name: crdFilename(claim.Spec.Names.Plural, claim.Spec.Group), obj: claim}) + } + return docs, nil +} + +// setCRDTypeMeta sets apiVersion/kind on a CRD derived via xcrd.ForCompositeResource +// or xcrd.ForCompositeResourceClaim, neither of which populates TypeMeta. +// Downstream consumers of CRDFilesystem's output (e.g. the schema +// generators) identify CRD YAML documents by their apiVersion/kind, the same +// way cmd/crossplane/xrd/convert.go's setTypeMeta does for its own derived +// CRDs. +func setCRDTypeMeta(crd *apiextv1.CustomResourceDefinition) { + crd.APIVersion = apiextv1.SchemeGroupVersion.String() + crd.Kind = "CustomResourceDefinition" +} + +// convertXRDv2ToV1 converts an apiextensions.crossplane.io/v2 XRD to the v1 +// shape xcrd.ForCompositeResource requires. The v2 spec is a same-named-field +// subset of the v1 spec (v2 dropped claim support), so a YAML round trip is a +// safe, lossless-for-this-purpose conversion - the same technique used +// elsewhere in this repo (cmd/crossplane/xrd/convert.go, validate/manager.go) +// to interpret an XRD payload against the v1 struct. +func convertXRDv2ToV1(xrd *xrdv2.CompositeResourceDefinition) (*xrdv1.CompositeResourceDefinition, error) { + bs, err := yaml.Marshal(xrd) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal v2 XRD") + } + v1XRD := &xrdv1.CompositeResourceDefinition{} + if err := yaml.Unmarshal(bs, v1XRD); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal v2 XRD as v1") + } + return v1XRD, nil +} + +func crdFilename(plural, group string) string { + return fmt.Sprintf("%s.%s.yaml", plural, group) +} diff --git a/internal/xpkg/metadata_test.go b/internal/xpkg/metadata_test.go new file mode 100644 index 00000000..1fc2c66d --- /dev/null +++ b/internal/xpkg/metadata_test.go @@ -0,0 +1,428 @@ +/* +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 xpkg + +import ( + "context" + "io" + "slices" + "strings" + "testing" + + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" +) + +// providerPackageYAML is a Provider-style package bundling a raw +// CustomResourceDefinition, the way `crdFilename` has always handled it. +const providerPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: things.example.com +spec: + group: example.com + names: + plural: things + kind: Thing + listKind: ThingList + singular: thing + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +` + +// providerV1beta1PackageYAML bundles a v1beta1 CRD, the other branch of the +// existing CRD-only handling. +const providerV1beta1PackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com +spec: + group: example.com + names: + plural: widgets + kind: Widget + listKind: WidgetList + singular: widget + scope: Namespaced + version: v1beta1 +` + +// configurationXRDNoClaimPackageYAML bundles a single XRD with no claim +// names - the repro for the bug this package fixes. +const configurationXRDNoClaimPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + scope: Cluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + +// configurationXRDWithClaimPackageYAML bundles a single XRD that offers a +// claim. +const configurationXRDWithClaimPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + claimNames: + kind: Database + plural: databases + singular: database + listKind: DatabaseList + scope: LegacyCluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + +// configurationXRDv2PackageYAML bundles a single apiextensions.crossplane.io/v2 +// XRD (no claim support in v2). +const configurationXRDv2PackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v2.0.0" +--- +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + +// configurationCRDAndXRDPackageYAML bundles both a raw CRD and an XRD in the +// same package. +const configurationCRDAndXRDPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: things.example.com +spec: + group: example.com + names: + plural: things + kind: Thing + listKind: ThingList + singular: thing + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdatabases.acme.example.com +spec: + group: acme.example.com + names: + kind: XDatabase + plural: xdatabases + singular: xdatabase + listKind: XDatabaseList + scope: Cluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +` + +// parseTestPackage parses body into a *parser.Package using the real runtime +// schemes, the same way the xpkg client parses a fetched package. +func parseTestPackage(t *testing.T, body string) *parser.Package { + t.Helper() + metaScheme, err := runtimexpkg.BuildMetaScheme() + if err != nil { + t.Fatalf("build meta scheme: %v", err) + } + objScheme, err := runtimexpkg.BuildObjectScheme() + if err != nil { + t.Fatalf("build object scheme: %v", err) + } + pkg, err := parser.New(metaScheme, objScheme).Parse(context.Background(), io.NopCloser(strings.NewReader(body))) + if err != nil { + t.Fatalf("parse package: %v", err) + } + return pkg +} + +// lsFS returns the sorted names of all files in fs. +func lsFS(t *testing.T, fs afero.Fs) []string { + t.Helper() + infos, err := afero.ReadDir(fs, "/") + if err != nil { + t.Fatalf("read dir: %v", err) + } + names := make([]string, 0, len(infos)) + for _, info := range infos { + names = append(names, info.Name()) + } + slices.Sort(names) + return names +} + +func readCRD(t *testing.T, fs afero.Fs, name string) *extv1.CustomResourceDefinition { + t.Helper() + bs, err := afero.ReadFile(fs, name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + crd := &extv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(bs, crd); err != nil { + t.Fatalf("unmarshal %s: %v", name, err) + } + // Downstream schema generators identify CRD documents by apiVersion/kind + // (see internal/schemas/generator's goCollectOpenAPIs), so every CRD + // CRDFilesystem writes - including ones derived from an XRD - must carry + // them, even though xcrd.ForCompositeResource/ForCompositeResourceClaim + // don't set them on the object they return. + if crd.APIVersion != extv1.SchemeGroupVersion.String() || crd.Kind != "CustomResourceDefinition" { + t.Errorf("%s: apiVersion/kind = %q/%q, want %q/%q", name, crd.APIVersion, crd.Kind, extv1.SchemeGroupVersion.String(), "CustomResourceDefinition") + } + return crd +} + +func TestCRDFilesystem_ProviderCRDOnly(t *testing.T) { + pkg := parseTestPackage(t, providerPackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"things.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } + + crd := readCRD(t, fs, "things.example.com.yaml") + if crd.Spec.Group != "example.com" || crd.Spec.Names.Plural != "things" || crd.Spec.Names.Kind != "Thing" { + t.Errorf("unexpected CRD content: %+v", crd.Spec) + } +} + +func TestCRDFilesystem_ProviderCRDv1beta1(t *testing.T) { + pkg := parseTestPackage(t, providerV1beta1PackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"widgets.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } +} + +func TestCRDFilesystem_XRDOnlyNoClaim(t *testing.T) { + pkg := parseTestPackage(t, configurationXRDNoClaimPackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"xdatabases.acme.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } + + crd := readCRD(t, fs, "xdatabases.acme.example.com.yaml") + if crd.Spec.Group != "acme.example.com" || crd.Spec.Names.Kind != "XDatabase" { + t.Errorf("unexpected CRD content: %+v", crd.Spec) + } + if !slices.Contains(crd.Spec.Names.Categories, xcrd.CategoryComposite) { + t.Errorf("derived CRD missing composite category: %v", crd.Spec.Names.Categories) + } +} + +func TestCRDFilesystem_XRDWithClaim(t *testing.T) { + pkg := parseTestPackage(t, configurationXRDWithClaimPackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"databases.acme.example.com.yaml", "xdatabases.acme.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } + + composite := readCRD(t, fs, "xdatabases.acme.example.com.yaml") + if !slices.Contains(composite.Spec.Names.Categories, xcrd.CategoryComposite) { + t.Errorf("composite CRD missing composite category: %v", composite.Spec.Names.Categories) + } + + claim := readCRD(t, fs, "databases.acme.example.com.yaml") + if claim.Spec.Names.Kind != "Database" { + t.Errorf("unexpected claim CRD kind: %s", claim.Spec.Names.Kind) + } + if !slices.Contains(claim.Spec.Names.Categories, xcrd.CategoryClaim) { + t.Errorf("claim CRD missing claim category: %v", claim.Spec.Names.Categories) + } +} + +func TestCRDFilesystem_XRDv2(t *testing.T) { + pkg := parseTestPackage(t, configurationXRDv2PackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"xdatabases.acme.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } + + crd := readCRD(t, fs, "xdatabases.acme.example.com.yaml") + if crd.Spec.Group != "acme.example.com" || crd.Spec.Names.Kind != "XDatabase" { + t.Errorf("unexpected CRD content: %+v", crd.Spec) + } +} + +func TestCRDFilesystem_MixedCRDAndXRD(t *testing.T) { + pkg := parseTestPackage(t, configurationCRDAndXRDPackageYAML) + + fs, err := CRDFilesystem(pkg) + if err != nil { + t.Fatalf("CRDFilesystem: %v", err) + } + + wantFiles := []string{"things.example.com.yaml", "xdatabases.acme.example.com.yaml"} + if diff := cmpNames(wantFiles, lsFS(t, fs)); diff != "" { + t.Errorf("files (-want +got):\n%s", diff) + } +} + +// cmpNames returns a diff-style string if want and got differ, else "". +func cmpNames(want, got []string) string { + if slices.Equal(want, got) { + return "" + } + return "want: " + strings.Join(want, ", ") + "\ngot: " + strings.Join(got, ", ") +}