From bcb7deebb333fd422899cc650f707835eea7fa64 Mon Sep 17 00:00:00 2001 From: justinsb Date: Mon, 25 Mar 2024 15:07:45 +0000 Subject: [PATCH] genapi: Add support for TagsTagKey Also fix up Update method to match Create method. --- go.mod | 1 + go.sum | 2 + .../direct/apikeys/apikeyskey_controller.go | 12 +- .../directbase/directbase_controller.go | 2 +- .../direct/directbase/interfaces.go | 2 +- .../direct/resourcemanager/client.go | 68 +++++ .../resourcemanager/tagkey_controller.go | 252 ++++++++++++++++++ .../direct/resourcemanager/utils.go | 80 ++++++ .../registration/registration_controller.go | 8 + 9 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 pkg/controller/direct/resourcemanager/client.go create mode 100644 pkg/controller/direct/resourcemanager/tagkey_controller.go create mode 100644 pkg/controller/direct/resourcemanager/utils.go diff --git a/go.mod b/go.mod index 12cd24fb0c..d3a23417a9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ replace github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp => ./mockgcp require ( cloud.google.com/go/apikeys v1.1.5 cloud.google.com/go/profiler v0.1.0 + cloud.google.com/go/resourcemanager v1.9.4 contrib.go.opencensus.io/exporter/prometheus v0.1.0 github.com/GoogleCloudPlatform/declarative-resource-client-library v1.62.0 github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp v0.0.0-00010101000000-000000000000 diff --git a/go.sum b/go.sum index b4ab7cd8ef..858dad5826 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/resourcemanager v1.9.4 h1:JwZ7Ggle54XQ/FVYSBrMLOQIKoIT/uer8mmNvNLK51k= +cloud.google.com/go/resourcemanager v1.9.4/go.mod h1:N1dhP9RFvo3lUfwtfLWVxfUWq8+KUQ+XLlHLH3BoFJ0= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= diff --git a/pkg/controller/direct/apikeys/apikeyskey_controller.go b/pkg/controller/direct/apikeys/apikeyskey_controller.go index deb4a2c78f..018210407a 100644 --- a/pkg/controller/direct/apikeys/apikeyskey_controller.go +++ b/pkg/controller/direct/apikeys/apikeyskey_controller.go @@ -234,7 +234,7 @@ func (a *adapter) Create(ctx context.Context, u *unstructured.Unstructured) erro } // Update implements the Adapter interface. -func (a *adapter) Update(ctx context.Context) (*unstructured.Unstructured, error) { +func (a *adapter) Update(ctx context.Context, u *unstructured.Unstructured) error { // TODO: Skip updates if no changes // TODO: Where/how do we want to enforce immutability? updateMask := &fieldmaskpb.FieldMask{} @@ -254,12 +254,12 @@ func (a *adapter) Update(ctx context.Context) (*unstructured.Unstructured, error if len(updateMask.Paths) == 0 { klog.Warningf("unexpected empty update mask, desired: %v, actual: %v", a.desired, a.actual) - return nil, nil + return nil } key := &pb.Key{} if err := keyMapping.Map(a.desired, key); err != nil { - return nil, err + return err } req := &pb.UpdateKeyRequest{ @@ -271,10 +271,10 @@ func (a *adapter) Update(ctx context.Context) (*unstructured.Unstructured, error _, err := a.gcp.UpdateKey(ctx, req) if err != nil { - return nil, err + return err } - // TODO: Return updated object - return nil, nil + // TODO: update status in u + return nil } func (a *adapter) fullyQualifiedName() string { diff --git a/pkg/controller/direct/directbase/directbase_controller.go b/pkg/controller/direct/directbase/directbase_controller.go index c93f065813..87e8a6fac6 100644 --- a/pkg/controller/direct/directbase/directbase_controller.go +++ b/pkg/controller/direct/directbase/directbase_controller.go @@ -261,7 +261,7 @@ func (r *reconcileContext) doReconcile(ctx context.Context, u *unstructured.Unst return false, r.handleUpdateFailed(ctx, u, fmt.Errorf("error creating: %w", err)) } } else { - if _, err = adapter.Update(ctx); err != nil { + if err := adapter.Update(ctx, u); err != nil { if unwrappedErr, ok := lifecyclehandler.CausedByUnresolvableDeps(err); ok { logger.Info(unwrappedErr.Error(), "resource", k8s.GetNamespacedName(u)) return r.handleUnresolvableDeps(ctx, u, unwrappedErr) diff --git a/pkg/controller/direct/directbase/interfaces.go b/pkg/controller/direct/directbase/interfaces.go index 9a4a305c13..b1f74e25f0 100644 --- a/pkg/controller/direct/directbase/interfaces.go +++ b/pkg/controller/direct/directbase/interfaces.go @@ -46,5 +46,5 @@ type Adapter interface { // Update updates an existing GCP object. // This should only be called when Find has previously returned true. - Update(ctx context.Context) (*unstructured.Unstructured, error) + Update(ctx context.Context, u *unstructured.Unstructured) error } diff --git a/pkg/controller/direct/resourcemanager/client.go b/pkg/controller/direct/resourcemanager/client.go new file mode 100644 index 0000000000..87cde63693 --- /dev/null +++ b/pkg/controller/direct/resourcemanager/client.go @@ -0,0 +1,68 @@ +// Copyright 2024 Google LLC +// +// 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 resourcemanager + +import ( + "context" + "fmt" + + api "cloud.google.com/go/resourcemanager/apiv3" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller" + "google.golang.org/api/option" +) + +type gcpClient struct { + config controller.Config +} + +func newGCPClient(ctx context.Context, config *controller.Config) (*gcpClient, error) { + gcpClient := &gcpClient{ + config: *config, + } + return gcpClient, nil +} + +func (m *gcpClient) options() ([]option.ClientOption, error) { + var opts []option.ClientOption + if m.config.UserAgent != "" { + opts = append(opts, option.WithUserAgent(m.config.UserAgent)) + } + if m.config.HTTPClient != nil { + // TODO: Set UserAgent in this scenario (error is: WithHTTPClient is incompatible with gRPC dial options) + opts = append(opts, option.WithHTTPClient(m.config.HTTPClient)) + } + if m.config.UserProjectOverride && m.config.BillingProject != "" { + opts = append(opts, option.WithQuotaProject(m.config.BillingProject)) + } + + // TODO: support endpoints? + // if m.config.Endpoint != "" { + // opts = append(opts, option.WithEndpoint(m.config.Endpoint)) + // } + + return opts, nil +} + +func (m *gcpClient) newTagKeysClient(ctx context.Context) (*api.TagKeysClient, error) { + opts, err := m.options() + if err != nil { + return nil, err + } + client, err := api.NewTagKeysRESTClient(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("building tag keys client: %w", err) + } + return client, err +} diff --git a/pkg/controller/direct/resourcemanager/tagkey_controller.go b/pkg/controller/direct/resourcemanager/tagkey_controller.go new file mode 100644 index 0000000000..5802565552 --- /dev/null +++ b/pkg/controller/direct/resourcemanager/tagkey_controller.go @@ -0,0 +1,252 @@ +// Copyright 2024 Google LLC +// +// 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 resourcemanager + +import ( + "context" + "fmt" + "strings" + "time" + + api "cloud.google.com/go/resourcemanager/apiv3" + pb "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb" + "google.golang.org/protobuf/types/known/fieldmaskpb" + "google.golang.org/protobuf/types/known/timestamppb" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/manager" + + krm "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/tags/v1beta1" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/directbase" +) + +// AddTagKeyController creates a new controller and adds it to the Manager. +// The Manager will set fields on the Controller and start it when the Manager is started. +func AddTagKeyController(mgr manager.Manager, config *controller.Config) error { + gvk := krm.TagsTagKeyGVK + + // TODO: Share gcp client (any value in doing so)? + ctx := context.TODO() + gcpClient, err := newGCPClient(ctx, config) + if err != nil { + return err + } + m := &tagKeyModel{gcpClient: gcpClient} + return directbase.Add(mgr, gvk, m) +} + +type tagKeyModel struct { + *gcpClient +} + +// model implements the Model interface. +var _ directbase.Model = &tagKeyModel{} + +type tagKeyAdapter struct { + resourceID string + + desired *krm.TagsTagKey + actual *pb.TagKey + + *gcpClient + tagKeysClient *api.TagKeysClient +} + +// adapter implements the Adapter interface. +var _ directbase.Adapter = &tagKeyAdapter{} + +// AdapterForObject implements the Model interface. +func (m *tagKeyModel) AdapterForObject(ctx context.Context, u *unstructured.Unstructured) (directbase.Adapter, error) { + tagKeysClient, err := m.newTagKeysClient(ctx) + if err != nil { + return nil, err + } + + // TODO: Just fetch this object? + obj := &krm.TagsTagKey{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil { + return nil, fmt.Errorf("error converting to %T: %w", obj, err) + } + + resourceID := ValueOf(obj.Spec.ResourceID) + // resourceID is server-generated, no fallback + // TODO: How do we do resource acquisition - maybe by shortname? + resourceID = strings.TrimPrefix(resourceID, "tagKeys/") + + return &tagKeyAdapter{ + resourceID: resourceID, + desired: obj, + gcpClient: m.gcpClient, + tagKeysClient: tagKeysClient, + }, nil +} + +// Find implements the Adapter interface. +func (a *tagKeyAdapter) Find(ctx context.Context) (bool, error) { + if a.resourceID == "" { + return false, nil + } + + req := &pb.GetTagKeyRequest{ + Name: a.fullyQualifiedName(), + } + tagKey, err := a.tagKeysClient.GetTagKey(ctx, req) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + + a.actual = tagKey + + return true, nil +} + +// Delete implements the Adapter interface. +func (a *tagKeyAdapter) Delete(ctx context.Context) (bool, error) { + // Already deletd + if a.resourceID == "" { + return false, nil + } + + // TODO: Delete via status selfLink? + req := &pb.DeleteTagKeyRequest{ + Name: a.fullyQualifiedName(), + } + + op, err := a.tagKeysClient.DeleteTagKey(ctx, req) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("deleting tagKey: %w", err) + } + + if _, err := op.Wait(ctx); err != nil { + return false, fmt.Errorf("tagKey deletion failed: %w", err) + } + // TODO: Do we need to check that it was deleted? + + return true, nil +} + +// Create implements the Adapter interface. +func (a *tagKeyAdapter) Create(ctx context.Context, u *unstructured.Unstructured) error { + log := klog.FromContext(ctx) + log.V(2).Info("creating object", "u", u) + + // TODO: Should be ref + parent := a.desired.Spec.Parent + tagKey := &pb.TagKey{ + Parent: parent, + ShortName: a.desired.Spec.ShortName, + Description: ValueOf(a.desired.Spec.Description), + PurposeData: a.desired.Spec.PurposeData, + } + + if s := ValueOf(a.desired.Spec.Purpose); s != "" { + purpose, ok := pb.Purpose_value[s] + if !ok { + return fmt.Errorf("unknown purpose %q", s) + } + tagKey.Purpose = pb.Purpose(purpose) + } + req := &pb.CreateTagKeyRequest{ + TagKey: tagKey, + } + + op, err := a.tagKeysClient.CreateTagKey(ctx, req) + if err != nil { + return fmt.Errorf("creating tagKey: %w", err) + } + + created, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("tagKey creation failed: %w", err) + } + + log.V(2).Info("created tagkey", "tagkey", created) + + resourceID := created.Name + if err := unstructured.SetNestedField(u.Object, resourceID, "spec", "resourceID"); err != nil { + return fmt.Errorf("setting spec.resourceID: %w", err) + } + + status := &krm.TagsTagKeyStatus{} + if err := tagKeyStatusToKRM(created, status); err != nil { + return err + } + + return setStatus(u, status) +} + +func tagKeyStatusToKRM(in *pb.TagKey, out *krm.TagsTagKeyStatus) error { + out.NamespacedName = &in.NamespacedName + // TODO: Should be metav1.Time (?) + out.CreateTime = timeToKRMString(in.GetCreateTime()) + out.UpdateTime = timeToKRMString(in.GetUpdateTime()) + return nil +} + +func timeToKRMString(t *timestamppb.Timestamp) *string { + if t == nil { + return nil + } + s := t.AsTime().Format(time.RFC3339Nano) + return &s +} + +// Update implements the Adapter interface. +func (a *tagKeyAdapter) Update(ctx context.Context, u *unstructured.Unstructured) error { + // TODO: Skip updates at the higher level if no changes? + updateMask := &fieldmaskpb.FieldMask{} + update := &pb.TagKey{} + update.Name = a.fullyQualifiedName() + + // description is the only field that can be updated + if ValueOf(a.desired.Spec.Description) != a.actual.GetDescription() { + updateMask.Paths = append(updateMask.Paths, "description") + update.Description = ValueOf(a.desired.Spec.Description) + } + + // TODO: Where/how do we want to enforce immutability? + + if len(updateMask.Paths) != 0 { + req := &pb.UpdateTagKeyRequest{ + TagKey: update, + UpdateMask: updateMask, + } + + op, err := a.tagKeysClient.UpdateTagKey(ctx, req) + if err != nil { + return err + } + + if _, err := op.Wait(ctx); err != nil { + return fmt.Errorf("tagKey update failed: %w", err) + } + // TODO: Do we need to check that the operation succeeeded? + } + + // TODO: Return updated object status + return nil +} + +func (a *tagKeyAdapter) fullyQualifiedName() string { + return fmt.Sprintf("tagKeys/%s", a.resourceID) +} diff --git a/pkg/controller/direct/resourcemanager/utils.go b/pkg/controller/direct/resourcemanager/utils.go new file mode 100644 index 0000000000..81013af78b --- /dev/null +++ b/pkg/controller/direct/resourcemanager/utils.go @@ -0,0 +1,80 @@ +// Copyright 2024 Google LLC +// +// 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 resourcemanager + +import ( + "errors" + "fmt" + + "github.com/googleapis/gax-go/v2/apierror" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" +) + +func setStatus(u *unstructured.Unstructured, typedStatus any) error { + // TODO: Just fetch this object? + status, err := runtime.DefaultUnstructuredConverter.ToUnstructured(typedStatus) + if err != nil { + return fmt.Errorf("error converting status to unstructured: %w", err) + } + // TODO: Merge to avoid overwriting conditions? + u.Object["status"] = status + + return nil +} + +func ValueOf[T any](p *T) T { + var v T + if p != nil { + v = *p + } + return v +} + +func PtrTo[T any](t T) *T { + return &t +} + +func areSame[T comparable](l, r *T) bool { + if l == nil { + return r == nil + } + if r == nil { + return l == nil + } + return *l == *r +} + +// HasHTTPCode returns true if the given error is an HTTP response with the given code. +func HasHTTPCode(err error, code int) bool { + if err == nil { + return false + } + apiError := &apierror.APIError{} + if errors.As(err, &apiError) { + if apiError.HTTPCode() == code { + return true + } + } else { + klog.Warningf("unexpected error type %T", err) + } + return false +} + +// IsNotFound returns true if the given error is an HTTP 404. +func IsNotFound(err error) bool { + return HasHTTPCode(err, 404) +} diff --git a/pkg/controller/registration/registration_controller.go b/pkg/controller/registration/registration_controller.go index 3cd0f8ca4b..8a656d4b41 100644 --- a/pkg/controller/registration/registration_controller.go +++ b/pkg/controller/registration/registration_controller.go @@ -24,6 +24,7 @@ import ( dclcontroller "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/dcl" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/deletiondefender" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/apikeys" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/resourcemanager" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/gsakeysecretgenerator" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/iam/auditconfig" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/iam/partialpolicy" @@ -181,6 +182,13 @@ func registerDefaultController(r *ReconcileRegistration, config *controller.Conf return nil, err } return schemaUpdater, nil + + case schema.GroupKind{Group: "tags.cnrm.cloud.google.com", Kind: "TagsTagKey"}: + if err := resourcemanager.AddTagKeyController(r.mgr, config); err != nil { + return nil, err + } + return schemaUpdater, nil + default: return nil, fmt.Errorf("requested direct reconciler for %v, but it is not supported", gvk.GroupKind()) }