diff --git a/pkg/config/conversion/conversions.go b/pkg/config/conversion/conversions.go new file mode 100644 index 00000000..ba5a2da1 --- /dev/null +++ b/pkg/config/conversion/conversions.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + // AllVersions denotes that a Conversion is applicable for all versions + // of an API with which the Conversion is registered. It can be used for + // both the conversion source or target API versions. + AllVersions = "*" +) + +// Conversion is the interface for the API version converters. +// Conversion implementations registered for a source, target +// pair are called in chain so Conversion implementations can be modular, e.g., +// a Conversion implementation registered for a specific source and target +// versions does not have to contain all the needed API conversions between +// these two versions. +type Conversion interface { + // Applicable should return true if this Conversion is applicable while + // converting the API of the `src` object to the API of the `dst` object. + Applicable(src, dst runtime.Object) bool +} + +// PavedConversion is an optimized Conversion between two fieldpath.Paved +// objects. PavedConversion implementations for a specific source and target +// version pair are chained together and the source and the destination objects +// are paved once at the beginning of the chained PavedConversion.ConvertPaved +// calls. The target fieldpath.Paved object is then converted into the original +// resource.Terraformed object at the end of the chained calls. This prevents +// the intermediate conversions between fieldpath.Paved and +// the resource.Terraformed representations of the same object, and the +// fieldpath.Paved representation is convenient for writing generic +// Conversion implementations not bound to a specific type. +type PavedConversion interface { + Conversion + // ConvertPaved converts from the `src` paved object to the `dst` + // paved object and returns `true` if the conversion has been done, + // `false` otherwise, together with any errors encountered. + ConvertPaved(src, target *fieldpath.Paved) (bool, error) +} + +// ManagedConversion defines a Conversion from a specific source +// resource.Managed type to a target one. Generic Conversion +// implementations may prefer to implement the PavedConversion interface. +// Implementations of ManagedConversion can do type assertions to +// specific source and target types, and so, they are expected to be +// strongly typed. +type ManagedConversion interface { + Conversion + // ConvertManaged converts from the `src` managed resource to the `dst` + // managed resource and returns `true` if the conversion has been done, + // `false` otherwise, together with any errors encountered. + ConvertManaged(src, target resource.Managed) (bool, error) +} + +type baseConversion struct { + sourceVersion string + targetVersion string +} + +func newBaseConversion(sourceVersion, targetVersion string) baseConversion { + return baseConversion{ + sourceVersion: sourceVersion, + targetVersion: targetVersion, + } +} + +func (c *baseConversion) Applicable(src, dst runtime.Object) bool { + return (c.sourceVersion == AllVersions || c.sourceVersion == src.GetObjectKind().GroupVersionKind().Version) && + (c.targetVersion == AllVersions || c.targetVersion == dst.GetObjectKind().GroupVersionKind().Version) +} + +type fieldCopy struct { + baseConversion + sourceField string + targetField string +} + +func (f *fieldCopy) ConvertPaved(src, target *fieldpath.Paved) (bool, error) { + if !f.Applicable(&unstructured.Unstructured{Object: src.UnstructuredContent()}, + &unstructured.Unstructured{Object: target.UnstructuredContent()}) { + return false, nil + } + v, err := src.GetValue(f.sourceField) + // TODO: the field might actually exist in the schema and + // missing in the object. Or, it may not exist in the schema. + // For a field that does not exist in the schema, we had better error. + if fieldpath.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, errors.Wrapf(err, "failed to get the field %q from the conversion source object", f.sourceField) + } + return true, errors.Wrapf(target.SetValue(f.targetField, v), "failed to set the field %q of the conversion target object", f.targetField) +} + +// NewFieldRenameConversion returns a new Conversion that implements a +// field renaming conversion from the specified `sourceVersion` to the specified +// `targetVersion` of an API. The field's name in the `sourceVersion` is given +// with the `sourceField` parameter and its name in the `targetVersion` is +// given with `targetField` parameter. +func NewFieldRenameConversion(sourceVersion, sourceField, targetVersion, targetField string) Conversion { + return &fieldCopy{ + baseConversion: newBaseConversion(sourceVersion, targetVersion), + sourceField: sourceField, + targetField: targetField, + } +} + +type customConverter func(src, target resource.Managed) error + +type customConversion struct { + baseConversion + customConverter customConverter +} + +func (cc *customConversion) ConvertManaged(src, target resource.Managed) (bool, error) { + if !cc.Applicable(src, target) || cc.customConverter == nil { + return false, nil + } + return true, errors.Wrap(cc.customConverter(src, target), "failed to apply the converter function") +} + +// NewCustomConverter returns a new Conversion from the specified +// `sourceVersion` of an API to the specified `targetVersion` and invokes +// the specified converter function to perform the conversion on the +// managed resources. +func NewCustomConverter(sourceVersion, targetVersion string, converter func(src, target resource.Managed) error) Conversion { + return &customConversion{ + baseConversion: newBaseConversion(sourceVersion, targetVersion), + customConverter: converter, + } +} diff --git a/pkg/config/conversion/conversions_test.go b/pkg/config/conversion/conversions_test.go new file mode 100644 index 00000000..03de9080 --- /dev/null +++ b/pkg/config/conversion/conversions_test.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "fmt" + "testing" + + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" + "k8s.io/utils/ptr" +) + +const ( + sourceVersion = "v1beta1" + sourceField = "testSourceField" + targetVersion = "v1beta2" + targetField = "testTargetField" +) + +func TestConvertPaved(t *testing.T) { + type args struct { + sourceVersion string + sourceField string + targetVersion string + targetField string + sourceObj *fieldpath.Paved + targetObj *fieldpath.Paved + } + type want struct { + converted bool + err error + targetObj *fieldpath.Paved + } + tests := map[string]struct { + reason string + args args + want want + }{ + "SuccessfulConversion": { + reason: "Source field in source version is successfully converted to the target field in target version.", + args: args{ + sourceVersion: sourceVersion, + sourceField: sourceField, + targetVersion: targetVersion, + targetField: targetField, + sourceObj: getPaved(sourceVersion, sourceField, ptr.To("testValue")), + targetObj: getPaved(targetVersion, targetField, nil), + }, + want: want{ + converted: true, + targetObj: getPaved(targetVersion, targetField, ptr.To("testValue")), + }, + }, + "SuccessfulConversionAllVersions": { + reason: "Source field in source version is successfully converted to the target field in target version when the conversion specifies wildcard version for both of the source and the target.", + args: args{ + sourceVersion: AllVersions, + sourceField: sourceField, + targetVersion: AllVersions, + targetField: targetField, + sourceObj: getPaved(sourceVersion, sourceField, ptr.To("testValue")), + targetObj: getPaved(targetVersion, targetField, nil), + }, + want: want{ + converted: true, + targetObj: getPaved(targetVersion, targetField, ptr.To("testValue")), + }, + }, + "SourceVersionMismatch": { + reason: "Conversion is not done if the source version of the object does not match the conversion's source version.", + args: args{ + sourceVersion: "mismatch", + sourceField: sourceField, + targetVersion: AllVersions, + targetField: targetField, + sourceObj: getPaved(sourceVersion, sourceField, ptr.To("testValue")), + targetObj: getPaved(targetVersion, targetField, nil), + }, + want: want{ + converted: false, + targetObj: getPaved(targetVersion, targetField, nil), + }, + }, + "TargetVersionMismatch": { + reason: "Conversion is not done if the target version of the object does not match the conversion's target version.", + args: args{ + sourceVersion: AllVersions, + sourceField: sourceField, + targetVersion: "mismatch", + targetField: targetField, + sourceObj: getPaved(sourceVersion, sourceField, ptr.To("testValue")), + targetObj: getPaved(targetVersion, targetField, nil), + }, + want: want{ + converted: false, + targetObj: getPaved(targetVersion, targetField, nil), + }, + }, + "SourceFieldNotFound": { + reason: "Conversion is not done if the source field is not found in the source object.", + args: args{ + sourceVersion: sourceVersion, + sourceField: sourceField, + targetVersion: targetVersion, + targetField: targetField, + sourceObj: getPaved(sourceVersion, sourceField, nil), + targetObj: getPaved(targetVersion, targetField, ptr.To("test")), + }, + want: want{ + converted: false, + targetObj: getPaved(targetVersion, targetField, ptr.To("test")), + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c := NewFieldRenameConversion(tc.args.sourceVersion, tc.args.sourceField, tc.args.targetVersion, tc.args.targetField) + converted, err := c.(*fieldCopy).ConvertPaved(tc.args.sourceObj, tc.args.targetObj) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nConvertPaved(sourceObj, targetObj): -wantErr, +gotErr:\n%s", tc.reason, diff) + } + if tc.want.err != nil { + return + } + if diff := cmp.Diff(tc.want.converted, converted); diff != "" { + t.Errorf("\n%s\nConvertPaved(sourceObj, targetObj): -wantConverted, +gotConverted:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.targetObj.UnstructuredContent(), tc.args.targetObj.UnstructuredContent()); diff != "" { + t.Errorf("\n%s\nConvertPaved(sourceObj, targetObj): -wantTargetObj, +gotTargetObj:\n%s", tc.reason, diff) + } + }) + } +} + +func getPaved(version, field string, value *string) *fieldpath.Paved { + m := map[string]any{ + "apiVersion": fmt.Sprintf("mockgroup/%s", version), + "kind": "mockkind", + } + if value != nil { + m[field] = *value + } + return fieldpath.Pave(m) +} diff --git a/pkg/config/resource.go b/pkg/config/resource.go index 1ad1e777..d6f9fa2c 100644 --- a/pkg/config/resource.go +++ b/pkg/config/resource.go @@ -21,6 +21,7 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/crossplane/upjet/pkg/config/conversion" "github.com/crossplane/upjet/pkg/registry" ) @@ -446,6 +447,8 @@ type Resource struct { // index notation (i.e., array/map components do not need indices). ServerSideApplyMergeStrategies ServerSideApplyMergeStrategies + Conversions []conversion.Conversion + // useNoForkClient indicates that a no-fork external client should // be generated instead of the Terraform CLI-forking client. useNoForkClient bool diff --git a/pkg/controller/conversion/functions.go b/pkg/controller/conversion/functions.go new file mode 100644 index 00000000..33619094 --- /dev/null +++ b/pkg/controller/conversion/functions.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/upjet/pkg/config/conversion" + "github.com/crossplane/upjet/pkg/resource" +) + +// RoundTrip round-trips from `src` to `dst` via an unstructured map[string]any +// representation of the `src` object and applies the registered webhook +// conversion functions of this registry. +func (r *registry) RoundTrip(dst, src resource.Terraformed) error { //nolint:gocyclo // considered breaking this according to the converters and I did not like it + srcMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(src) + if err != nil { + return errors.Wrap(err, "cannot convert the conversion source object into the map[string]any representation") + } + gvk := dst.GetObjectKind().GroupVersionKind() + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(srcMap, dst); err != nil { + return errors.Wrap(err, "cannot convert the map[string]any representation of the source object to the conversion target object") + } + // restore the original GVK for the conversion destination + dst.GetObjectKind().SetGroupVersionKind(gvk) + + // now we will try to run the registered webhook conversions + dstMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dst) + if err != nil { + return errors.Wrap(err, "cannot convert the conversion destination object into the map[string]any representation") + } + srcPaved := fieldpath.Pave(srcMap) + dstPaved := fieldpath.Pave(dstMap) + for _, c := range r.GetConversions(dst) { + if pc, ok := c.(conversion.PavedConversion); ok { + if _, err := pc.ConvertPaved(srcPaved, dstPaved); err != nil { + return errors.Wrapf(err, "cannot apply the PavedConversion for the %q object", dst.GetTerraformResourceType()) + } + } + } + // convert the map[string]any representation of the conversion target back to + // the original type. + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(dstMap, dst); err != nil { + return errors.Wrap(err, "cannot convert the map[string]any representation of the conversion target object to the target object") + } + + for _, c := range r.GetConversions(dst) { + if tc, ok := c.(conversion.ManagedConversion); ok { + if _, err := tc.ConvertManaged(src, dst); err != nil { + return errors.Wrapf(err, "cannot apply the TerraformedConversion for the %q object", dst.GetTerraformResourceType()) + } + } + } + + return nil +} + +// RoundTrip round-trips from `src` to `dst` via an unstructured map[string]any +// representation of the `src` object and applies the registered webhook +// conversion functions. +func RoundTrip(dst, src resource.Terraformed) error { + return instance.RoundTrip(dst, src) +} diff --git a/pkg/controller/conversion/functions_test.go b/pkg/controller/conversion/functions_test.go new file mode 100644 index 00000000..1e7ebd4d --- /dev/null +++ b/pkg/controller/conversion/functions_test.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "fmt" + "testing" + + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/upjet/pkg/config" + "github.com/crossplane/upjet/pkg/config/conversion" + "github.com/crossplane/upjet/pkg/resource" + "github.com/crossplane/upjet/pkg/resource/fake" +) + +const ( + key1 = "key1" + val1 = "val1" + key2 = "key2" + val2 = "val2" + commonKey = "commonKey" + commonVal = "commonVal" +) + +func TestRoundTrip(t *testing.T) { + type args struct { + dst resource.Terraformed + src resource.Terraformed + conversions []conversion.Conversion + } + type want struct { + err error + dst resource.Terraformed + } + tests := map[string]struct { + reason string + args args + want want + }{ + "SuccessfulRoundTrip": { + reason: "Source object is successfully copied into the target object.", + args: args{ + dst: fake.NewTerraformed(), + src: fake.NewTerraformed(fake.WithParameters(fake.NewMap(key1, val1))), + }, + want: want{ + dst: fake.NewTerraformed(fake.WithParameters(fake.NewMap(key1, val1))), + }, + }, + "SuccessfulRoundTripWithConversions": { + reason: "Source object is successfully converted into the target object with a set of conversions.", + args: args{ + dst: fake.NewTerraformed(), + src: fake.NewTerraformed(fake.WithParameters(fake.NewMap(commonKey, commonVal, key1, val1))), + conversions: []conversion.Conversion{ + // Because the parameters of the fake.Terraformed is an unstructured + // map, all the fields of source (including key1) are successfully + // copied into dst by registry.RoundTrip. + // This conversion deletes the copied key "key1". + conversion.NewCustomConverter(conversion.AllVersions, conversion.AllVersions, func(_, target xpresource.Managed) error { + tr := target.(*fake.Terraformed) + delete(tr.Parameters, key1) + return nil + }), + conversion.NewFieldRenameConversion(conversion.AllVersions, fmt.Sprintf("parameterizable.parameters.%s", key1), conversion.AllVersions, fmt.Sprintf("parameterizable.parameters.%s", key2)), + }, + }, + want: want{ + dst: fake.NewTerraformed(fake.WithParameters(fake.NewMap(commonKey, commonVal, key2, val1))), + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + p := &config.Provider{ + Resources: map[string]*config.Resource{ + tc.args.dst.GetTerraformResourceType(): { + Conversions: tc.args.conversions, + }, + }, + } + r := ®istry{} + if err := r.RegisterConversions(p); err != nil { + t.Fatalf("\n%s\nRegisterConversions(p): Failed to register the conversions with the registry.\n", tc.reason) + } + err := r.RoundTrip(tc.args.dst, tc.args.src) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nRoundTrip(dst, src): -wantErr, +gotErr:\n%s", tc.reason, diff) + } + if tc.want.err != nil { + return + } + if diff := cmp.Diff(tc.want.dst, tc.args.dst); diff != "" { + t.Errorf("\n%s\nRoundTrip(dst, src): -wantDst, +gotDst:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/controller/conversion/registry.go b/pkg/controller/conversion/registry.go new file mode 100644 index 00000000..ab1fac5b --- /dev/null +++ b/pkg/controller/conversion/registry.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "github.com/pkg/errors" + + "github.com/crossplane/upjet/pkg/config" + "github.com/crossplane/upjet/pkg/config/conversion" + "github.com/crossplane/upjet/pkg/resource" +) + +const ( + errAlreadyRegistered = "conversion functions are already registered" +) + +var instance *registry + +// registry represents the conversion hook registry for a provider. +type registry struct { + provider *config.Provider +} + +// RegisterConversions registers the API version conversions from the specified +// provider configuration with this registry. +func (r *registry) RegisterConversions(provider *config.Provider) error { + if r.provider != nil { + return errors.New(errAlreadyRegistered) + } + r.provider = provider + return nil +} + +// GetConversions returns the conversion.Conversions registered in this +// registry for the specified Terraformed resource. +func (r *registry) GetConversions(tr resource.Terraformed) []conversion.Conversion { + t := tr.GetTerraformResourceType() + if r == nil || r.provider == nil || r.provider.Resources[t] == nil { + return nil + } + return r.provider.Resources[t].Conversions +} + +// GetConversions returns the conversion.Conversions registered for the +// specified Terraformed resource. +func GetConversions(tr resource.Terraformed) []conversion.Conversion { + return instance.GetConversions(tr) +} + +// RegisterConversions registers the API version conversions from the specified +// provider configuration. +func RegisterConversions(provider *config.Provider) error { + if instance != nil { + return errors.New(errAlreadyRegistered) + } + instance = ®istry{} + return instance.RegisterConversions(provider) +} diff --git a/pkg/controller/options.go b/pkg/controller/options.go index 353ef866..1bba55cb 100644 --- a/pkg/controller/options.go +++ b/pkg/controller/options.go @@ -45,6 +45,10 @@ type Options struct { // PollJitter adds the specified jitter to the configured reconcile period // of the up-to-date resources in managed.Reconciler. PollJitter time.Duration + + // StartWebhooks enables starting of the conversion webhooks by the + // provider's controllerruntime.Manager. + StartWebhooks bool } // ESSOptions for External Secret Stores. diff --git a/pkg/examples/example.go b/pkg/examples/example.go index 8c6981e6..363a3f46 100644 --- a/pkg/examples/example.go +++ b/pkg/examples/example.go @@ -185,7 +185,7 @@ func (eg *Generator) Generate(group, version string, r *config.Resource) error { // e.g. gvk = ec2/v1beta1/instance gvk := fmt.Sprintf("%s/%s/%s", groupPrefix, version, strings.ToLower(r.Kind)) pm := paveCRManifest(rm.Examples[0].Paved.UnstructuredContent(), r, rm.Examples[0].Name, group, version, gvk) - manifestDir := filepath.Join(eg.rootDir, "examples-generated", groupPrefix) + manifestDir := filepath.Join(eg.rootDir, "examples-generated", groupPrefix, r.Version) pm.ManifestPath = filepath.Join(manifestDir, fmt.Sprintf("%s.yaml", strings.ToLower(r.Kind))) eg.resources[fmt.Sprintf("%s.%s", r.Name, reference.Wildcard)] = pm return nil diff --git a/pkg/pipeline/conversion_hub.go b/pkg/pipeline/conversion_hub.go new file mode 100644 index 00000000..f04372b8 --- /dev/null +++ b/pkg/pipeline/conversion_hub.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "go/types" + "os" + "path/filepath" + "strings" + + "github.com/muvaf/typewriter/pkg/wrapper" + "github.com/pkg/errors" + + "github.com/crossplane/upjet/pkg/pipeline/templates" +) + +// NewConversionHubGenerator returns a new ConversionHubGenerator. +func NewConversionHubGenerator(pkg *types.Package, rootDir, group, version string) *ConversionHubGenerator { + return &ConversionHubGenerator{ + LocalDirectoryPath: filepath.Join(rootDir, "apis", strings.ToLower(strings.Split(group, ".")[0]), version), + LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), + pkg: pkg, + } +} + +// ConversionHubGenerator generates conversion methods implementing the +// conversion.Hub interface on the CRD structs. +type ConversionHubGenerator struct { + LocalDirectoryPath string + LicenseHeaderPath string + + pkg *types.Package +} + +// Generate writes generated conversion.Hub interface functions +func (cg *ConversionHubGenerator) Generate(cfgs []*terraformedInput, apiVersion string) error { + trFile := wrapper.NewFile(cg.pkg.Path(), cg.pkg.Name(), templates.ConversionHubTemplate, + wrapper.WithGenStatement(GenStatement), + wrapper.WithHeaderPath(cg.LicenseHeaderPath), + ) + filePath := filepath.Join(cg.LocalDirectoryPath, "zz_generated.conversion_hubs.go") + vars := map[string]any{ + "APIVersion": apiVersion, + } + resources := make([]map[string]any, len(cfgs)) + index := 0 + for _, cfg := range cfgs { + resources[index] = map[string]any{ + "CRD": map[string]string{ + "Kind": cfg.Kind, + }, + } + index++ + } + vars["Resources"] = resources + if len(resources) == 0 { + return nil + } + return errors.Wrapf( + trFile.Write(filePath, vars, os.ModePerm), + "cannot write the generated conversion Hub functions file %s", filePath, + ) +} diff --git a/pkg/pipeline/conversion_spoke.go b/pkg/pipeline/conversion_spoke.go new file mode 100644 index 00000000..1811b111 --- /dev/null +++ b/pkg/pipeline/conversion_spoke.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "fmt" + "go/types" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/muvaf/typewriter/pkg/wrapper" + "github.com/pkg/errors" + + "github.com/crossplane/upjet/pkg/pipeline/templates" +) + +var ( + regexTypeFile = regexp.MustCompile(`zz_(.+)_types.go`) +) + +// NewConversionSpokeGenerator returns a new ConversionSpokeGenerator. +func NewConversionSpokeGenerator(pkg *types.Package, rootDir, group, version string) *ConversionSpokeGenerator { + return &ConversionSpokeGenerator{ + LocalDirectoryPath: filepath.Join(rootDir, "apis", strings.ToLower(strings.Split(group, ".")[0])), + LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), + SpokeVersionsMap: make(map[string][]string), + pkg: pkg, + version: version, + } +} + +// ConversionSpokeGenerator generates conversion methods implementing the +// conversion.Convertible interface on the CRD structs. +type ConversionSpokeGenerator struct { + LocalDirectoryPath string + LicenseHeaderPath string + SpokeVersionsMap map[string][]string + + pkg *types.Package + version string +} + +// Generate writes generated conversion.Convertible interface functions +func (cg *ConversionSpokeGenerator) Generate(cfgs []*terraformedInput) error { //nolint:gocyclo + entries, err := os.ReadDir(cg.LocalDirectoryPath) + if err != nil { + return errors.Wrapf(err, "cannot list the directory entries for the source folder %s while generating the conversion.Convertible interface functions", cg.LocalDirectoryPath) + } + + for _, e := range entries { + if !e.IsDir() || e.Name() == cg.version { + // we skip spoke generation for the current version as the assumption is + // the current CRD version is the hub version. + continue + } + trFile := wrapper.NewFile(cg.pkg.Path(), cg.pkg.Name(), templates.ConversionSpokeTemplate, + wrapper.WithGenStatement(GenStatement), + wrapper.WithHeaderPath(cg.LicenseHeaderPath), + ) + filePath := filepath.Join(cg.LocalDirectoryPath, e.Name(), "zz_generated.conversion_spokes.go") + vars := map[string]any{ + "APIVersion": e.Name(), + } + + var resources []map[string]any + versionDir := filepath.Join(cg.LocalDirectoryPath, e.Name()) + files, err := os.ReadDir(versionDir) + if err != nil { + return errors.Wrapf(err, "cannot list the directory entries for the source folder %s while looking for the generated types", versionDir) + } + for _, f := range files { + if f.IsDir() { + continue + } + m := regexTypeFile.FindStringSubmatch(f.Name()) + if len(m) < 2 { + continue + } + c := findKindTerraformedInput(cfgs, m[1]) + if c == nil { + // type may not be available in the new version => + // no conversion is possible. + continue + } + resources = append(resources, map[string]any{ + "CRD": map[string]string{ + "Kind": c.Kind, + }, + }) + sk := fmt.Sprintf("%s.%s", c.ShortGroup, c.Kind) + cg.SpokeVersionsMap[sk] = append(cg.SpokeVersionsMap[sk], filepath.Base(versionDir)) + } + + vars["Resources"] = resources + if len(resources) == 0 { + continue + } + if err := trFile.Write(filePath, vars, os.ModePerm); err != nil { + return errors.Wrapf(err, "cannot write the generated conversion Hub functions file %s", filePath) + } + } + return nil +} + +func findKindTerraformedInput(cfgs []*terraformedInput, name string) *terraformedInput { + for _, c := range cfgs { + if strings.EqualFold(name, c.Kind) { + return c + } + } + return nil +} diff --git a/pkg/pipeline/run.go b/pkg/pipeline/run.go index 4d707984..bc82d99b 100644 --- a/pkg/pipeline/run.go +++ b/pkg/pipeline/run.go @@ -94,6 +94,8 @@ func Run(pc *config.Provider, rootDir string) { //nolint:gocyclo versionGen := NewVersionGenerator(rootDir, pc.ModulePath, group, version) crdGen := NewCRDGenerator(versionGen.Package(), rootDir, pc.ShortName, group, version) tfGen := NewTerraformedGenerator(versionGen.Package(), rootDir, group, version) + conversionHubGen := NewConversionHubGenerator(versionGen.Package(), rootDir, group, version) + conversionSpokeGen := NewConversionSpokeGenerator(versionGen.Package(), rootDir, group, version) ctrlGen := NewControllerGenerator(rootDir, pc.ModulePath, group) for _, name := range sortedResources(resources) { @@ -127,10 +129,28 @@ func Run(pc *config.Provider, rootDir string) { //nolint:gocyclo panic(errors.Wrapf(err, "cannot generate terraformed for resource %s", group)) } + if err := conversionHubGen.Generate(tfResources, version); err != nil { + panic(errors.Wrapf(err, "cannot generate the conversion.Hub function for the resource group %q", group)) + } + + if err := conversionSpokeGen.Generate(tfResources); err != nil { + panic(errors.Wrapf(err, "cannot generate the conversion.Convertible functions for the resource group %q", group)) + } + if err := versionGen.Generate(); err != nil { panic(errors.Wrap(err, "cannot generate version files")) } - apiVersionPkgList = append(apiVersionPkgList, versionGen.Package().Path()) + p := versionGen.Package().Path() + apiVersionPkgList = append(apiVersionPkgList, p) + for _, r := range resources { + // if there are spoke versions for the given group.Kind + if spokeVersions := conversionSpokeGen.SpokeVersionsMap[fmt.Sprintf("%s.%s", r.ShortGroup, r.Kind)]; spokeVersions != nil { + base := filepath.Dir(p) + for _, sv := range spokeVersions { + apiVersionPkgList = append(apiVersionPkgList, filepath.Join(base, sv)) + } + } + } } } diff --git a/pkg/pipeline/templates/controller.go.tmpl b/pkg/pipeline/templates/controller.go.tmpl index e75d3714..5b584dc2 100644 --- a/pkg/pipeline/templates/controller.go.tmpl +++ b/pkg/pipeline/templates/controller.go.tmpl @@ -19,6 +19,7 @@ import ( "github.com/crossplane/upjet/pkg/controller/handler" tjcontroller "github.com/crossplane/upjet/pkg/controller" "github.com/crossplane/upjet/pkg/terraform" + "github.com/pkg/errors" ctrl "sigs.k8s.io/controller-runtime" {{ .Imports }} @@ -96,6 +97,17 @@ func Setup(mgr ctrl.Manager, o tjcontroller.Options) error { opts = append(opts, managed.WithManagementPolicies()) } {{- end}} + + // register webhooks for the kind {{ .TypePackageAlias }}{{ .CRD.Kind }} + // if they're enabled. + if o.StartWebhooks { + if err := ctrl.NewWebhookManagedBy(mgr). + For(&{{ .TypePackageAlias }}{{ .CRD.Kind }}{}). + Complete(); err != nil { + return errors.Wrap(err, "cannot register webhook for the kind {{ .TypePackageAlias }}{{ .CRD.Kind }}") + } + } + r := managed.NewReconciler(mgr, xpresource.ManagedKind({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind), opts...) return ctrl.NewControllerManagedBy(mgr). diff --git a/pkg/pipeline/templates/conversion_hub.go.tmpl b/pkg/pipeline/templates/conversion_hub.go.tmpl new file mode 100644 index 00000000..5747ebd5 --- /dev/null +++ b/pkg/pipeline/templates/conversion_hub.go.tmpl @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +{{ .Header }} + +{{ .GenStatement }} + +package {{ .APIVersion }} + +{{ range .Resources }} + // Hub marks this type as a conversion hub. + func (tr *{{ .CRD.Kind }}) Hub() {} +{{ end }} diff --git a/pkg/pipeline/templates/conversion_spoke.go.tmpl b/pkg/pipeline/templates/conversion_spoke.go.tmpl new file mode 100644 index 00000000..c76577a3 --- /dev/null +++ b/pkg/pipeline/templates/conversion_spoke.go.tmpl @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 The Crossplane Authors +// +// SPDX-License-Identifier: Apache-2.0 + +{{ .Header }} + +{{ .GenStatement }} + +package {{ .APIVersion }} + +import ( + ujconversion "github.com/crossplane/upjet/pkg/controller/conversion" + "github.com/crossplane/upjet/pkg/resource" + "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/conversion" +) + +{{ range .Resources }} + // ConvertTo converts this {{ .CRD.Kind }} to the hub type. + func (tr *{{ .CRD.Kind }}) ConvertTo(dstRaw conversion.Hub) error { + if err := ujconversion.RoundTrip(dstRaw.(resource.Terraformed), tr); err != nil { + return errors.Wrapf(err, "cannot convert from the spoke version %q to the hub version %q", tr.GetObjectKind().GroupVersionKind().Version, dstRaw.GetObjectKind().GroupVersionKind().Version) + } + return nil + } + + // ConvertFrom converts from the hub type to the {{ .CRD.Kind }} type. + func (tr *{{ .CRD.Kind }}) ConvertFrom(srcRaw conversion.Hub) error { + if err := ujconversion.RoundTrip(tr, srcRaw.(resource.Terraformed)); err != nil { + return errors.Wrapf(err, "cannot convert from the hub version %q to the spoke version %q", srcRaw.GetObjectKind().GroupVersionKind().Version, tr.GetObjectKind().GroupVersionKind().Version) + } + return nil + } +{{ end }} diff --git a/pkg/pipeline/templates/crd_types.go.tmpl b/pkg/pipeline/templates/crd_types.go.tmpl index a3f9adba..4b7a5186 100644 --- a/pkg/pipeline/templates/crd_types.go.tmpl +++ b/pkg/pipeline/templates/crd_types.go.tmpl @@ -41,13 +41,13 @@ type {{ .CRD.Kind }}Status struct { } // +kubebuilder:object:root=true +// +kubebuilder:subresource:status // {{ .CRD.Kind }} is the Schema for the {{ .CRD.Kind }}s API. {{ .CRD.Description }} // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" // +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status" // +kubebuilder:printcolumn:name="EXTERNAL-NAME",type="string",JSONPath=".metadata.annotations.crossplane\\.io/external-name" // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" -// +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,{{ .Provider.ShortName }}}{{ if .CRD.Path }},path={{ .CRD.Path }}{{ end }} type {{ .CRD.Kind }} struct { metav1.TypeMeta `json:",inline"` diff --git a/pkg/pipeline/templates/embed.go b/pkg/pipeline/templates/embed.go index c809057a..00cdb169 100644 --- a/pkg/pipeline/templates/embed.go +++ b/pkg/pipeline/templates/embed.go @@ -36,3 +36,15 @@ var RegisterTemplate string // //go:embed setup.go.tmpl var SetupTemplate string + +// ConversionHubTemplate is populated with the CRD API versions +// conversion.Hub implementation template string. +// +//go:embed conversion_hub.go.tmpl +var ConversionHubTemplate string + +// ConversionSpokeTemplate is populated with the CRD API versions +// conversion.Convertible implementation template string. +// +//go:embed conversion_spoke.go.tmpl +var ConversionSpokeTemplate string diff --git a/pkg/resource/fake/terraformed.go b/pkg/resource/fake/terraformed.go index ed71b356..ed891bb4 100644 --- a/pkg/resource/fake/terraformed.go +++ b/pkg/resource/fake/terraformed.go @@ -122,3 +122,33 @@ func (t *Terraformed) DeepCopyObject() runtime.Object { _ = json.Unmarshal(j, out) return out } + +// Option is an option to modify the properties of a Terraformed object. +type Option func(terraformed *Terraformed) + +// WithParameters sets the parameters of a Terraformed. +func WithParameters(params map[string]any) Option { + return func(tr *Terraformed) { + tr.Parameters = params + } +} + +// NewTerraformed initializes a new Terraformed with the given options. +func NewTerraformed(opts ...Option) *Terraformed { + tr := &Terraformed{} + for _, o := range opts { + o(tr) + } + return tr +} + +// NewMap prepares a map from the supplied key value parameters. +// The parameters slice must be a sequence of key, value pairs and must have +// an even length. The function will panic otherwise. +func NewMap(keyValue ...string) map[string]any { + m := make(map[string]any, len(keyValue)/2) + for i := 0; i < len(keyValue)-1; i += 2 { + m[keyValue[i]] = keyValue[i+1] + } + return m +}