diff --git a/go.mod b/go.mod index 26613f75..ffb2cb06 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/muvaf/typewriter v0.0.0-20210910160850-80e49fe1eb32 github.com/pkg/errors v0.9.1 - github.com/spf13/afero v1.8.0 + github.com/spf13/afero v1.9.2 github.com/tmccombs/hcl2json v0.3.3 github.com/yuin/goldmark v1.4.13 github.com/zclconf/go-cty v1.11.0 @@ -26,6 +26,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.25.0 k8s.io/apimachinery v0.25.0 + k8s.io/client-go v0.25.0 k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed sigs.k8s.io/controller-runtime v0.12.1 sigs.k8s.io/yaml v1.3.0 @@ -34,7 +35,7 @@ require ( require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect - github.com/alecthomas/units v0.0.0-20210927113745-59d0afb8317a // indirect + github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/antchfx/xpath v1.2.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -67,7 +68,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -86,8 +87,8 @@ require ( github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect github.com/vmihailenco/tagparser v0.1.1 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect - golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect - golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect + golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect @@ -98,7 +99,6 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.24.0 // indirect - k8s.io/client-go v0.25.0 // indirect k8s.io/component-base v0.25.0 // indirect k8s.io/klog/v2 v2.70.1 // indirect k8s.io/kube-openapi v0.0.0-20220803162953-67bda5d908f1 // indirect diff --git a/go.sum b/go.sum index 10cb1bf8..59ef694b 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alecthomas/units v0.0.0-20210927113745-59d0afb8317a h1:E/8AP5dFtMhl5KPJz66Kt9G0n+7Sn41Fy1wv9/jHOrc= -github.com/alecthomas/units v0.0.0-20210927113745-59d0afb8317a/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/antchfx/htmlquery v1.2.4 h1:qLteofCMe/KGovBI6SQgmou2QNyedFUW+pE+BpeZ494= github.com/antchfx/htmlquery v1.2.4/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc= github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8= @@ -399,8 +399,9 @@ github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZb github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -522,8 +523,8 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60= -github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= @@ -737,8 +738,8 @@ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE= -golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 h1:+jnHzr9VPj32ykQVai5DNahi9+NSp7yYuCsl5eAQtL0= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -822,8 +823,8 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= diff --git a/pkg/migration/converter.go b/pkg/migration/converter.go new file mode 100644 index 00000000..228d7623 --- /dev/null +++ b/pkg/migration/converter.go @@ -0,0 +1,112 @@ +// Copyright 2022 Upbound Inc. +// +// 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 migration + +import ( + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/json" +) + +const ( + errFromUnstructured = "failed to convert from unstructured.Unstructured to the managed resource type" + errToUnstructured = "failed to convert from the managed resource type to unstructured.Unstructured" + errRawExtensionUnmarshal = "failed to unmarshal runtime.RawExtension" + + errFmtPavedDelete = "failed to delete fieldpath %q from paved" +) + +// CopyInto copies values of fields from the migration `source` object +// into the migration `target` object and fills in the target object's +// TypeMeta using the supplied `targetGVK`. While copying fields from +// migration source to migration target, the fields at the paths +// specified with `skipFieldPaths` array are skipped. This is a utility +// that can be used in the migration resource converter implementations. +// If a certain field with the same name in both the `source` and the `target` +// objects has different types in `source` and `target`, then it must be +// included in the `skipFieldPaths` and it must manually be handled in the +// conversion function. +func CopyInto(source any, target any, targetGVK schema.GroupVersionKind, skipFieldPaths ...string) (any, error) { + u := ToUnstructured(source) + paved := fieldpath.Pave(u.Object) + skipFieldPaths = append(skipFieldPaths, "apiVersion", "kind") + for _, p := range skipFieldPaths { + if err := paved.DeleteField(p); err != nil { + return nil, errors.Wrapf(err, errFmtPavedDelete, p) + } + } + u.SetGroupVersionKind(targetGVK) + return target, errors.Wrap(runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, target), errFromUnstructured) +} + +// sanitizeResource removes certain fields from the unstructured object. +// It turns out that certain fields, such as `metadata.creationTimestamp` +// are still serialized even if they have zero-values. This function +// removes such fields. We also unconditionally sanitize `status` +// so that the controller will populate it back. +func sanitizeResource(m map[string]any) map[string]any { + delete(m, "status") + if _, ok := m["metadata"]; !ok { + return m + } + metadata := m["metadata"].(map[string]any) + + if v := metadata["creationTimestamp"]; v == nil { + delete(metadata, "creationTimestamp") + } + if len(metadata) == 0 { + delete(m, "metadata") + } + return m +} + +// ToUnstructured converts the specified managed resource to an +// unstructured.Unstructured. Before the converted object is +// returned, it's sanitized by removing certain fields +// (like status, metadata.creationTimestamp). +func ToUnstructured(mg any) unstructured.Unstructured { + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(mg) + if err != nil { + panic(errors.Wrap(err, errToUnstructured)) + } + return unstructured.Unstructured{ + Object: sanitizeResource(m), + } +} + +// FromRawExtension attempts to convert a runtime.RawExtension into +// an unstructured.Unstructured. +func FromRawExtension(r runtime.RawExtension) (unstructured.Unstructured, error) { + var m map[string]interface{} + if err := json.Unmarshal(r.Raw, &m); err != nil { + return unstructured.Unstructured{}, errors.Wrap(err, errRawExtensionUnmarshal) + } + return unstructured.Unstructured{ + Object: m, + }, nil +} + +// FromGroupVersionKind converts a schema.GroupVersionKind into +// a migration.GroupVersionKind. +func FromGroupVersionKind(gvk schema.GroupVersionKind) GroupVersionKind { + return GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} diff --git a/pkg/migration/interfaces.go b/pkg/migration/interfaces.go index 086f4fa1..aee955cc 100644 --- a/pkg/migration/interfaces.go +++ b/pkg/migration/interfaces.go @@ -1,6 +1,16 @@ -/* -Copyright 2022 Upbound Inc. -*/ +// Copyright 2022 Upbound Inc. +// +// 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 migration @@ -9,6 +19,9 @@ import ( v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" ) +// Converter converts a managed resource or a Composition's ComposedTemplate +// from the migration source provider's schema to the migration target +// provider's schema. type Converter interface { // Resources takes a managed resource and returns zero or more managed // resources to be created. @@ -22,13 +35,22 @@ type Converter interface { ComposedTemplates(cmp v1.ComposedTemplate, convertedBase ...*v1.ComposedTemplate) error } +// Source is a source for reading resource manifests type Source interface { + // HasNext returns `true` if the Source implementation has a next manifest + // available to return with a call to Next. Any errors encountered while + // determining whether a next manifest exists will also be reported. HasNext() (bool, error) + // Next returns the next resource manifest available or + // any errors encountered while reading the next resource manifest. Next() (UnstructuredWithMetadata, error) } +// Target is a target where resource manifests can be manipulated +// (e.g., added, deleted, patched, etc.) type Target interface { + // Put writes a resource manifest to this Target Put(o UnstructuredWithMetadata) error - Patch(o UnstructuredWithMetadata) error + // Delete deletes a resource manifest from this Target Delete(o UnstructuredWithMetadata) error } diff --git a/pkg/migration/plan_generator.go b/pkg/migration/plan_generator.go new file mode 100644 index 00000000..eef0ef64 --- /dev/null +++ b/pkg/migration/plan_generator.go @@ -0,0 +1,401 @@ +// Copyright 2022 Upbound Inc. +// +// 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 migration + +import ( + "fmt" + "strings" + + v1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/crossplane/crossplane-runtime/pkg/meta" + "github.com/crossplane/crossplane-runtime/pkg/resource" + xpv1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + "github.com/pkg/errors" +) + +const ( + errSourceHasNext = "failed to generate migration plan: Could not check next object from source" + errSourceNext = "failed to generate migration plan: Could not get next object from source" + errUnstructuredConvert = "failed to convert from unstructured object to v1.Composition" + errUnstructuredMarshal = "failed to marshal unstructured object to JSON" + errResourceMigrate = "failed to migrate resource" + errCompositePause = "failed to pause composite resource" + errCompositionMigrate = "failed to migrate the composition" + errComposedTemplateBase = "failed to migrate the base of a composed template" + errComposedTemplateMigrate = "failed to migrate the composed templates of the composition" + errResourceOutput = "failed to output migrated resource" + errResourceOrphan = "failed to orphan managed resource" + errDeletionOrphan = "failed to set deletion policy to Orphan" + errCompositionOutput = "failed to output migrated composition" + errPlanGeneration = "failed to generate the migration plan" + errPause = "failed to store a paused manifest" +) + +type step int + +const ( + stepPauseManaged step = iota + stepPauseComposites + stepCreateNewManaged + stepNewCompositions + stepEditComposites + stepEditClaims + stepDeletionPolicyOrphan + stepDeleteOldManaged + stepStartManaged + stepStartComposites +) + +// PlanGenerator generates a migration.Plan reading the manifests available +// from `source`, converting managed resources and compositions using the +// available `migration.Converter`s registered in the `registry` and +// writing the output manifests to the specified `target`. +type PlanGenerator struct { + source Source + target Target + registry Registry + // Plan is the migration.Plan whose steps are expected + // to complete a migration when they're executed in order. + Plan Plan +} + +// NewPlanGenerator constructs a new PlanGenerator using the specified +// Source and Target and the default converter Registry. +func NewPlanGenerator(source Source, target Target) PlanGenerator { + return PlanGenerator{ + source: source, + target: target, + registry: registry, + } +} + +// GeneratePlan generates a migration plan for the manifests available from +// the configured Source and writing them to the configured Target using the +// configured converter Registry. The generated Plan is available in the +// PlanGenerator.Plan variable if the generation is successful +// (i.e., no errors are reported). +func (pg *PlanGenerator) GeneratePlan() error { + pg.buildPlan() + return errors.Wrap(pg.convert(), errPlanGeneration) +} + +func (pg *PlanGenerator) convert() error { //nolint: gocyclo + for hasNext, err := pg.source.HasNext(); ; hasNext, err = pg.source.HasNext() { + if err != nil { + return errors.Wrap(err, errSourceHasNext) + } + if !hasNext { + break + } + o, err := pg.source.Next() + if err != nil { + return errors.Wrap(err, errSourceNext) + } + switch gvk := o.Object.GroupVersionKind(); gvk { + case xpv1.CompositionGroupVersionKind: + target, converted, err := pg.convertComposition(o) + if err != nil { + return errors.Wrap(err, errCompositionMigrate) + } + if converted { + if err := pg.stepNewComposition(target); err != nil { + return errors.Wrap(err, errCompositionMigrate) + } + } + default: + if o.Metadata.IsComposite { + if err := pg.stepPauseComposite(&o); err != nil { + return errors.Wrap(err, errCompositePause) + } + continue + } + + targets, converted, err := pg.convertResource(o) + if err != nil { + return errors.Wrap(err, errResourceMigrate) + } + if converted { + for _, tu := range targets { + tu := tu + if err := pg.stepNewManagedResource(&tu); err != nil { + return errors.Wrap(err, errResourceMigrate) + } + if err := pg.stepStartManagedResource(&tu); err != nil { + return errors.Wrap(err, errResourceMigrate) + } + } + } else if _, ok, _ := toManagedResource(o.Object); ok { + if err := pg.stepStartManagedResource(&o); err != nil { + return errors.Wrap(err, errResourceMigrate) + } + } + } + if err := pg.addStepsForManagedResource(&o); err != nil { + return err + } + } + return nil +} + +func (pg *PlanGenerator) convertResource(o UnstructuredWithMetadata) ([]UnstructuredWithMetadata, bool, error) { + gvk := o.Object.GroupVersionKind() + conv := pg.registry[gvk] + if conv == nil { + return []UnstructuredWithMetadata{o}, false, nil + } + // we have already ensured that the GVK belongs to a managed resource type + mg, _, err := toManagedResource(o.Object) + if err != nil { + return nil, false, errors.Wrap(err, errResourceMigrate) + } + resources, err := conv.Resources(mg) + if err != nil { + return nil, false, errors.Wrap(err, errResourceMigrate) + } + converted := make([]UnstructuredWithMetadata, 0, len(resources)) + for _, mg := range resources { + converted = append(converted, UnstructuredWithMetadata{ + Object: ToUnstructured(mg), + Metadata: o.Metadata, + }) + } + return converted, true, nil +} + +func toManagedResource(u unstructured.Unstructured) (resource.Managed, bool, error) { + gvk := u.GroupVersionKind() + obj, err := scheme.Scheme.New(gvk) + if err != nil { + return nil, false, errors.Wrapf(err, errFmtNewObject, gvk) + } + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj); err != nil { + return nil, false, errors.Wrap(err, errFromUnstructured) + } + mg, ok := obj.(resource.Managed) + return mg, ok, nil +} + +func (pg *PlanGenerator) convertComposition(o UnstructuredWithMetadata) (*UnstructuredWithMetadata, bool, error) { // nolint:gocyclo + c := xpv1.Composition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.Object.Object, &c); err != nil { + return nil, false, errors.Wrap(err, errUnstructuredConvert) + } + var targetResources []*xpv1.ComposedTemplate + isConverted := false + for _, cmp := range c.Spec.Resources { + u, err := FromRawExtension(cmp.Base) + if err != nil { + return nil, false, errors.Wrap(err, errCompositionMigrate) + } + gvk := u.GroupVersionKind() + converted, ok, err := pg.convertResource(UnstructuredWithMetadata{ + Object: u, + Metadata: o.Metadata, + }) + if err != nil { + return nil, false, errors.Wrap(err, errComposedTemplateBase) + } + isConverted = isConverted || ok + cmps := make([]*xpv1.ComposedTemplate, 0, len(converted)) + for _, u := range converted { + buff, err := u.Object.MarshalJSON() + if err != nil { + return nil, false, errors.Wrap(err, errUnstructuredMarshal) + } + c := cmp.DeepCopy() + c.Base = runtime.RawExtension{ + Raw: buff, + } + cmps = append(cmps, c) + } + conv := pg.registry[gvk] + if conv != nil { + if err := conv.ComposedTemplates(cmp, cmps...); err != nil { + return nil, false, errors.Wrap(err, errComposedTemplateMigrate) + } + } + targetResources = append(targetResources, cmps...) + } + c.Spec.Resources = make([]xpv1.ComposedTemplate, 0, len(targetResources)) + for _, cmp := range targetResources { + c.Spec.Resources = append(c.Spec.Resources, *cmp) + } + return &UnstructuredWithMetadata{ + Object: ToUnstructured(&c), + Metadata: o.Metadata, + }, isConverted, nil +} + +// NOTE: to cover different migration scenarios, we may use +// "migration templates" instead of a static plan. But a static plan should be +// fine as a start. +func (pg *PlanGenerator) buildPlan() { + pg.Plan.Spec.Steps = make([]Step, 10) + + pg.Plan.Spec.Steps[stepPauseManaged].Name = "pause-managed" + pg.Plan.Spec.Steps[stepPauseManaged].Type = StepTypeApply + pg.Plan.Spec.Steps[stepPauseManaged].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepPauseComposites].Name = "pause-composites" + pg.Plan.Spec.Steps[stepPauseComposites].Type = StepTypeApply + pg.Plan.Spec.Steps[stepPauseComposites].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepCreateNewManaged].Name = "create-new-managed" + pg.Plan.Spec.Steps[stepCreateNewManaged].Type = StepTypeApply + pg.Plan.Spec.Steps[stepCreateNewManaged].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepNewCompositions].Name = "new-compositions" + pg.Plan.Spec.Steps[stepNewCompositions].Type = StepTypeApply + pg.Plan.Spec.Steps[stepNewCompositions].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepEditComposites].Name = "edit-composites" + pg.Plan.Spec.Steps[stepEditComposites].Type = StepTypeApply + pg.Plan.Spec.Steps[stepEditComposites].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepEditClaims].Name = "edit-claims" + pg.Plan.Spec.Steps[stepEditClaims].Type = StepTypeApply + pg.Plan.Spec.Steps[stepEditClaims].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Name = "deletion-policy-orphan" + pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Type = StepTypeApply + pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepDeleteOldManaged].Name = "delete-old-managed" + pg.Plan.Spec.Steps[stepDeleteOldManaged].Type = StepTypeDelete + deletePolicy := FinalizerPolicyRemove + pg.Plan.Spec.Steps[stepDeleteOldManaged].Delete = &DeleteStep{ + Options: &DeleteOptions{ + FinalizerPolicy: &deletePolicy, + }, + } + + pg.Plan.Spec.Steps[stepStartManaged].Name = "start-managed" + pg.Plan.Spec.Steps[stepStartManaged].Type = StepTypeApply + pg.Plan.Spec.Steps[stepStartManaged].Apply = &ApplyStep{} + + pg.Plan.Spec.Steps[stepStartComposites].Name = "start-composites" + pg.Plan.Spec.Steps[stepStartComposites].Type = StepTypeApply + pg.Plan.Spec.Steps[stepStartComposites].Apply = &ApplyStep{} +} + +func (pg *PlanGenerator) addStepsForManagedResource(u *UnstructuredWithMetadata) error { + if _, ok, err := toManagedResource(u.Object); err != nil || !ok { + // not a managed resource or unable to determine + // whether it's a managed resource + return nil // nolint:nilerr + } + qName := getQualifiedName(u.Object) + if err := pg.stepPauseManagedResource(u, qName); err != nil { + return err + } + if err := pg.stepOrphanManagedResource(u, qName); err != nil { + return err + } + pg.stepDeleteOldManagedResource(u) + return nil +} + +func (pg *PlanGenerator) stepStartManagedResource(u *UnstructuredWithMetadata) error { + annot := u.Object.GetAnnotations() + if annot != nil { + delete(annot, meta.AnnotationKeyReconciliationPaused) + u.Object.SetAnnotations(annot) + } + + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepStartManaged].Name, getQualifiedName(u.Object)) + pg.Plan.Spec.Steps[stepStartManaged].Apply.Files = append(pg.Plan.Spec.Steps[stepStartManaged].Apply.Files, u.Metadata.Path) + if err := pg.target.Put(*u); err != nil { + return errors.Wrap(err, errResourceOutput) + } + return nil +} + +func (pg *PlanGenerator) stepPauseManagedResource(u *UnstructuredWithMetadata, qName string) error { + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepPauseManaged].Name, qName) + pg.Plan.Spec.Steps[stepPauseManaged].Apply.Files = append(pg.Plan.Spec.Steps[stepPauseManaged].Apply.Files, u.Metadata.Path) + return pg.pause(u.Metadata.Path, &u.Object) +} + +func (pg *PlanGenerator) stepPauseComposite(u *UnstructuredWithMetadata) error { + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepPauseComposites].Name, getQualifiedName(u.Object)) + pg.Plan.Spec.Steps[stepPauseComposites].Apply.Files = append(pg.Plan.Spec.Steps[stepPauseComposites].Apply.Files, u.Metadata.Path) + return pg.pause(u.Metadata.Path, &u.Object) +} + +func (pg *PlanGenerator) stepOrphanManagedResource(u *UnstructuredWithMetadata, qName string) error { + pv := fieldpath.Pave(u.Object.Object) + if err := pv.SetValue("spec.deletionPolicy", v1.DeletionOrphan); err != nil { + return errors.Wrap(err, errDeletionOrphan) + } + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Name, qName) + pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Apply.Files = append(pg.Plan.Spec.Steps[stepDeletionPolicyOrphan].Apply.Files, u.Metadata.Path) + return errors.Wrap(pg.target.Put(*u), errResourceOrphan) +} + +func (pg *PlanGenerator) stepDeleteOldManagedResource(u *UnstructuredWithMetadata) { + pg.Plan.Spec.Steps[stepDeleteOldManaged].Delete.Resources = append(pg.Plan.Spec.Steps[stepDeleteOldManaged].Delete.Resources, + Resource{ + GroupVersionKind: FromGroupVersionKind(u.Object.GroupVersionKind()), + Name: u.Object.GetName(), + }) +} + +func addPauseAnnotation(u *unstructured.Unstructured) { + annot := u.GetAnnotations() + if annot == nil { + annot = make(map[string]string) + } + annot[meta.AnnotationKeyReconciliationPaused] = "true" + u.SetAnnotations(annot) +} + +func (pg *PlanGenerator) pause(fp string, u *unstructured.Unstructured) error { + addPauseAnnotation(u) + return errors.Wrap(pg.target.Put(UnstructuredWithMetadata{ + Object: *u, + Metadata: Metadata{ + Path: fp, + }, + }), errPause) +} + +func getQualifiedName(u unstructured.Unstructured) string { + gvk := u.GroupVersionKind() + return fmt.Sprintf("%s.%ss.%s", u.GetName(), strings.ToLower(gvk.Kind), gvk.Group) +} + +func (pg *PlanGenerator) stepNewManagedResource(u *UnstructuredWithMetadata) error { + addPauseAnnotation(&u.Object) + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepCreateNewManaged].Name, getQualifiedName(u.Object)) + pg.Plan.Spec.Steps[stepCreateNewManaged].Apply.Files = append(pg.Plan.Spec.Steps[stepCreateNewManaged].Apply.Files, u.Metadata.Path) + if err := pg.target.Put(*u); err != nil { + return errors.Wrap(err, errResourceOutput) + } + return nil +} + +func (pg *PlanGenerator) stepNewComposition(u *UnstructuredWithMetadata) error { + u.Metadata.Path = fmt.Sprintf("%s/%s.yaml", pg.Plan.Spec.Steps[stepNewCompositions].Name, getQualifiedName(u.Object)) + pg.Plan.Spec.Steps[stepNewCompositions].Apply.Files = append(pg.Plan.Spec.Steps[stepNewCompositions].Apply.Files, u.Metadata.Path) + if err := pg.target.Put(*u); err != nil { + return errors.Wrap(err, errCompositionOutput) + } + return nil +} diff --git a/pkg/migration/registry.go b/pkg/migration/registry.go new file mode 100644 index 00000000..1d0ebdde --- /dev/null +++ b/pkg/migration/registry.go @@ -0,0 +1,92 @@ +// Copyright 2022 Upbound Inc. +// +// 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 migration + +import ( + "github.com/crossplane/crossplane-runtime/pkg/resource" + v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/scheme" +) + +const ( + errFmtNewObject = "failed to instantiate a new runtime.Object using scheme.Scheme for: %s" + errFmtNotManagedResource = "specified GVK does not belong to a managed resource: %s" +) + +var ( + // the default Converter registry + registry Registry = make(map[schema.GroupVersionKind]Converter) +) + +// ResourceConversionFn is a function that converts the specified migration +// source managed resource to one or more migration target managed resources. +type ResourceConversionFn func(mg resource.Managed) ([]resource.Managed, error) + +// ComposedTemplateConversionFn is a function that converts from the specified +// v1.ComposedTemplate's migration source resources to one or more migration +// target resources. +type ComposedTemplateConversionFn func(cmp v1.ComposedTemplate, convertedBase ...*v1.ComposedTemplate) error + +// Registry is a registry of `migration.Converter`s keyed with +// the associated `schema.GroupVersionKind`s. +type Registry map[schema.GroupVersionKind]Converter + +// RegisterConverter registers the specified migration.Converter for the +// specified GVK with the default Registry. +func RegisterConverter(gvk schema.GroupVersionKind, conv Converter) { + // make sure a converter is being registered for a managed resource, + // and it's registered with our runtime scheme. + // This will be needed, during runtime, for properly converting resources + obj, err := scheme.Scheme.New(gvk) + if err != nil { + panic(errors.Wrapf(err, errFmtNewObject, gvk)) + } + if _, ok := obj.(resource.Managed); !ok { + panic(errors.Errorf(errFmtNotManagedResource, gvk)) + } + registry[gvk] = conv +} + +type delegatingConverter struct { + rFn ResourceConversionFn + cmpFn ComposedTemplateConversionFn +} + +// Resources converts from the specified migration source resource to +// the migration target resources by calling the configured ResourceConversionFn. +func (d delegatingConverter) Resources(mg resource.Managed) ([]resource.Managed, error) { + return d.rFn(mg) +} + +// ComposedTemplates converts from the specified migration source +// v1.ComposedTemplate to the migration target schema by calling the configured +// ComposedTemplateConversionFn. +func (d delegatingConverter) ComposedTemplates(cmp v1.ComposedTemplate, convertedBase ...*v1.ComposedTemplate) error { + return d.cmpFn(cmp, convertedBase...) +} + +// RegisterConversionFunctions registers the supplied ResourceConversionFn and +// ComposedTemplateConversionFn for the specified GVK. +// The specified GVK must belong to a Crossplane managed resource type and +// the type must already have been registered with the client-go's +// default scheme. +func RegisterConversionFunctions(gvk schema.GroupVersionKind, rFn ResourceConversionFn, cmpFn ComposedTemplateConversionFn) { + RegisterConverter(gvk, delegatingConverter{ + rFn: rFn, + cmpFn: cmpFn, + }) +} diff --git a/pkg/migration/types.go b/pkg/migration/types.go index c65a0f36..82795f75 100644 --- a/pkg/migration/types.go +++ b/pkg/migration/types.go @@ -1,61 +1,134 @@ -/* -Copyright 2022 Upbound Inc. -*/ +// Copyright 2022 Upbound Inc. +// +// 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 migration import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" ) +// FinalizerPolicy denotes the policy regarding the managed reconciler's +// finalizer while deleting a managed resource. type FinalizerPolicy string const ( + // FinalizerPolicyRemove is the FinalizerPolicy for removing + // the managed reconciler's finalizer from a managed resource. FinalizerPolicyRemove FinalizerPolicy = "Remove" // Default ) +// Plan represents a migration plan for migrating managed resources, +// and associated composites and claims from a migration source provider +// to a migration target provider. type Plan struct { Version string `json:"version"` Spec Spec `json:"spec,omitempty"` } +// Spec represents the specification of a migration plan type Spec struct { + // Steps are the migration plan's steps that are expected + // to complete a migration when executed in order. Steps []Step `json:"steps,omitempty"` } +// StepType is the type used to name a migration step +type StepType string + +const ( + // StepTypeApply denotes an apply step + StepTypeApply StepType = "Apply" + // StepTypeDelete denotes a delete step + StepTypeDelete StepType = "Delete" +) + +// Step represents a step in the generated migration plan type Step struct { - Name string `json:"name"` - Type string `json:"type"` - Apply *ApplyStep `json:"apply,omitempty"` + // Name is the name of this Step + Name string `json:"name"` + // Type is the type of this Step. + // Can be one of Apply, Delete, etc. + Type StepType `json:"type"` + // Apply contains the information needed to run an StepTypeApply step. + // Must be set when the Step.Type is StepTypeApply. + Apply *ApplyStep `json:"apply,omitempty"` + // Delete contains the information needed to run an StepTypeDelete step. + // Must be set when the Step.Type is StepTypeDelete. Delete *DeleteStep `json:"delete,omitempty"` } +// ApplyStep represents an apply step in which an array of manifests +// is applied from the filesystem. type ApplyStep struct { - Files []string `json:"files"` + // Files denotes the paths of the manifest files to be applied. + // The paths can either be relative or absolute. + Files []string `json:"files,omitempty"` } +// DeleteStep represents a deletion step with options type DeleteStep struct { - Options *DeleteOptions `json:"options,omitempty"` - Resources []Resource `json:"resources"` + // Options represents the options to be used while deleting the resources + // specified in Resources. + Options *DeleteOptions `json:"options,omitempty"` + // Resources is the array of resources to be deleted in this step + Resources []Resource `json:"resources,omitempty"` } +// DeleteOptions represent options to be used during deletion of +// a managed resource. type DeleteOptions struct { + // FinalizerPolicy denotes the policy to be used regarding + // the managed reconciler's finalizer FinalizerPolicy *FinalizerPolicy `json:"finalizerPolicy,omitempty"` } +// GroupVersionKind represents the GVK for an object's kind. +// schema.GroupVersionKind does not contain json the serialization tags +// for its fields, but we would like to serialize these as part of the +// migration plan. +type GroupVersionKind struct { + // Group is the API group for the resource + Group string `json:"group"` + // Version is the API version for the resource + Version string `json:"version"` + // Kind is the kind name for the resource + Kind string `json:"kind"` +} + type Resource struct { - schema.GroupVersionKind `json:",inline"` - Name string `json:"name"` + // GroupVersionKind holds the GVK for the resource's type + // schema.GroupVersionKind is not embedded for consistent serialized names + GroupVersionKind `json:",inline"` + // Name is the name of the resource + Name string `json:"name"` } +// Metadata holds metadata for an object read from a Source type Metadata struct { + // Path uniquely identifies the path for this object on its Source Path string // colon separated list of parent `Path`s for fan-ins and fan-outs // Example: resources/a.yaml:resources/b.yaml Parents string + // IsComposite set if the object belongs to a Composite type + IsComposite bool + // IsClaim set if the object belongs to a Claim type + IsClaim bool } +// UnstructuredWithMetadata represents an unstructured.Unstructured +// together with the associated Metadata. type UnstructuredWithMetadata struct { Object unstructured.Unstructured Metadata Metadata