From 395fef757e2037958aaba33d3ede055554f10c3d Mon Sep 17 00:00:00 2001 From: Jason Vigil Date: Thu, 22 Aug 2024 16:31:35 +0000 Subject: [PATCH] feat: Add cloudsql support for "create from clone" --- apis/sql/v1beta1/sqlinstance_types.go | 34 +- apis/sql/v1beta1/zz_generated.deepcopy.go | 53 ++ ...qlinstances.sql.cnrm.cloud.google.com.yaml | 67 +- mockgcp/mocksql/sqlinstance.go | 35 + .../apis/sql/v1beta1/sqlinstance_types.go | 32 + .../apis/sql/v1beta1/zz_generated.deepcopy.go | 67 ++ pkg/controller/direct/sql/mapping.go | 53 +- pkg/controller/direct/sql/merge.go | 2 +- pkg/controller/direct/sql/normalize.go | 72 +- .../direct/sql/sqlinstance_controller.go | 103 ++- pkg/controller/direct/sql/utils.go | 8 + ...d_object_sqlinstanceclonebasic.golden.yaml | 50 ++ .../sqlinstanceclonebasic/_http.log | 620 ++++++++++++++++++ .../sqlinstanceclonebasic/create.yaml | 35 + .../sqlinstanceclonebasic/dependencies.yaml | 32 + .../resource-docs/sql/sqlinstance.md | 121 ++++ 16 files changed, 1326 insertions(+), 58 deletions(-) create mode 100644 pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_generated_object_sqlinstanceclonebasic.golden.yaml create mode 100644 pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_http.log create mode 100644 pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/create.yaml create mode 100644 pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/dependencies.yaml diff --git a/apis/sql/v1beta1/sqlinstance_types.go b/apis/sql/v1beta1/sqlinstance_types.go index d7f8ca6ecf..913fef4db8 100644 --- a/apis/sql/v1beta1/sqlinstance_types.go +++ b/apis/sql/v1beta1/sqlinstance_types.go @@ -204,8 +204,6 @@ type InstanceMaintenanceWindow struct { UpdateTrack *string `json:"updateTrack,omitempty"` } -// +kubebuilder:validation:XValidation:rule="has(self.value) ? !has(self.valueFrom) : true",message="valueFrom is forbidden when value is specified" -// +kubebuilder:validation:XValidation:rule="has(self.valueFrom) ? !has(self.value): true",message="value is forbidden when valueFrom is specified" type InstancePassword struct { /* Value of the field. Cannot be used if 'valueFrom' is specified. */ // +optional @@ -297,8 +295,6 @@ type InstanceReplicaConfiguration struct { VerifyServerCertificate *bool `json:"verifyServerCertificate,omitempty"` } -// +kubebuilder:validation:XValidation:rule="has(self.value) ? !has(self.valueFrom) : true",message="valueFrom is forbidden when value is specified" -// +kubebuilder:validation:XValidation:rule="has(self.valueFrom) ? !has(self.value): true",message="value is forbidden when valueFrom is specified" type InstanceRootPassword struct { /* Value of the field. Cannot be used if 'valueFrom' is specified. */ // +optional @@ -441,7 +437,37 @@ type InstanceValueFrom struct { SecretKeyRef *v1alpha1.SecretKeyRef `json:"secretKeyRef,omitempty"` } +type BinLogCoordinates struct { + /* Name of the binary log file for a Cloud SQL instance. */ + BinLogFileName string `json:"binLogFileName,omitempty"` + + /* Position (offset) within the binary log file. */ + BinLogPosition int64 `json:"binLogPosition,omitempty,string"` +} + +type CloneSource struct { + /* Binary log coordinates, if specified, identify the position up to which the source instance is + cloned. If not specified, the source instance is cloned up to the most recent binary log coordinates. */ + // +optional + BinLogCoordinates *BinLogCoordinates `json:"binLogCoordinates,omitempty"` + + /* (SQL Server only) Clone only the specified databases from the source instance. Clone all databases if empty. */ + // +optional + DatabaseNames []string `json:"databaseNames,omitempty"` + + /* Timestamp, if specified, identifies the time to which the source instance is cloned. */ + // +optional + PointInTime *string `json:"pointInTime,omitempty"` + + /* The source SQLInstance to clone */ + SQLInstanceRef refsv1beta1.SQLInstanceRef `json:"sqlInstanceRef,omitempty"` +} + type SQLInstanceSpec struct { + /* Create this database as a clone of a source instance. Immutable. */ + // +optional + CloneSource *CloneSource `json:"cloneSource,omitempty"` + /* The MySQL, PostgreSQL or SQL Server (beta) version to use. Supported values include MYSQL_5_6, MYSQL_5_7, MYSQL_8_0, POSTGRES_9_6, POSTGRES_10, POSTGRES_11, POSTGRES_12, POSTGRES_13, POSTGRES_14, POSTGRES_15, SQLSERVER_2017_STANDARD, SQLSERVER_2017_ENTERPRISE, SQLSERVER_2017_EXPRESS, SQLSERVER_2017_WEB. Database Version Policies includes an up-to-date reference of supported versions. */ // +optional DatabaseVersion *string `json:"databaseVersion,omitempty"` diff --git a/apis/sql/v1beta1/zz_generated.deepcopy.go b/apis/sql/v1beta1/zz_generated.deepcopy.go index a4efc1c72c..4290da13dc 100644 --- a/apis/sql/v1beta1/zz_generated.deepcopy.go +++ b/apis/sql/v1beta1/zz_generated.deepcopy.go @@ -24,6 +24,54 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BinLogCoordinates) DeepCopyInto(out *BinLogCoordinates) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BinLogCoordinates. +func (in *BinLogCoordinates) DeepCopy() *BinLogCoordinates { + if in == nil { + return nil + } + out := new(BinLogCoordinates) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CloneSource) DeepCopyInto(out *CloneSource) { + *out = *in + if in.BinLogCoordinates != nil { + in, out := &in.BinLogCoordinates, &out.BinLogCoordinates + *out = new(BinLogCoordinates) + **out = **in + } + if in.DatabaseNames != nil { + in, out := &in.DatabaseNames, &out.DatabaseNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.PointInTime != nil { + in, out := &in.PointInTime, &out.PointInTime + *out = new(string) + **out = **in + } + out.SQLInstanceRef = in.SQLInstanceRef + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloneSource. +func (in *CloneSource) DeepCopy() *CloneSource { + if in == nil { + return nil + } + out := new(CloneSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InstanceActiveDirectoryConfig) DeepCopyInto(out *InstanceActiveDirectoryConfig) { *out = *in @@ -904,6 +952,11 @@ func (in *SQLInstanceList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SQLInstanceSpec) DeepCopyInto(out *SQLInstanceSpec) { *out = *in + if in.CloneSource != nil { + in, out := &in.CloneSource, &out.CloneSource + *out = new(CloneSource) + (*in).DeepCopyInto(*out) + } if in.DatabaseVersion != nil { in, out := &in.DatabaseVersion, &out.DatabaseVersion *out = new(string) diff --git a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_sqlinstances.sql.cnrm.cloud.google.com.yaml b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_sqlinstances.sql.cnrm.cloud.google.com.yaml index e3085be2af..947cb67239 100644 --- a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_sqlinstances.sql.cnrm.cloud.google.com.yaml +++ b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_sqlinstances.sql.cnrm.cloud.google.com.yaml @@ -60,6 +60,63 @@ spec: type: object spec: properties: + cloneSource: + description: Create this database as a clone of a source instance. + Immutable. + properties: + binLogCoordinates: + description: Binary log coordinates, if specified, identify the + position up to which the source instance is cloned. If not specified, + the source instance is cloned up to the most recent binary log + coordinates. + properties: + binLogFileName: + description: Name of the binary log file for a Cloud SQL instance. + type: string + binLogPosition: + description: Position (offset) within the binary log file. + format: int64 + type: integer + type: object + databaseNames: + description: (SQL Server only) Clone only the specified databases + from the source instance. Clone all databases if empty. + items: + type: string + type: array + pointInTime: + description: Timestamp, if specified, identifies the time to which + the source instance is cloned. + type: string + sqlInstanceRef: + description: The source SQLInstance to clone + oneOf: + - not: + required: + - external + required: + - name + - not: + anyOf: + - required: + - name + - required: + - namespace + required: + - external + properties: + external: + description: The SQLInstance selfLink, when not managed by + Config Connector. + type: string + name: + description: The `name` field of a `SQLInstance` resource. + type: string + namespace: + description: The `namespace` field of a `SQLInstance` resource. + type: string + type: object + type: object databaseVersion: description: The MySQL, PostgreSQL or SQL Server (beta) version to use. Supported values include MYSQL_5_6, MYSQL_5_7, MYSQL_8_0, POSTGRES_9_6, @@ -201,11 +258,6 @@ spec: type: object type: object type: object - x-kubernetes-validations: - - message: valueFrom is forbidden when value is specified - rule: 'has(self.value) ? !has(self.valueFrom) : true' - - message: value is forbidden when valueFrom is specified - rule: 'has(self.valueFrom) ? !has(self.value): true' sslCipher: description: Immutable. Permissible ciphers for use in SSL encryption. type: string @@ -249,11 +301,6 @@ spec: type: object type: object type: object - x-kubernetes-validations: - - message: valueFrom is forbidden when value is specified - rule: 'has(self.value) ? !has(self.valueFrom) : true' - - message: value is forbidden when valueFrom is specified - rule: 'has(self.valueFrom) ? !has(self.value): true' settings: description: The settings to use for the database. The configuration is detailed below. diff --git a/mockgcp/mocksql/sqlinstance.go b/mockgcp/mocksql/sqlinstance.go index 6b3ed1f715..03b1736ac8 100644 --- a/mockgcp/mocksql/sqlinstance.go +++ b/mockgcp/mocksql/sqlinstance.go @@ -51,6 +51,41 @@ func (s *sqlInstancesService) Get(ctx context.Context, req *pb.SqlInstancesGetRe return obj, nil } +func (s *sqlInstancesService) Clone(ctx context.Context, req *pb.SqlInstancesCloneRequest) (*pb.Operation, error) { + sourceFQN, err := s.buildInstanceName(req.GetProject(), req.GetInstance()) + if err != nil { + return nil, err + } + + source := &pb.DatabaseInstance{} + if err := s.storage.Get(ctx, sourceFQN.String(), source); err != nil { + return nil, err + } + + cloneName := req.Body.CloneContext.DestinationInstanceName + clone := proto.Clone(source).(*pb.DatabaseInstance) + clone.Name = cloneName + + insertReq := &pb.SqlInstancesInsertRequest{ + Project: req.GetProject(), + Body: clone, + } + + insertOp, err := s.Insert(ctx, insertReq) + if err != nil { + return nil, err + } + + cloneOp := &pb.Operation{ + TargetProject: insertOp.TargetProject, + OperationType: pb.Operation_CLONE, + } + + return s.operations.startLRO(ctx, cloneOp, clone, func() (proto.Message, error) { + return clone, nil + }) +} + func (s *sqlInstancesService) Insert(ctx context.Context, req *pb.SqlInstancesInsertRequest) (*pb.Operation, error) { name, err := s.buildInstanceName(req.GetProject(), req.GetBody().GetName()) if err != nil { diff --git a/pkg/clients/generated/apis/sql/v1beta1/sqlinstance_types.go b/pkg/clients/generated/apis/sql/v1beta1/sqlinstance_types.go index 010380c053..fed22c7373 100644 --- a/pkg/clients/generated/apis/sql/v1beta1/sqlinstance_types.go +++ b/pkg/clients/generated/apis/sql/v1beta1/sqlinstance_types.go @@ -94,6 +94,34 @@ type InstanceBackupRetentionSettings struct { RetentionUnit *string `json:"retentionUnit,omitempty"` } +type InstanceBinLogCoordinates struct { + /* Name of the binary log file for a Cloud SQL instance. */ + // +optional + BinLogFileName *string `json:"binLogFileName,omitempty"` + + /* Position (offset) within the binary log file. */ + // +optional + BinLogPosition *int64 `json:"binLogPosition,omitempty"` +} + +type InstanceCloneSource struct { + /* Binary log coordinates, if specified, identify the position up to which the source instance is cloned. If not specified, the source instance is cloned up to the most recent binary log coordinates. */ + // +optional + BinLogCoordinates *InstanceBinLogCoordinates `json:"binLogCoordinates,omitempty"` + + /* (SQL Server only) Clone only the specified databases from the source instance. Clone all databases if empty. */ + // +optional + DatabaseNames []string `json:"databaseNames,omitempty"` + + /* Timestamp, if specified, identifies the time to which the source instance is cloned. */ + // +optional + PointInTime *string `json:"pointInTime,omitempty"` + + /* The source SQLInstance to clone */ + // +optional + SqlInstanceRef *v1alpha1.ResourceRef `json:"sqlInstanceRef,omitempty"` +} + type InstanceDataCacheConfig struct { /* Whether data cache is enabled for the instance. */ // +optional @@ -426,6 +454,10 @@ type InstanceValueFrom struct { } type SQLInstanceSpec struct { + /* Create this database as a clone of a source instance. Immutable. */ + // +optional + CloneSource *InstanceCloneSource `json:"cloneSource,omitempty"` + /* The MySQL, PostgreSQL or SQL Server (beta) version to use. Supported values include MYSQL_5_6, MYSQL_5_7, MYSQL_8_0, POSTGRES_9_6, POSTGRES_10, POSTGRES_11, POSTGRES_12, POSTGRES_13, POSTGRES_14, POSTGRES_15, SQLSERVER_2017_STANDARD, SQLSERVER_2017_ENTERPRISE, SQLSERVER_2017_EXPRESS, SQLSERVER_2017_WEB. Database Version Policies includes an up-to-date reference of supported versions. */ // +optional DatabaseVersion *string `json:"databaseVersion,omitempty"` diff --git a/pkg/clients/generated/apis/sql/v1beta1/zz_generated.deepcopy.go b/pkg/clients/generated/apis/sql/v1beta1/zz_generated.deepcopy.go index 44bb69ce3f..3a7a1b308f 100644 --- a/pkg/clients/generated/apis/sql/v1beta1/zz_generated.deepcopy.go +++ b/pkg/clients/generated/apis/sql/v1beta1/zz_generated.deepcopy.go @@ -164,6 +164,68 @@ func (in *InstanceBackupRetentionSettings) DeepCopy() *InstanceBackupRetentionSe return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceBinLogCoordinates) DeepCopyInto(out *InstanceBinLogCoordinates) { + *out = *in + if in.BinLogFileName != nil { + in, out := &in.BinLogFileName, &out.BinLogFileName + *out = new(string) + **out = **in + } + if in.BinLogPosition != nil { + in, out := &in.BinLogPosition, &out.BinLogPosition + *out = new(int64) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceBinLogCoordinates. +func (in *InstanceBinLogCoordinates) DeepCopy() *InstanceBinLogCoordinates { + if in == nil { + return nil + } + out := new(InstanceBinLogCoordinates) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceCloneSource) DeepCopyInto(out *InstanceCloneSource) { + *out = *in + if in.BinLogCoordinates != nil { + in, out := &in.BinLogCoordinates, &out.BinLogCoordinates + *out = new(InstanceBinLogCoordinates) + (*in).DeepCopyInto(*out) + } + if in.DatabaseNames != nil { + in, out := &in.DatabaseNames, &out.DatabaseNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.PointInTime != nil { + in, out := &in.PointInTime, &out.PointInTime + *out = new(string) + **out = **in + } + if in.SqlInstanceRef != nil { + in, out := &in.SqlInstanceRef, &out.SqlInstanceRef + *out = new(v1alpha1.ResourceRef) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceCloneSource. +func (in *InstanceCloneSource) DeepCopy() *InstanceCloneSource { + if in == nil { + return nil + } + out := new(InstanceCloneSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InstanceDataCacheConfig) DeepCopyInto(out *InstanceDataCacheConfig) { *out = *in @@ -1038,6 +1100,11 @@ func (in *SQLInstanceList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SQLInstanceSpec) DeepCopyInto(out *SQLInstanceSpec) { *out = *in + if in.CloneSource != nil { + in, out := &in.CloneSource, &out.CloneSource + *out = new(InstanceCloneSource) + (*in).DeepCopyInto(*out) + } if in.DatabaseVersion != nil { in, out := &in.DatabaseVersion, &out.DatabaseVersion *out = new(string) diff --git a/pkg/controller/direct/sql/mapping.go b/pkg/controller/direct/sql/mapping.go index 046efedfa8..c35979ac93 100644 --- a/pkg/controller/direct/sql/mapping.go +++ b/pkg/controller/direct/sql/mapping.go @@ -21,15 +21,22 @@ import ( refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1" krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/sql/v1beta1" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct" ) -func SQLInstanceKRMToGCP(in *krm.SQLInstance, refs *SQLInstanceInternalRefs) (*api.DatabaseInstance, error) { +func SQLInstanceKRMToGCPInstance(in *krm.SQLInstance, refs *SQLInstanceInternalRefs) (*api.DatabaseInstance, error) { out := &api.DatabaseInstance{} if in == nil { return nil, fmt.Errorf("cannot convert nil SQLInstance") } + if in.Spec.CloneSource != nil { + // If spec.cloneSource is specified, it's invalid to convert krm.SQLInstance -> api.DatabaseInstance. + // Instead, the krm.SQLInstance should be converted to an api.InstancesCloneRequest. + return nil, fmt.Errorf("cannot convert SQLInstance with CloneSource specified") + } + if in.Spec.DatabaseVersion != nil { out.DatabaseVersion = *in.Spec.DatabaseVersion } @@ -793,10 +800,44 @@ func Convert_SQLInstance_API_v1_To_KRM_status(in *api.DatabaseInstance, out *krm return nil } -func LazyPtr[T comparable](v T) *T { - var defaultValue T - if v == defaultValue { - return nil +func SQLInstanceKRMToGCPCloneRequest(in *krm.SQLInstance, refs *SQLInstanceInternalRefs) (*api.InstancesCloneRequest, error) { + if in == nil { + return nil, fmt.Errorf("cannot convert nil SQLInstance") + } + + if in.Spec.CloneSource == nil { + // spec.cloneSource is required for converting KRM.SQLInstance -> api.InstancesCloneRequest. + return nil, fmt.Errorf("cannot convert nil CloneSource") + } + + cloneReq := &api.InstancesCloneRequest{ + CloneContext: &api.CloneContext{ + DatabaseNames: in.Spec.CloneSource.DatabaseNames, + Kind: "sql#cloneContext", + PointInTime: direct.ValueOf(in.Spec.CloneSource.PointInTime), + }, + } + + resourceID := ValueOf(in.Spec.ResourceID) + if resourceID == "" { + resourceID = in.Name } - return &v + cloneReq.CloneContext.DestinationInstanceName = resourceID + + if in.Spec.Settings.IpConfiguration != nil { + cloneReq.CloneContext.AllocatedIpRange = ValueOf(in.Spec.Settings.IpConfiguration.AllocatedIpRange) + } + + if in.Spec.CloneSource.BinLogCoordinates != nil { + cloneReq.CloneContext.BinLogCoordinates = &api.BinLogCoordinates{ + BinLogFileName: in.Spec.CloneSource.BinLogCoordinates.BinLogFileName, + BinLogPosition: in.Spec.CloneSource.BinLogCoordinates.BinLogPosition, + } + } + + if in.Spec.Settings.LocationPreference != nil { + cloneReq.CloneContext.PreferredZone = ValueOf(in.Spec.Settings.LocationPreference.Zone) + } + + return cloneReq, nil } diff --git a/pkg/controller/direct/sql/merge.go b/pkg/controller/direct/sql/merge.go index 10966dd654..f8f67c002d 100644 --- a/pkg/controller/direct/sql/merge.go +++ b/pkg/controller/direct/sql/merge.go @@ -626,7 +626,7 @@ func MergeDesiredSQLInstanceWithActual(desired *krm.SQLInstance, refs *SQLInstan } if desired.Spec.Settings.DiskAutoresize != nil { - if desired.Spec.Settings.DiskAutoresize != actual.Settings.StorageAutoResize { + if direct.ValueOf(desired.Spec.Settings.DiskAutoresize) != direct.ValueOf(actual.Settings.StorageAutoResize) { // Change disk autoresize updateRequired = true } diff --git a/pkg/controller/direct/sql/normalize.go b/pkg/controller/direct/sql/normalize.go index ccd313a4c7..cf97c9b218 100644 --- a/pkg/controller/direct/sql/normalize.go +++ b/pkg/controller/direct/sql/normalize.go @@ -32,12 +32,13 @@ import ( ) type SQLInstanceInternalRefs struct { - cryptoKey string - masterInstance string - replicaPassword string - rootPassword string - privateNetwork string - auditLogBucket string + cryptoKey string + masterInstance string + replicaPassword string + rootPassword string + privateNetwork string + auditLogBucket string + sourceSQLInstance string } func NormalizeSQLInstance(ctx context.Context, kube client.Reader, obj *krm.SQLInstance) (*SQLInstanceInternalRefs, error) { @@ -65,6 +66,10 @@ func NormalizeSQLInstance(ctx context.Context, kube client.Reader, obj *krm.SQLI if err != nil { return nil, err } + sourceSQLInstance, err := normalizeSourceSQLInstanceRef(ctx, kube, obj) + if err != nil { + return nil, err + } if err := normalizeLabels(obj); err != nil { return nil, err } @@ -72,12 +77,13 @@ func NormalizeSQLInstance(ctx context.Context, kube client.Reader, obj *krm.SQLI return nil, err } return &SQLInstanceInternalRefs{ - cryptoKey: cryptoKeyRef, - masterInstance: masterInstanceRef, - replicaPassword: replicaPassword, - rootPassword: rootPassword, - privateNetwork: privateNetwork, - auditLogBucket: auditLogBucket, + cryptoKey: cryptoKeyRef, + masterInstance: masterInstanceRef, + replicaPassword: replicaPassword, + rootPassword: rootPassword, + privateNetwork: privateNetwork, + auditLogBucket: auditLogBucket, + sourceSQLInstance: sourceSQLInstance, }, nil } @@ -290,6 +296,48 @@ func normalizeAuditLogBucketRef(ctx context.Context, kube client.Reader, obj *kr } } +func normalizeSourceSQLInstanceRef(ctx context.Context, kube client.Reader, obj *krm.SQLInstance) (string, error) { + if obj.Spec.CloneSource == nil { + return "", nil + } + + sqlInstanceRef := obj.Spec.CloneSource.SQLInstanceRef + + if sqlInstanceRef.External != "" && sqlInstanceRef.Name != "" { + return "", fmt.Errorf("cannot specify both spec.settings.cloneSource.sqlInstanceRef.external and spec.settings.cloneSource.sqlInstanceRef.name") + } + + if sqlInstanceRef.External != "" { + return sqlInstanceRef.External, nil + } else if sqlInstanceRef.Name != "" { + if sqlInstanceRef.Namespace == "" { + sqlInstanceRef.Namespace = obj.Namespace + } + + key := types.NamespacedName{ + Namespace: sqlInstanceRef.Namespace, + Name: sqlInstanceRef.Name, + } + + sqlInstance := &unstructured.Unstructured{} + sqlInstance.SetGroupVersionKind(krm.SQLInstanceGVK) + if err := kube.Get(ctx, key, sqlInstance); err != nil { + if apierrors.IsNotFound(err) { + return "", k8s.NewReferenceNotFoundError(krm.SQLInstanceGVK, key) + } + return "", fmt.Errorf("error reading referenced SQLInstance %v: %w", key, err) + } + + sqlInstanceName, err := refs.GetResourceID(sqlInstance) + if err != nil { + return "", err + } + return sqlInstanceName, nil + } else { + return "", fmt.Errorf("must specify either spec.settings.cloneSource.sqlInstanceRef.external or spec.settings.cloneSource.sqlInstanceRef.name") + } +} + func normalizeLabels(obj *krm.SQLInstance) error { if obj.Labels == nil { obj.Labels = make(map[string]string) diff --git a/pkg/controller/direct/sql/sqlinstance_controller.go b/pkg/controller/direct/sql/sqlinstance_controller.go index fac0e53f6f..cec2d937b8 100644 --- a/pkg/controller/direct/sql/sqlinstance_controller.go +++ b/pkg/controller/direct/sql/sqlinstance_controller.go @@ -69,7 +69,7 @@ var _ directbase.Adapter = &sqlInstanceAdapter{} func (m *sqlInstanceModel) AdapterForObject(ctx context.Context, kube client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) { obj := &krm.SQLInstance{} if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil { - return nil, fmt.Errorf("error converting to %T: %w", obj, err) + return nil, fmt.Errorf("converting to %T failed: %w", obj, err) } resourceID, err := refs.GetResourceID(u) @@ -138,14 +138,67 @@ func (a *sqlInstanceAdapter) Create(ctx context.Context, u *unstructured.Unstruc return fmt.Errorf("resourceID is empty") } - desiredGCP, err := SQLInstanceKRMToGCP(a.desired, a.refs) + if a.desired.Spec.CloneSource != nil { + return a.cloneInstance(ctx, u, log) + } else { + return a.insertInstance(ctx, u, log) + } +} + +func (a *sqlInstanceAdapter) cloneInstance(ctx context.Context, u *unstructured.Unstructured, log klog.Logger) error { + desiredGCP, err := SQLInstanceKRMToGCPCloneRequest(a.desired, a.refs) + if err != nil { + return err + } + + op, err := a.sqlInstancesClient.Clone(a.projectID, a.refs.sourceSQLInstance, desiredGCP).Context(ctx).Do() + if err != nil { + return fmt.Errorf("cloning SQLInstance %s failed: %w", a.desired.Name, err) + } + + pollingBackoff := gax.Backoff{ + Initial: time.Second, + Max: time.Minute, + Multiplier: 2, + } + for { + log.V(2).Info("polling", "op", op) + + if op.Status == "DONE" { + break + } + if err := gax.Sleep(ctx, pollingBackoff.Pause()); err != nil { + return fmt.Errorf("waiting for SQLInstance %s clone failed: %w", a.desired.Name, err) + } + op, err = a.sqlOperationsClient.Get(a.projectID, op.Name).Do() + if err != nil { + return fmt.Errorf("getting SQLInstance %s clone operation %s failed: %w", a.desired.Name, op.Name, err) + } + } + + created, err := a.sqlInstancesClient.Get(a.projectID, a.resourceID).Context(ctx).Do() + if err != nil { + return fmt.Errorf("getting SQLInstance %s failed: %w", a.desired.Name, err) + } + + log.V(2).Info("instance cloned", "op", op, "instance", created) + + status := &krm.SQLInstanceStatus{} + if err := Convert_SQLInstance_API_v1_To_KRM_status(created, status); err != nil { + return fmt.Errorf("updating SQLInstance status failed: %w", err) + } + return setStatus(u, status) +} + +func (a *sqlInstanceAdapter) insertInstance(ctx context.Context, u *unstructured.Unstructured, log klog.Logger) error { + desiredGCP, err := SQLInstanceKRMToGCPInstance(a.desired, a.refs) if err != nil { return err } op, err := a.sqlInstancesClient.Insert(a.projectID, desiredGCP).Context(ctx).Do() if err != nil { - return fmt.Errorf("create SQLInstance %s failed: %w", a.desired.Name, err) + return fmt.Errorf("creating SQLInstance %s failed: %w", a.desired.Name, err) } pollingBackoff := gax.Backoff{ @@ -160,22 +213,22 @@ func (a *sqlInstanceAdapter) Create(ctx context.Context, u *unstructured.Unstruc break } if err := gax.Sleep(ctx, pollingBackoff.Pause()); err != nil { - return fmt.Errorf("wait SQLInstance %s creation failed: %w", a.desired.Name, err) + return fmt.Errorf("waiting for SQLInstance %s creation failed: %w", a.desired.Name, err) } op, err = a.sqlOperationsClient.Get(a.projectID, op.Name).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s create operation %s failed: %w", a.desired.Name, op.Name, err) + return fmt.Errorf("getting SQLInstance %s create operation %s failed: %w", a.desired.Name, op.Name, err) } } created, err := a.sqlInstancesClient.Get(a.projectID, a.resourceID).Context(ctx).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s failed: %w", a.desired.Name, err) + return fmt.Errorf("getting SQLInstance %s failed: %w", a.desired.Name, err) } users, err := a.sqlUsersClient.List(a.projectID, a.resourceID).Context(ctx).Do() if err != nil { - return fmt.Errorf("list SQLInstance %s users failed: %w", a.desired.Name, err) + return fmt.Errorf("listing SQLInstance %s users failed: %w", a.desired.Name, err) } if users != nil { @@ -185,7 +238,7 @@ func (a *sqlInstanceAdapter) Create(ctx context.Context, u *unstructured.Unstruc // Ref: https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database_instance op, err := a.sqlUsersClient.Delete(a.projectID, a.resourceID).Context(ctx).Name(user.Name).Do() if err != nil { - return fmt.Errorf("delete SQLInstance %s root user failed: %w", a.desired.Name, err) + return fmt.Errorf("deleting SQLInstance %s root user failed: %w", a.desired.Name, err) } for { log.V(2).Info("polling", "op", op) @@ -194,11 +247,11 @@ func (a *sqlInstanceAdapter) Create(ctx context.Context, u *unstructured.Unstruc break } if err := gax.Sleep(ctx, pollingBackoff.Pause()); err != nil { - return fmt.Errorf("wait SQLInstance %s delete user failed: %w", a.desired.Name, err) + return fmt.Errorf("waiting for SQLInstance %s delete user failed: %w", a.desired.Name, err) } op, err = a.sqlOperationsClient.Get(a.projectID, op.Name).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s delete root user operation %s failed: %w", a.desired.Name, op.Name, err) + return fmt.Errorf("getting SQLInstance %s delete root user operation %s failed: %w", a.desired.Name, op.Name, err) } } } @@ -209,7 +262,7 @@ func (a *sqlInstanceAdapter) Create(ctx context.Context, u *unstructured.Unstruc status := &krm.SQLInstanceStatus{} if err := Convert_SQLInstance_API_v1_To_KRM_status(created, status); err != nil { - return fmt.Errorf("update SQLInstance status failed: %w", err) + return fmt.Errorf("updating SQLInstance status failed: %w", err) } return setStatus(u, status) } @@ -225,7 +278,7 @@ func (a *sqlInstanceAdapter) Update(ctx context.Context, u *unstructured.Unstruc } op, err := a.sqlInstancesClient.Patch(a.projectID, *a.desired.Spec.ResourceID, newVersionDb).Context(ctx).Do() if err != nil { - return fmt.Errorf("patch SQLInstance %s version failed: %w", *a.desired.Spec.ResourceID, err) + return fmt.Errorf("patching SQLInstance %s version failed: %w", *a.desired.Spec.ResourceID, err) } pollingBackoff := gax.Backoff{ @@ -240,17 +293,17 @@ func (a *sqlInstanceAdapter) Update(ctx context.Context, u *unstructured.Unstruc break } if err := gax.Sleep(ctx, pollingBackoff.Pause()); err != nil { - return fmt.Errorf("wait SQLInstance %s version patch failed: %w", *a.desired.Spec.ResourceID, err) + return fmt.Errorf("waiting for SQLInstance %s version patch failed: %w", *a.desired.Spec.ResourceID, err) } op, err = a.sqlOperationsClient.Get(a.projectID, op.Name).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s version patch operation %s failed: %w", *a.desired.Spec.ResourceID, op.Name, err) + return fmt.Errorf("getting SQLInstance %s version patch operation %s failed: %w", *a.desired.Spec.ResourceID, op.Name, err) } } updated, err := a.sqlInstancesClient.Get(a.projectID, a.resourceID).Context(ctx).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s failed: %w", *a.desired.Spec.ResourceID, err) + return fmt.Errorf("getting SQLInstance %s failed: %w", *a.desired.Spec.ResourceID, err) } log.V(2).Info("instance version updated", "op", op, "instance", updated) @@ -261,13 +314,13 @@ func (a *sqlInstanceAdapter) Update(ctx context.Context, u *unstructured.Unstruc // Next, update rest of the fields merged, diffDetected, err := MergeDesiredSQLInstanceWithActual(a.desired, a.refs, a.actual) if err != nil { - return fmt.Errorf("diff SQLInstances failed: %w", err) + return fmt.Errorf("diffing SQLInstances failed: %w", err) } if diffDetected { op, err := a.sqlInstancesClient.Update(a.projectID, merged.Name, merged).Context(ctx).Do() if err != nil { - return fmt.Errorf("update SQLInstance %s failed: %w", merged.Name, err) + return fmt.Errorf("updating SQLInstance %s failed: %w", merged.Name, err) } pollingBackoff := gax.Backoff{ @@ -282,24 +335,24 @@ func (a *sqlInstanceAdapter) Update(ctx context.Context, u *unstructured.Unstruc break } if err := gax.Sleep(ctx, pollingBackoff.Pause()); err != nil { - return fmt.Errorf("wait SQLInstance %s update failed: %w", merged.Name, err) + return fmt.Errorf("waiting for SQLInstance %s update failed: %w", merged.Name, err) } op, err = a.sqlOperationsClient.Get(a.projectID, op.Name).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s update operation %s failed: %w", merged.Name, op.Name, err) + return fmt.Errorf("getting SQLInstance %s update operation %s failed: %w", merged.Name, op.Name, err) } } updated, err := a.sqlInstancesClient.Get(a.projectID, a.resourceID).Context(ctx).Do() if err != nil { - return fmt.Errorf("get SQLInstance %s failed: %w", merged.Name, err) + return fmt.Errorf("getting SQLInstance %s failed: %w", merged.Name, err) } log.V(2).Info("instance updated", "op", op, "instance", updated) status := &krm.SQLInstanceStatus{} if err := Convert_SQLInstance_API_v1_To_KRM_status(updated, status); err != nil { - return fmt.Errorf("update SQLInstance status failed: %w", err) + return fmt.Errorf("updating SQLInstance status failed: %w", err) } return setStatus(u, status) } @@ -317,7 +370,7 @@ func (a *sqlInstanceAdapter) Delete(ctx context.Context) (bool, error) { } op, err := a.sqlInstancesClient.Delete(a.projectID, a.resourceID).Context(ctx).Do() if err != nil { - return false, fmt.Errorf("deleting SQLInstance %s: %w", a.resourceID, err) + return false, fmt.Errorf("deleting SQLInstance %s failed: %w", a.resourceID, err) } log.V(2).Info("deleted SQLInstance", "op", op) @@ -332,12 +385,12 @@ func (a *sqlInstanceAdapter) Export(ctx context.Context) (*unstructured.Unstruct sqlInstance, err := SQLInstanceGCPToKRM(a.actual) if err != nil { - return nil, fmt.Errorf("error converting SQLInstance from API %w", err) + return nil, fmt.Errorf("converting SQLInstance from API failed: %w", err) } sqlInstanceObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(sqlInstance) if err != nil { - return nil, fmt.Errorf("error converting SQLInstance spec to unstructured: %w", err) + return nil, fmt.Errorf("converting SQLInstance spec to unstructured failed: %w", err) } u := &unstructured.Unstructured{ @@ -352,7 +405,7 @@ func (a *sqlInstanceAdapter) Export(ctx context.Context) (*unstructured.Unstruct func setStatus(u *unstructured.Unstructured, typedStatus any) error { status, err := runtime.DefaultUnstructuredConverter.ToUnstructured(typedStatus) if err != nil { - return fmt.Errorf("error converting status to unstructured: %w", err) + return fmt.Errorf("converting status to unstructured failed: %w", err) } old, _, _ := unstructured.NestedMap(u.Object, "status") diff --git a/pkg/controller/direct/sql/utils.go b/pkg/controller/direct/sql/utils.go index 7120b1d516..33c85472ea 100644 --- a/pkg/controller/direct/sql/utils.go +++ b/pkg/controller/direct/sql/utils.go @@ -21,3 +21,11 @@ func ValueOf[T any](p *T) T { } return v } + +func LazyPtr[T comparable](v T) *T { + var defaultValue T + if v == defaultValue { + return nil + } + return &v +} diff --git a/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_generated_object_sqlinstanceclonebasic.golden.yaml b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_generated_object_sqlinstanceclonebasic.golden.yaml new file mode 100644 index 0000000000..9a1aabbe57 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_generated_object_sqlinstanceclonebasic.golden.yaml @@ -0,0 +1,50 @@ +apiVersion: sql.cnrm.cloud.google.com/v1beta1 +kind: SQLInstance +metadata: + annotations: + cnrm.cloud.google.com/management-conflict-prevention-policy: none + cnrm.cloud.google.com/project-id: ${projectId} + finalizers: + - cnrm.cloud.google.com/finalizer + - cnrm.cloud.google.com/deletion-defender + generation: 1 + labels: + cnrm-test: "true" + name: postgres-clone-${uniqueId} + namespace: ${uniqueId} +spec: + cloneSource: + sqlInstanceRef: + name: postgres-source-${uniqueId} + databaseVersion: POSTGRES_16 + region: us-central1 + settings: + backupConfiguration: + enabled: true + pointInTimeRecoveryEnabled: true + locationPreference: + zone: us-central1-a + tier: db-custom-1-3840 +status: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: The resource is up to date + reason: UpToDate + status: "True" + type: Ready + connectionName: ${projectId}:us-central1:postgres-clone-${uniqueId} + firstIpAddress: 10.1.2.3 + instanceType: CLOUD_SQL_INSTANCE + ipAddress: 10.1.2.3 + observedGeneration: 1 + publicIpAddress: 10.1.2.3 + selfLink: https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId} + serverCaCert: + cert: | + -----BEGIN CERTIFICATE----- + -----END CERTIFICATE----- + commonName: common-name + createTime: "1970-01-01T00:00:00Z" + expirationTime: "1970-01-01T00:00:00Z" + sha1Fingerprint: "12345678" + serviceAccountEmailAddress: p${projectNumber}-abcdef@gcp-sa-cloud-sql.iam.gserviceaccount.com diff --git a/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_http.log b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_http.log new file mode 100644 index 0000000000..51bb26f692 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/_http.log @@ -0,0 +1,620 @@ +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +404 Not Found +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "error": { + "code": 404, + "errors": [ + { + "domain": "global", + "message": "The Cloud SQL instance does not exist.", + "reason": "instanceDoesNotExist" + } + ], + "message": "The Cloud SQL instance does not exist." + } +} + +--- + +POST https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances?alt=json&prettyPrint=false +Content-Type: application/json +User-Agent: kcc/controller-manager + +{ + "databaseVersion": "POSTGRES_16", + "name": "postgres-source-${uniqueId}", + "region": "us-central1", + "settings": { + "activationPolicy": "ALWAYS", + "availabilityType": "ZONAL", + "backupConfiguration": { + "enabled": true, + "pointInTimeRecoveryEnabled": true + }, + "dataDiskType": "PD_SSD", + "edition": "ENTERPRISE", + "locationPreference": { + "zone": "us-central1-a" + }, + "pricingPlan": "PER_USE", + "storageAutoResize": true, + "tier": "db-custom-1-3840", + "userLabels": { + "cnrm-test": "true", + "managed-by-cnrm": "true" + } + } +} + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "CREATE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "PENDING", + "targetId": "postgres-source-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "endTime": "2024-04-01T12:34:56.123456Z", + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "CREATE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "DONE", + "targetId": "postgres-source-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "backendType": "SECOND_GEN", + "connectionName": "${projectId}:us-central1:postgres-source-${uniqueId}", + "createTime": "2024-04-01T12:34:56.123456Z", + "databaseInstalledVersion": "POSTGRES_16_3", + "databaseVersion": "POSTGRES_16", + "etag": "abcdef0123A=", + "gceZone": "us-central1-a", + "geminiConfig": { + "activeQueryEnabled": false, + "entitled": false, + "googleVacuumMgmtEnabled": false, + "indexAdvisorEnabled": false, + "oomSessionCancelEnabled": false + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "ipAddresses": [ + { + "ipAddress": "10.1.2.3", + "type": "PRIMARY" + }, + { + "ipAddress": "10.1.2.3", + "type": "OUTGOING" + } + ], + "kind": "sql#instance", + "maintenanceVersion": "POSTGRES_16_3.R20240527.01_10", + "name": "postgres-source-${uniqueId}", + "project": "${projectId}", + "region": "us-central1", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}", + "serverCaCert": { + "cert": "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", + "certSerialNumber": "0", + "commonName": "common-name", + "createTime": "2024-04-01T12:34:56.123456Z", + "expirationTime": "2024-04-01T12:34:56.123456Z", + "instance": "postgres-source-${uniqueId}", + "kind": "sql#sslCert", + "sha1Fingerprint": "12345678" + }, + "serviceAccountEmailAddress": "p${projectNumber}-abcdef@gcp-sa-cloud-sql.iam.gserviceaccount.com", + "settings": { + "activationPolicy": "ALWAYS", + "authorizedGaeApplications": [], + "availabilityType": "ZONAL", + "backupConfiguration": { + "backupRetentionSettings": { + "retainedBackups": 7, + "retentionUnit": "COUNT" + }, + "enabled": true, + "kind": "sql#backupConfiguration", + "pointInTimeRecoveryEnabled": true, + "replicationLogArchivingEnabled": false, + "startTime": "12:00", + "transactionLogRetentionDays": 7, + "transactionalLogStorageState": "TRANSACTIONAL_LOG_STORAGE_STATE_UNSPECIFIED" + }, + "connectorEnforcement": "NOT_REQUIRED", + "dataDiskSizeGb": "10", + "dataDiskType": "PD_SSD", + "deletionProtectionEnabled": false, + "edition": "ENTERPRISE", + "ipConfiguration": { + "authorizedNetworks": [], + "ipv4Enabled": true, + "requireSsl": false, + "sslMode": "ALLOW_UNENCRYPTED_AND_ENCRYPTED" + }, + "kind": "sql#settings", + "locationPreference": { + "kind": "sql#locationPreference", + "zone": "us-central1-a" + }, + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "settingsVersion": "123", + "storageAutoResize": true, + "storageAutoResizeLimit": "0", + "tier": "db-custom-1-3840", + "userLabels": { + "cnrm-test": "true", + "managed-by-cnrm": "true" + } + }, + "sqlNetworkArchitecture": "NEW_NETWORK_ARCHITECTURE", + "state": "RUNNABLE" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}/users?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "items": [ + { + "etag": "abcdef0123A=", + "host": "", + "instance": "postgres-source-${uniqueId}", + "kind": "sql#user", + "name": "postgres", + "project": "${projectId}" + } + ], + "kind": "sql#usersList" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +404 Not Found +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "error": { + "code": 404, + "errors": [ + { + "domain": "global", + "message": "The Cloud SQL instance does not exist.", + "reason": "instanceDoesNotExist" + } + ], + "message": "The Cloud SQL instance does not exist." + } +} + +--- + +POST https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}/clone?alt=json&prettyPrint=false +Content-Type: application/json +User-Agent: kcc/controller-manager + +{ + "cloneContext": { + "destinationInstanceName": "postgres-clone-${uniqueId}", + "kind": "sql#cloneContext", + "preferredZone": "us-central1-a" + } +} + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "CLONE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "PENDING", + "targetId": "postgres-clone-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "endTime": "2024-04-01T12:34:56.123456Z", + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "CLONE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "DONE", + "targetId": "postgres-clone-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "backendType": "SECOND_GEN", + "connectionName": "${projectId}:us-central1:postgres-clone-${uniqueId}", + "createTime": "2024-04-01T12:34:56.123456Z", + "databaseInstalledVersion": "POSTGRES_16_3", + "databaseVersion": "POSTGRES_16", + "etag": "abcdef0123A=", + "gceZone": "us-central1-a", + "geminiConfig": { + "activeQueryEnabled": false, + "entitled": false, + "googleVacuumMgmtEnabled": false, + "indexAdvisorEnabled": false, + "oomSessionCancelEnabled": false + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "ipAddresses": [ + { + "ipAddress": "10.1.2.3", + "type": "PRIMARY" + }, + { + "ipAddress": "10.1.2.3", + "type": "OUTGOING" + } + ], + "kind": "sql#instance", + "maintenanceVersion": "POSTGRES_16_3.R20240527.01_10", + "name": "postgres-clone-${uniqueId}", + "project": "${projectId}", + "region": "us-central1", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}", + "serverCaCert": { + "cert": "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", + "certSerialNumber": "0", + "commonName": "common-name", + "createTime": "2024-04-01T12:34:56.123456Z", + "expirationTime": "2024-04-01T12:34:56.123456Z", + "instance": "postgres-clone-${uniqueId}", + "kind": "sql#sslCert", + "sha1Fingerprint": "12345678" + }, + "serviceAccountEmailAddress": "p${projectNumber}-abcdef@gcp-sa-cloud-sql.iam.gserviceaccount.com", + "settings": { + "activationPolicy": "ALWAYS", + "authorizedGaeApplications": [], + "availabilityType": "ZONAL", + "backupConfiguration": { + "backupRetentionSettings": { + "retainedBackups": 7, + "retentionUnit": "COUNT" + }, + "enabled": true, + "kind": "sql#backupConfiguration", + "pointInTimeRecoveryEnabled": true, + "replicationLogArchivingEnabled": false, + "startTime": "12:00", + "transactionLogRetentionDays": 7, + "transactionalLogStorageState": "TRANSACTIONAL_LOG_STORAGE_STATE_UNSPECIFIED" + }, + "connectorEnforcement": "NOT_REQUIRED", + "dataDiskSizeGb": "10", + "dataDiskType": "PD_SSD", + "deletionProtectionEnabled": false, + "edition": "ENTERPRISE", + "ipConfiguration": { + "authorizedNetworks": [], + "ipv4Enabled": true, + "requireSsl": false, + "sslMode": "ALLOW_UNENCRYPTED_AND_ENCRYPTED" + }, + "kind": "sql#settings", + "locationPreference": { + "kind": "sql#locationPreference", + "zone": "us-central1-a" + }, + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "settingsVersion": "123", + "storageAutoResize": true, + "storageAutoResizeLimit": "0", + "tier": "db-custom-1-3840", + "userLabels": { + "cnrm-test": "true", + "managed-by-cnrm": "true" + } + }, + "sqlNetworkArchitecture": "NEW_NETWORK_ARCHITECTURE", + "state": "RUNNABLE" +} + +--- + +DELETE https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "DELETE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "PENDING", + "targetId": "postgres-clone-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-clone-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} + +--- + +GET https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "backendType": "SECOND_GEN", + "connectionName": "${projectId}:us-central1:postgres-source-${uniqueId}", + "createTime": "2024-04-01T12:34:56.123456Z", + "databaseInstalledVersion": "POSTGRES_16_3", + "databaseVersion": "POSTGRES_16", + "etag": "abcdef0123A=", + "gceZone": "us-central1-a", + "geminiConfig": { + "activeQueryEnabled": false, + "entitled": false, + "googleVacuumMgmtEnabled": false, + "indexAdvisorEnabled": false, + "oomSessionCancelEnabled": false + }, + "instanceType": "CLOUD_SQL_INSTANCE", + "ipAddresses": [ + { + "ipAddress": "10.1.2.3", + "type": "PRIMARY" + }, + { + "ipAddress": "10.1.2.3", + "type": "OUTGOING" + } + ], + "kind": "sql#instance", + "maintenanceVersion": "POSTGRES_16_3.R20240527.01_10", + "name": "postgres-source-${uniqueId}", + "project": "${projectId}", + "region": "us-central1", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}", + "serverCaCert": { + "cert": "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n", + "certSerialNumber": "0", + "commonName": "common-name", + "createTime": "2024-04-01T12:34:56.123456Z", + "expirationTime": "2024-04-01T12:34:56.123456Z", + "instance": "postgres-source-${uniqueId}", + "kind": "sql#sslCert", + "sha1Fingerprint": "12345678" + }, + "serviceAccountEmailAddress": "p${projectNumber}-abcdef@gcp-sa-cloud-sql.iam.gserviceaccount.com", + "settings": { + "activationPolicy": "ALWAYS", + "authorizedGaeApplications": [], + "availabilityType": "ZONAL", + "backupConfiguration": { + "backupRetentionSettings": { + "retainedBackups": 7, + "retentionUnit": "COUNT" + }, + "enabled": true, + "kind": "sql#backupConfiguration", + "pointInTimeRecoveryEnabled": true, + "replicationLogArchivingEnabled": false, + "startTime": "12:00", + "transactionLogRetentionDays": 7, + "transactionalLogStorageState": "TRANSACTIONAL_LOG_STORAGE_STATE_UNSPECIFIED" + }, + "connectorEnforcement": "NOT_REQUIRED", + "dataDiskSizeGb": "10", + "dataDiskType": "PD_SSD", + "deletionProtectionEnabled": false, + "edition": "ENTERPRISE", + "ipConfiguration": { + "authorizedNetworks": [], + "ipv4Enabled": true, + "requireSsl": false, + "sslMode": "ALLOW_UNENCRYPTED_AND_ENCRYPTED" + }, + "kind": "sql#settings", + "locationPreference": { + "kind": "sql#locationPreference", + "zone": "us-central1-a" + }, + "pricingPlan": "PER_USE", + "replicationType": "SYNCHRONOUS", + "settingsVersion": "123", + "storageAutoResize": true, + "storageAutoResizeLimit": "0", + "tier": "db-custom-1-3840", + "userLabels": { + "cnrm-test": "true", + "managed-by-cnrm": "true" + } + }, + "sqlNetworkArchitecture": "NEW_NETWORK_ARCHITECTURE", + "state": "RUNNABLE" +} + +--- + +DELETE https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}?alt=json&prettyPrint=false +User-Agent: kcc/controller-manager + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "insertTime": "2024-04-01T12:34:56.123456Z", + "kind": "sql#operation", + "name": "${operationID}", + "operationType": "DELETE", + "selfLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/operations/${operationID}", + "status": "PENDING", + "targetId": "postgres-source-${uniqueId}", + "targetLink": "https://sqladmin.googleapis.com/sql/v1beta4/projects/${projectId}/instances/postgres-source-${uniqueId}", + "targetProject": "${projectId}", + "user": "user@example.com" +} \ No newline at end of file diff --git a/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/create.yaml b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/create.yaml new file mode 100644 index 0000000000..70cd9e03cd --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/create.yaml @@ -0,0 +1,35 @@ +# 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. + +apiVersion: sql.cnrm.cloud.google.com/v1beta1 +kind: SQLInstance +metadata: + name: postgres-clone-${uniqueId} +spec: + cloneSource: + sqlInstanceRef: + name: postgres-source-${uniqueId} + databaseVersion: POSTGRES_16 + region: us-central1 + settings: + backupConfiguration: + enabled: true + pointInTimeRecoveryEnabled: true + # Location preference is not actually a required field. However, setting it for tests + # helps with with normalizing the GCP responses, because otherwise GCP chooses a zone + # preference based on availability. Therefore it could potentially vary if not + # explicity specified. + locationPreference: + zone: us-central1-a + tier: db-custom-1-3840 diff --git a/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/dependencies.yaml b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/dependencies.yaml new file mode 100644 index 0000000000..262b8ba1ac --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/sql/v1beta1/sqlinstance/sqlinstanceclonebasic/dependencies.yaml @@ -0,0 +1,32 @@ +# 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. + +apiVersion: sql.cnrm.cloud.google.com/v1beta1 +kind: SQLInstance +metadata: + name: postgres-source-${uniqueId} +spec: + databaseVersion: POSTGRES_16 + region: us-central1 + settings: + backupConfiguration: + enabled: true + pointInTimeRecoveryEnabled: true + # Location preference is not actually a required field. However, setting it for tests + # helps with with normalizing the GCP responses, because otherwise GCP chooses a zone + # preference based on availability. Therefore it could potentially vary if not + # explicity specified. + locationPreference: + zone: us-central1-a + tier: db-custom-1-3840 diff --git a/scripts/generate-google3-docs/resource-reference/generated/resource-docs/sql/sqlinstance.md b/scripts/generate-google3-docs/resource-reference/generated/resource-docs/sql/sqlinstance.md index 2ea411a0b9..6776cee359 100644 --- a/scripts/generate-google3-docs/resource-reference/generated/resource-docs/sql/sqlinstance.md +++ b/scripts/generate-google3-docs/resource-reference/generated/resource-docs/sql/sqlinstance.md @@ -83,6 +83,17 @@ documentation. ### Spec #### Schema ```yaml +cloneSource: + binLogCoordinates: + binLogFileName: string + binLogPosition: integer + databaseNames: + - string + pointInTime: string + sqlInstanceRef: + external: string + name: string + namespace: string databaseVersion: string encryptionKMSCryptoKeyRef: external: string @@ -215,6 +226,116 @@ settings: + + +

