diff --git a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_spannerdatabases.spanner.cnrm.cloud.google.com.yaml b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_spannerdatabases.spanner.cnrm.cloud.google.com.yaml index d9c2f419c0c..562b6ea82bf 100644 --- a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_spannerdatabases.spanner.cnrm.cloud.google.com.yaml +++ b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_spannerdatabases.spanner.cnrm.cloud.google.com.yaml @@ -8,7 +8,6 @@ metadata: cnrm.cloud.google.com/managed-by-kcc: "true" cnrm.cloud.google.com/stability-level: stable cnrm.cloud.google.com/system: "true" - cnrm.cloud.google.com/tf2crd: "true" name: spannerdatabases.spanner.cnrm.cloud.google.com spec: group: spanner.cnrm.cloud.google.com diff --git a/go.mod b/go.mod index 9c5ed0ca34d..922782a486d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( cloud.google.com/go/monitoring v1.19.0 cloud.google.com/go/profiler v0.1.0 cloud.google.com/go/resourcemanager v1.9.7 + cloud.google.com/go/spanner v1.60.0 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 de172573692..bbacee00043 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIA cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/resourcemanager v1.9.7 h1:SdvD0PaPX60+yeKoSe16mawFpM0EPuiPPihTIVlhRsY= cloud.google.com/go/resourcemanager v1.9.7/go.mod h1:cQH6lJwESufxEu6KepsoNAsjrUtYYNXRwxm4QFE5g8A= +cloud.google.com/go/spanner v1.60.0 h1:O9kf49dfaDRzPpKJNChHUJ+Bao02WPedZb8ZPyi02lI= +cloud.google.com/go/spanner v1.60.0/go.mod h1:D2bOAeT/dC6zsZhXRIxbdYa5nQEYU3wYM/1KN3eg7Fs= 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= @@ -866,8 +868,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/hack/compare-mock b/hack/compare-mock index 771dc465738..db1621c9f05 100755 --- a/hack/compare-mock +++ b/hack/compare-mock @@ -5,6 +5,8 @@ set -x export KUBEBUILDER_ASSETS=$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use -p path) +export KCC_USE_DIRECT_RECONCILERS=SpannerDatabase + rm -rf $(pwd)/artifactz/mocks RUN_TESTS=TestAllInSeries/$1 diff --git a/pkg/controller/direct/directbase/utils.go b/pkg/controller/direct/directbase/utils.go new file mode 100644 index 00000000000..e63c6d55d78 --- /dev/null +++ b/pkg/controller/direct/directbase/utils.go @@ -0,0 +1,51 @@ +// 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 directbase + +import ( + "errors" + + "github.com/googleapis/gax-go/v2/apierror" + "k8s.io/klog/v2" +) + +func ValueOf[T any](p *T) T { + var v T + if p != nil { + v = *p + } + return v +} + +// 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/direct/register/register.go b/pkg/controller/direct/register/register.go index 619d85e7458..bab5ac2e6da 100644 --- a/pkg/controller/direct/register/register.go +++ b/pkg/controller/direct/register/register.go @@ -20,4 +20,5 @@ import ( _ "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/gkehub" _ "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/logging" _ "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/resourcemanager" + _ "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/spanner" ) diff --git a/pkg/controller/direct/spanner/database_controller.go b/pkg/controller/direct/spanner/database_controller.go new file mode 100644 index 00000000000..3d37f2abf02 --- /dev/null +++ b/pkg/controller/direct/spanner/database_controller.go @@ -0,0 +1,235 @@ +// 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 spanner + +import ( + "context" + "fmt" + + databaseapi "cloud.google.com/go/spanner/admin/database/apiv1" + "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" + "google.golang.org/api/option" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + krm "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/spanner/v1beta1" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/directbase" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/k8s" +) + +const ctlrName = "spannerdatabase-controller" + +func init() { + directbase.ControllerBuilder.RegisterModel(krm.SpannerDatabaseGVK, NewSpannerDatabaseModel) +} + +type spannerDatabaseModel struct { + config *controller.Config +} + +var _ directbase.Model = &spannerDatabaseModel{} + +func NewSpannerDatabaseModel(config *controller.Config) directbase.Model { + return &spannerDatabaseModel{config: config} +} + +var _ directbase.Adapter = &spannerDatabaseAdapter{} + +type spannerDatabaseAdapter struct { + projectID string + instanceID string + databaseID string + + desired *krm.SpannerDatabase + + dbClient *databaseapi.DatabaseAdminClient +} + +func (m *spannerDatabaseModel) client(ctx context.Context) (*databaseapi.DatabaseAdminClient, error) { + var opts []option.ClientOption + if m.config.UserAgent != "" { + opts = append(opts, option.WithUserAgent(m.config.UserAgent)) + } + if m.config.HTTPClient != nil { + opts = append(opts, option.WithHTTPClient(m.config.HTTPClient)) + } + if m.config.UserProjectOverride && m.config.BillingProject != "" { + opts = append(opts, option.WithQuotaProject(m.config.BillingProject)) + } + + gcpClient, err := databaseapi.NewDatabaseAdminRESTClient(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("building SpannerDatabase client: %w", err) + } + return gcpClient, err +} + +// AdapterForObject implements the Model interface. +func (m *spannerDatabaseModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) { + client, err := m.client(ctx) + if err != nil { + return nil, err + } + + obj := &krm.SpannerDatabase{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil { + return nil, fmt.Errorf("error converting to %T: %w", obj, err) + } + + // TODO: Resolve external + instanceID := obj.Spec.InstanceRef.Name + + // TODO(yuwenma): following current behavior. But do we have better option? + databaseID := directbase.ValueOf(obj.Spec.ResourceID) + if databaseID == "" { + databaseID = obj.GetName() + } + + // TODO(yuwenma): following current behavior. But do we have better option? + projectID, ok := u.GetAnnotations()[k8s.ProjectIDAnnotation] + if !ok { + projectID = u.GetNamespace() + } + + return &spannerDatabaseAdapter{ + projectID: projectID, + instanceID: instanceID, + databaseID: databaseID, + desired: obj, + dbClient: client, + }, nil +} + +// Find implements the Adapter interface. +func (a *spannerDatabaseAdapter) Find(ctx context.Context) (bool, error) { + if a.databaseID == "" { + return false, nil + } + + req := &databasepb.GetDatabaseRequest{ + Name: a.fullyQualifiedName(), + } + _, err := a.dbClient.GetDatabase(ctx, req) + if err != nil { + if directbase.IsNotFound(err) { + klog.Warningf("SpannerDatabase was not found: %v", err) + return false, nil + } + return false, err + } + + return true, nil +} + +// Delete implements the Adapter interface. +func (a *spannerDatabaseAdapter) Delete(ctx context.Context) (bool, error) { + // TODO: Delete via status selfLink + req := &databasepb.DropDatabaseRequest{ + Database: a.fullyQualifiedName(), + } + if err := a.dbClient.DropDatabase(ctx, req); err != nil { + if directbase.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("deleting key: %w", err) + } + return true, nil +} + +// Create implements the Adapter interface. +func (a *spannerDatabaseAdapter) Create(ctx context.Context, u *unstructured.Unstructured) error { + log := klog.FromContext(ctx) + log.V(2).Info("creating object", "u", u) + + req, err := a.spannerDatabaseKRMToCreateDatabaseRequest(a.desired) + if err != nil { + return fmt.Errorf("convert SpannerDatabase KRM to CreateDatabaseRequest API: %w", err) + } + + log.Info("creating spannerDatabase", "spannerDatabase", req) + + op, err := a.dbClient.CreateDatabase(ctx, req) + if err != nil { + return fmt.Errorf("creating spannerDatabase: %w", err) + } + created, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("waiting for spannerDatabase creation: %w", err) + } + log.V(2).Info("created spannerDatabase", "spannerDatabase", created) + + return nil +} + +func (a *spannerDatabaseAdapter) Update(ctx context.Context, u *unstructured.Unstructured) error { + // TODO + return nil +} + +func (a *spannerDatabaseAdapter) Export(ctx context.Context) (*unstructured.Unstructured, error) { + return nil, nil +} + +func (a *spannerDatabaseAdapter) fullyQualifiedName() string { + return fmt.Sprintf("projects/%s/instances/%s/databases/%s", a.projectID, a.instanceID, a.databaseID) +} + +func (a *spannerDatabaseAdapter) spannerDatabaseKRMToCreateDatabaseRequest(r *krm.SpannerDatabase) (*databasepb.CreateDatabaseRequest, error) { + // Default database dialect is GOOGLE_STANDARD_SQL + databaseDialect := databasepb.DatabaseDialect_DATABASE_DIALECT_UNSPECIFIED + if r.Spec.DatabaseDialect != nil { + if directbase.ValueOf(r.Spec.DatabaseDialect) == "GOOGLE_STANDARD_SQL" { + databaseDialect = databasepb.DatabaseDialect_GOOGLE_STANDARD_SQL + } else if directbase.ValueOf(r.Spec.DatabaseDialect) == "POSTGRESQL" { + databaseDialect = databasepb.DatabaseDialect_POSTGRESQL + } else { + return nil, fmt.Errorf("unsupported database dialect: %s", directbase.ValueOf(r.Spec.DatabaseDialect)) + } + } + + // For POSTGRESQL, database name must be enclosed in double quotes + createDelimiter := '`' + if databaseDialect == databasepb.DatabaseDialect_POSTGRESQL { + createDelimiter = '"' + } + createStatement := fmt.Sprintf( + "CREATE DATABASE %c%s%c", + createDelimiter, + a.databaseID, + createDelimiter, + ) + + // Add version retention period if specified + extraStatements := r.Spec.Ddl + if r.Spec.VersionRetentionPeriod != nil { + extraStatements = append(extraStatements, fmt.Sprintf( + "ALTER DATABASE %c%s%c SET OPTIONS (version_retention_period = '%s')", + createDelimiter, + a.databaseID, + createDelimiter, + directbase.ValueOf(r.Spec.VersionRetentionPeriod)), + ) + } + + return &databasepb.CreateDatabaseRequest{ + Parent: fmt.Sprintf("projects/%s/instances/%s", a.projectID, a.instanceID), + CreateStatement: createStatement, + ExtraStatements: extraStatements, + DatabaseDialect: databaseDialect, + }, nil +} diff --git a/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_generated_object_spannerdatabase.golden.yaml b/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_generated_object_spannerdatabase.golden.yaml index 6c5a8c2a189..19046c1413a 100644 --- a/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_generated_object_spannerdatabase.golden.yaml +++ b/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_generated_object_spannerdatabase.golden.yaml @@ -3,14 +3,11 @@ kind: SpannerDatabase metadata: annotations: cnrm.cloud.google.com/management-conflict-prevention-policy: none - cnrm.cloud.google.com/mutable-but-unreadable-fields: '{"spec":{"ddl":["CREATE - TABLE t1 (t1 INT64 NOT NULL,) PRIMARY KEY(t1)"]}}' cnrm.cloud.google.com/project-id: ${projectId} - cnrm.cloud.google.com/state-into-spec: merge finalizers: - cnrm.cloud.google.com/finalizer - cnrm.cloud.google.com/deletion-defender - generation: 2 + generation: 1 labels: cnrm-test: "true" name: spannerdatabase-test @@ -20,7 +17,6 @@ spec: - CREATE TABLE t1 (t1 INT64 NOT NULL,) PRIMARY KEY(t1) instanceRef: name: spannerinstance-${uniqueId} - resourceID: spannerdatabase-test status: conditions: - lastTransitionTime: "1970-01-01T00:00:00Z" @@ -28,4 +24,4 @@ status: reason: UpToDate status: "True" type: Ready - observedGeneration: 2 + observedGeneration: 1 diff --git a/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_http.log b/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_http.log index 48d00ffbb02..10471f01e5e 100644 --- a/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_http.log +++ b/pkg/test/resourcefixture/testdata/basic/spanner/v1beta1/spannerdatabase/_http.log @@ -125,9 +125,10 @@ X-Xss-Protection: 0 --- -GET https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?alt=json +GET https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?%24alt=json%3Benum-encoding%3Dint Content-Type: application/json -User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/kcc/controller-manager +x-goog-api-client: gl-go/1.22.3 gapic/1.60.0 gax/2.12.3 rest/UNKNOWN +x-goog-request-params: name=projects%2F${projectId}%2Finstances%2Fspannerinstance-${uniqueId}%2Fdatabases%2Fspannerdatabase-test 404 Not Found Cache-Control: private @@ -150,12 +151,14 @@ X-Xss-Protection: 0 --- -POST https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases?alt=json +POST https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases?%24alt=json%3Benum-encoding%3Dint Content-Type: application/json -User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/kcc/controller-manager +x-goog-api-client: gl-go/1.22.3 gapic/1.60.0 gax/2.12.3 rest/UNKNOWN +x-goog-request-params: parent=projects%2F${projectId}%2Finstances%2Fspannerinstance-${uniqueId} { - "createStatement": "CREATE DATABASE `spannerdatabase-test`" + "createStatement": "CREATE DATABASE `spannerdatabase-test`", + "parent": "projects/${projectId}/instances/spannerinstance-${uniqueId}" } 200 OK @@ -176,9 +179,10 @@ X-Xss-Protection: 0 --- -GET https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?alt=json +GET https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?%24alt=json%3Benum-encoding%3Dint Content-Type: application/json -User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/kcc/controller-manager +x-goog-api-client: gl-go/1.22.3 gapic/1.60.0 gax/2.12.3 rest/UNKNOWN +x-goog-request-params: name=projects%2F${projectId}%2Finstances%2Fspannerinstance-${uniqueId}%2Fdatabases%2Fspannerdatabase-test 200 OK Cache-Control: private @@ -198,9 +202,10 @@ X-Xss-Protection: 0 --- -DELETE https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?alt=json +DELETE https://spanner.googleapis.com/v1/projects/${projectId}/instances/spannerinstance-${uniqueId}/databases/spannerdatabase-test?%24alt=json%3Benum-encoding%3Dint Content-Type: application/json -User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/kcc/controller-manager +x-goog-api-client: gl-go/1.22.3 gapic/1.60.0 gax/2.12.3 rest/UNKNOWN +x-goog-request-params: database=projects%2F${projectId}%2Finstances%2Fspannerinstance-${uniqueId}%2Fdatabases%2Fspannerdatabase-test 200 OK Cache-Control: private