diff --git a/apis/core/v1alpha1/annotations.go b/apis/core/v1alpha1/annotations.go index 75869bb..27c7673 100644 --- a/apis/core/v1alpha1/annotations.go +++ b/apis/core/v1alpha1/annotations.go @@ -81,4 +81,14 @@ const ( // the resource is read-only and should not be created/patched/deleted by the // ACK service controller. AnnotationReadOnly = AnnotationPrefix + "read-only" + // AnnotationAdoptionPolicy is an annotation whose value is the identifier for whether + // we will attempt adoption only (value = adopt-only) or attempt a create if resource + // is not found (value adopt-or-create). + // + // NOTE (michaelhtm): Currently create-or-adopt is not supported + AnnotationAdoptionPolicy = AnnotationPrefix + "adoption-policy" + // AnnotationAdoptionFields is an annotation whose value contains a json-like + // format of the requied fields to do a ReadOne when attempting to force-adopt + // a Resource + AnnotationAdoptionFields = AnnotationPrefix + "adoption-fields" ) diff --git a/mocks/pkg/types/aws_resource.go b/mocks/pkg/types/aws_resource.go index 29cd765..7d14bfc 100644 --- a/mocks/pkg/types/aws_resource.go +++ b/mocks/pkg/types/aws_resource.go @@ -131,6 +131,20 @@ func (_m *AWSResource) SetIdentifiers(_a0 *v1alpha1.AWSIdentifiers) error { return r0 } +// PopulateResourceFromAnnotation provides a mock function with given fields: _a0 +func (_m *AWSResource) PopulateResourceFromAnnotation(_a0 map[string]string) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(map[string]string) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SetObjectMeta provides a mock function with given fields: meta func (_m *AWSResource) SetObjectMeta(meta v1.ObjectMeta) { _m.Called(meta) diff --git a/pkg/featuregate/features.go b/pkg/featuregate/features.go index 79ce3fd..358a4f2 100644 --- a/pkg/featuregate/features.go +++ b/pkg/featuregate/features.go @@ -19,6 +19,10 @@ package featuregate import "fmt" const ( + // ResourceAdoption is a feature gate for enabling forced adoption of resources + // by annotation + ResourceAdoption = "ResourceAdoption" + // ReadOnlyResources is a feature gate for enabling ReadOnly resources annotation. ReadOnlyResources = "ReadOnlyResources" @@ -32,6 +36,7 @@ const ( // defaultACKFeatureGates is a map of feature names to Feature structs // representing the default feature gates for ACK controllers. var defaultACKFeatureGates = FeatureGates{ + ResourceAdoption: {Stage: Alpha, Enabled: false}, ReadOnlyResources: {Stage: Alpha, Enabled: false}, TeamLevelCARM: {Stage: Alpha, Enabled: false}, ServiceLevelCARM: {Stage: Alpha, Enabled: false}, diff --git a/pkg/runtime/reconciler.go b/pkg/runtime/reconciler.go index 6a4f43a..e859b9f 100644 --- a/pkg/runtime/reconciler.go +++ b/pkg/runtime/reconciler.go @@ -57,6 +57,10 @@ const ( // resource if the CARM cache is not synced yet, or if the roleARN is not // available. roleARNNotAvailableRequeueDelay = 15 * time.Second + // adoptOrCreate is an annotation field that decides whether to create the + // resource if it doesn't exist, or adopt the resource if it exists. + // value comes from getAdoptionPolicy + // adoptOrCreate = "adopt-or-create" ) // reconciler describes a generic reconciler within ACK. @@ -298,6 +302,70 @@ func (r *resourceReconciler) handleCacheError( return r.HandleReconcileError(ctx, desired, latest, requeue.NeededAfter(err, roleARNNotAvailableRequeueDelay)) } +func (r *resourceReconciler) handleAdoption( + ctx context.Context, + rm acktypes.AWSResourceManager, + desired acktypes.AWSResource, + rlog acktypes.Logger, +) (acktypes.AWSResource, error) { + // If the resource is being adopted by force, we need to access + // the required field passed by annotation and attempt a read. + + rlog.Info("Adopting Resource") + extractedFields, err := ExtractAdoptionFields(desired) + if err != nil { + return desired, ackerr.NewTerminalError(err) + } + if len(extractedFields) == 0 { + // TODO(michaelhtm) Here we need to figure out if we want to have an + // error or not. should we consider accepting values from Spec? + // And then we can let the ReadOne figure out if we have missing + // required fields for a Read + return nil, fmt.Errorf("failed extracting fields from annotation") + } + resolved := desired.DeepCopy() + err = resolved.PopulateResourceFromAnnotation(extractedFields) + if err != nil { + return nil, err + } + + rlog.Enter("rm.EnsureTags") + err = rm.EnsureTags(ctx, resolved, r.sc.GetMetadata()) + rlog.Exit("rm.EnsureTags", err) + if err != nil { + return resolved, err + } + rlog.Enter("rm.ReadOne") + latest, err := rm.ReadOne(ctx, resolved) + if err != nil { + return latest, err + } + + if err = r.setResourceManaged(ctx, rm, latest); err != nil { + return latest, err + } + + // Ensure tags again after adding the finalizer and patching the + // resource. Patching desired resource omits the controller tags + // because they are not persisted in etcd. So we again ensure + // that tags are present before performing the create operation. + rlog.Enter("rm.EnsureTags") + err = rm.EnsureTags(ctx, latest, r.sc.GetMetadata()) + rlog.Exit("rm.EnsureTags", err) + if err != nil { + return latest, err + } + r.rd.MarkAdopted(latest) + rlog.WithValues("is_adopted", "true") + latest, err = r.patchResourceMetadataAndSpec(ctx, rm, desired, latest) + if err != nil { + return latest, err + } + + rlog.Info("Resource Adopted") + return latest, nil +} + // reconcile either cleans up a deleted resource or ensures that the supplied // AWSResource's backing API resource matches the supplied desired state. // @@ -360,6 +428,21 @@ func (r *resourceReconciler) Sync( isAdopted := IsAdopted(desired) rlog.WithValues("is_adopted", isAdopted) + if r.cfg.FeatureGates.IsEnabled(featuregate.ResourceAdoption) { + if NeedAdoption(desired) && !r.rd.IsManaged(desired) { + latest, err := r.handleAdoption(ctx, rm, desired, rlog) + + if err != nil { + // If we get an error, we want to return here + // TODO(michaelhtm): Change the handling of + // the error to allow Adopt or Create here + // when supported + return latest, err + } + return latest, nil + } + } + if r.cfg.FeatureGates.IsEnabled(featuregate.ReadOnlyResources) { isReadOnly := IsReadOnly(desired) rlog.WithValues("is_read_only", isReadOnly) @@ -367,6 +450,8 @@ func (r *resourceReconciler) Sync( // NOTE(a-hilaly): When the time comes to support adopting resources // using annotations, we will need to think a little bit more about // the case where a user, wants to adopt a resource as read-only. + // + // NOTE(michaelhtm): Done, tnx :) // If the resource is read-only, we enter a different code path where we // only read the resource and patch the metadata and spec. diff --git a/pkg/runtime/util.go b/pkg/runtime/util.go index b2079e0..21c0481 100644 --- a/pkg/runtime/util.go +++ b/pkg/runtime/util.go @@ -14,6 +14,7 @@ package runtime import ( + "encoding/json" "strings" corev1 "k8s.io/api/core/v1" @@ -68,3 +69,55 @@ func IsReadOnly(res acktypes.AWSResource) bool { } return false } + +// GetAdoptionPolicy returns the Adoption Policy of the resource +// defined by the user in annotation. Possible values are: +// adopt-only | adopt-or-create +// adopt-only keeps requing until the resource is found +// adopt-or-create creates the resource if does not exist +func GetAdoptionPolicy(res acktypes.AWSResource) string { + mo := res.MetaObject() + if mo == nil { + panic("getAdoptionPolicy received resource with nil RuntimeObject") + } + for k, v := range mo.GetAnnotations() { + if k == ackv1alpha1.AnnotationAdoptionPolicy { + return v + } + } + + return "" +} + +// NeedAdoption returns true when the resource has +// adopt annotation but is not yet adopted +func NeedAdoption(res acktypes.AWSResource) bool { + return GetAdoptionPolicy(res) != "" && !IsAdopted(res) +} + +func ExtractAdoptionFields(res acktypes.AWSResource) (map[string]string, error) { + fields := getAdoptionFields(res) + + extractedFields := &map[string]string{} + err := json.Unmarshal([]byte(fields), extractedFields) + if err != nil { + return nil, err + } + + return *extractedFields, nil +} + +func getAdoptionFields(res acktypes.AWSResource) string { + mo := res.MetaObject() + if mo == nil { + // Should never happen... if it does, it's buggy code. + panic("ExtractRequiredFields received resource with nil RuntimeObject") + } + + for k, v := range mo.GetAnnotations() { + if k == ackv1alpha1.AnnotationAdoptionFields { + return v + } + } + return "" +} diff --git a/pkg/runtime/util_test.go b/pkg/runtime/util_test.go index e9da364..cf871e2 100644 --- a/pkg/runtime/util_test.go +++ b/pkg/runtime/util_test.go @@ -67,3 +67,56 @@ func TestIsSynced(t *testing.T) { }) require.False(ackrt.IsSynced(res)) } + +func TestIsForcedAdoption(t *testing.T) { + require := require.New(t) + + res := &mocks.AWSResource{} + res.On("MetaObject").Return(&metav1.ObjectMeta{ + Annotations: map[string]string{ + ackv1alpha1.AnnotationAdoptionPolicy: "true", + ackv1alpha1.AnnotationAdopted: "false", + }, + }) + require.True(ackrt.NeedAdoption(res)) + + res = &mocks.AWSResource{} + res.On("MetaObject").Return(&metav1.ObjectMeta{ + Annotations: map[string]string{ + ackv1alpha1.AnnotationAdoptionPolicy: "true", + ackv1alpha1.AnnotationAdopted: "true", + }, + }) + require.False(ackrt.NeedAdoption(res)) + + res = &mocks.AWSResource{} + res.On("MetaObject").Return(&metav1.ObjectMeta{ + Annotations: map[string]string{ + ackv1alpha1.AnnotationAdoptionPolicy: "false", + ackv1alpha1.AnnotationAdopted: "true", + }, + }) + require.False(ackrt.NeedAdoption(res)) +} + +func TestExtractAdoptionFields(t *testing.T) { + require := require.New(t) + + res := &mocks.AWSResource{} + res.On("MetaObject").Return(&metav1.ObjectMeta{ + Annotations: map[string]string{ + ackv1alpha1.AnnotationAdoptionFields: `{ + "clusterName": "my-cluster", + "name": "ng-1234" + }`, + }, + }) + + expected := map[string]string{ + "clusterName": "my-cluster", + "name": "ng-1234", + } + actual, err := ackrt.ExtractAdoptionFields(res) + require.NoError(err) + require.Equal(expected, actual) +} diff --git a/pkg/types/aws_resource.go b/pkg/types/aws_resource.go index 805c018..dd2673e 100644 --- a/pkg/types/aws_resource.go +++ b/pkg/types/aws_resource.go @@ -48,4 +48,7 @@ type AWSResource interface { SetStatus(AWSResource) // DeepCopy will return a copy of the resource DeepCopy() AWSResource + // PopulateResourceFromAnnotation will set the Spec or Status field that user + // provided from annotations + PopulateResourceFromAnnotation(fields map[string]string) error }