cloneSource

+

Optional

+ + +

object

+

{% verbatim %}Create this database as a clone of a source instance. Immutable.{% endverbatim %}

+ + + + +

cloneSource.binLogCoordinates

+

Optional

+ + +

object

+

{% verbatim %}Binary log coordinates, if specified, identify the position up to which the source instance is cloned. If not specified, the source instance is cloned up to the most recent binary log coordinates.{% endverbatim %}

+ + + + +

cloneSource.binLogCoordinates.binLogFileName

+

Optional

+ + +

string

+

{% verbatim %}Name of the binary log file for a Cloud SQL instance.{% endverbatim %}

+ + + + +

cloneSource.binLogCoordinates.binLogPosition

+

Optional

+ + +

integer

+

{% verbatim %}Position (offset) within the binary log file.{% endverbatim %}

+ + + + +

cloneSource.databaseNames

+

Optional

+ + +

list (string)

+

{% verbatim %}(SQL Server only) Clone only the specified databases from the source instance. Clone all databases if empty.{% endverbatim %}

+ + + + +

cloneSource.databaseNames[]

+

Optional

+ + +

string

+

{% verbatim %}{% endverbatim %}

+ + + + +

cloneSource.pointInTime

+

Optional

+ + +

string

+

{% verbatim %}Timestamp, if specified, identifies the time to which the source instance is cloned.{% endverbatim %}

+ + + + +

cloneSource.sqlInstanceRef

+

Optional

+ + +

object

+

{% verbatim %}The source SQLInstance to clone{% endverbatim %}

+ + + + +

cloneSource.sqlInstanceRef.external

+

Optional

+ + +

string

+

{% verbatim %}The SQLInstance selfLink, when not managed by Config Connector.{% endverbatim %}

+ + + + +

cloneSource.sqlInstanceRef.name

+

Optional

+ + +

string

+

{% verbatim %}The `name` field of a `SQLInstance` resource.{% endverbatim %}

+ + + + +

cloneSource.sqlInstanceRef.namespace

+

Optional

+ + +

string

+

{% verbatim %}The `namespace` field of a `SQLInstance` resource.{% endverbatim %}

+ +

databaseVersion