From cedfaa097e2c23e8f404d95f4d40b6c404ab1552 Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:17:06 +0000 Subject: [PATCH 1/6] SpeechPhraseSet: add controller --- .../direct/speech/phraseset_controller.go | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 pkg/controller/direct/speech/phraseset_controller.go diff --git a/pkg/controller/direct/speech/phraseset_controller.go b/pkg/controller/direct/speech/phraseset_controller.go new file mode 100644 index 00000000000..47d6f526374 --- /dev/null +++ b/pkg/controller/direct/speech/phraseset_controller.go @@ -0,0 +1,261 @@ +// 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. + +// +tool:controller +// proto.service: google.cloud.speech.v2.Speech +// proto.message: google.cloud.speech.v2.PhraseSet +// crd.type: SpeechPhraseSet +// crd.version: v1alpha1 + +package speech + +import ( + "context" + "fmt" + "reflect" + + gcp "cloud.google.com/go/speech/apiv2" + pb "cloud.google.com/go/speech/apiv2/speechpb" + "google.golang.org/protobuf/types/known/fieldmaskpb" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1" + krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/speech/v1alpha1" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/config" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/directbase" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/registry" +) + +func init() { + registry.RegisterModel(krm.SpeechPhraseSetGVK, NewPhraseSetModel) +} + +func NewPhraseSetModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) { + return &phraseSetModel{config: *config}, nil +} + +var _ directbase.Model = &phraseSetModel{} + +type phraseSetModel struct { + config config.ControllerConfig +} + +func (m *phraseSetModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) { + obj := &krm.SpeechPhraseSet{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil { + return nil, fmt.Errorf("error converting to %T: %w", obj, err) + } + + id, err := krm.NewPhraseSetIdentity(ctx, reader, obj) + if err != nil { + return nil, err + } + + gcpClient, err := newGCPClient(ctx, &m.config) + if err != nil { + return nil, err + } + client, err := gcpClient.newSpeechClient(ctx) + if err != nil { + return nil, err + } + + return &phraseSetAdapter{ + gcpClient: client, + id: id, + desired: obj, + reader: reader, + }, nil +} + +func (m *phraseSetModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) { + // TODO: Support URLs + return nil, nil +} + +type phraseSetAdapter struct { + gcpClient *gcp.Client + id *krm.PhraseSetIdentity + desired *krm.SpeechPhraseSet + actual *pb.PhraseSet + reader client.Reader +} + +var _ directbase.Adapter = &phraseSetAdapter{} + +// Find retrieves the GCP resource. +// Return true means the object is found. This triggers Adapter `Update` call. +// Return false means the object is not found. This triggers Adapter `Create` call. +// Return a non-nil error requeues the requests. +func (a *phraseSetAdapter) Find(ctx context.Context) (bool, error) { + log := klog.FromContext(ctx) + log.V(2).Info("getting speech phraseset", "name", a.id) + + req := &pb.GetPhraseSetRequest{Name: a.id.String()} + actual, err := a.gcpClient.GetPhraseSet(ctx, req) + if err != nil { + if direct.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting speech phraseset %q: %w", a.id, err) + } + + a.actual = actual + return true, nil +} + +// Create creates the resource in GCP based on `spec` and update the Config Connector object `status` based on the GCP response. +func (a *phraseSetAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error { + log := klog.FromContext(ctx) + log.V(2).Info("creating speech phraseset", "name", a.id) + mapCtx := &direct.MapContext{} + + resource := SpeechPhraseSetSpec_ToProto(mapCtx, &a.desired.Spec) + if mapCtx.Err() != nil { + return mapCtx.Err() + } + + req := &pb.CreatePhraseSetRequest{ + Parent: a.id.Parent().String(), + PhraseSetId: a.id.ID(), + PhraseSet: resource, + } + op, err := a.gcpClient.CreatePhraseSet(ctx, req) + if err != nil { + return fmt.Errorf("creating speech phraseset %s: %w", a.id, err) + } + created, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("speech phraseset %s waiting creation: %w", a.id, err) + } + log.V(2).Info("successfully created speech phraseset", "name", a.id) + + status := &krm.SpeechPhraseSetStatus{} + status.ObservedState = SpeechPhraseSetObservedState_FromProto(mapCtx, created) + if mapCtx.Err() != nil { + return mapCtx.Err() + } + status.ExternalRef = direct.LazyPtr(a.id.String()) + return createOp.UpdateStatus(ctx, status, nil) +} + +// Update updates the resource in GCP based on `spec` and update the Config Connector object `status` based on the GCP response. +func (a *phraseSetAdapter) Update(ctx context.Context, updateOp *directbase.UpdateOperation) error { + log := klog.FromContext(ctx) + log.V(2).Info("updating speech phraseset", "name", a.id) + mapCtx := &direct.MapContext{} + + resource := SpeechPhraseSetSpec_ToProto(mapCtx, &a.desired.Spec) + if mapCtx.Err() != nil { + return mapCtx.Err() + } + + paths := []string{} + if !reflect.DeepEqual(resource.Phrases, a.actual.Phrases) { + paths = append(paths, "phrases") + } + if !reflect.DeepEqual(resource.Boost, a.actual.Boost) { + paths = append(paths, "boost") + } + if !reflect.DeepEqual(resource.DisplayName, a.actual.DisplayName) { + paths = append(paths, "display_name") + } + if !reflect.DeepEqual(resource.Annotations, a.actual.Annotations) { + paths = append(paths, "annotations") + } + + var updated *pb.PhraseSet + if len(paths) == 0 { + log.V(2).Info("no field needs update", "name", a.id) + updated = a.actual + } else { + resource.Name = a.id.String() // we need to set the name so that GCP API can identify the resource + req := &pb.UpdatePhraseSetRequest{ + PhraseSet: resource, + UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}, + } + op, err := a.gcpClient.UpdatePhraseSet(ctx, req) + if err != nil { + return fmt.Errorf("updating speech phraseset %s: %w", a.id.String(), err) + } + updated, err = op.Wait(ctx) + if err != nil { + return fmt.Errorf("speech phraseset %s waiting update: %w", a.id, err) + } + log.V(2).Info("successfully updated speech phraseset", "name", a.id) + } + + status := &krm.SpeechPhraseSetStatus{} + status.ObservedState = SpeechPhraseSetObservedState_FromProto(mapCtx, updated) + if mapCtx.Err() != nil { + return mapCtx.Err() + } + status.ExternalRef = direct.LazyPtr(a.id.String()) + return updateOp.UpdateStatus(ctx, status, nil) +} + +// Export maps the GCP object to a Config Connector resource `spec`. +func (a *phraseSetAdapter) Export(ctx context.Context) (*unstructured.Unstructured, error) { + if a.actual == nil { + return nil, fmt.Errorf("Find() not called") + } + u := &unstructured.Unstructured{} + + obj := &krm.SpeechPhraseSet{} + mapCtx := &direct.MapContext{} + obj.Spec = direct.ValueOf(SpeechPhraseSetSpec_FromProto(mapCtx, a.actual)) + if mapCtx.Err() != nil { + return nil, mapCtx.Err() + } + obj.Spec.ProjectRef = &refs.ProjectRef{External: a.id.Parent().ProjectID} + obj.Spec.Location = a.id.Parent().Location + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + + u.SetName(a.id.ID()) + u.SetGroupVersionKind(krm.SpeechPhraseSetGVK) + u.Object = uObj + return u, nil +} + +// Delete the resource from GCP service when the corresponding Config Connector resource is deleted. +func (a *phraseSetAdapter) Delete(ctx context.Context, deleteOp *directbase.DeleteOperation) (bool, error) { + log := klog.FromContext(ctx) + log.V(2).Info("deleting speech phraseset", "name", a.id) + + req := &pb.DeletePhraseSetRequest{Name: a.id.String()} + op, err := a.gcpClient.DeletePhraseSet(ctx, req) + if err != nil { + if direct.IsNotFound(err) { + // Return success if not found (assume it was already deleted). + log.V(2).Info("skipping delete for non-existent speech phraseset, assuming it was already deleted", "name", a.id) + return true, nil + } + return false, fmt.Errorf("deleting speech phraseset %s: %w", a.id, err) + } + log.V(2).Info("successfully initiated deletion speech phraseset", "name", a.id) + + _, err = op.Wait(ctx) + if err != nil { + return false, fmt.Errorf("waiting delete speech phraseset %s: %w", a.id, err) + } + log.V(2).Info("successfully deleted speech phraseset", "name", a.id) + return true, nil +} From b55ff216574d841e6d37e143c87bb35bb65e000e Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:17:58 +0000 Subject: [PATCH 2/6] SpeechPhraseSet: add basic test --- .../speechphraseset-minimal/create.yaml | 26 +++++++++++++++++++ .../speechphraseset-minimal/update.yaml | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/create.yaml create mode 100644 pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/update.yaml diff --git a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/create.yaml b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/create.yaml new file mode 100644 index 00000000000..ea3996c8821 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/create.yaml @@ -0,0 +1,26 @@ +# Copyright 2025 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: speech.cnrm.cloud.google.com/v1alpha1 +kind: SpeechPhraseSet +metadata: + name: speechphraseset-minimal-${uniqueId} +spec: + projectRef: + external: ${projectId} + location: global # Expected resource location to be global + displayName: "Initial description" + phrases: + - value: "initial phrase" + boost: "1.0" diff --git a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/update.yaml b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/update.yaml new file mode 100644 index 00000000000..9f2454db9f4 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/update.yaml @@ -0,0 +1,26 @@ +# Copyright 2025 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: speech.cnrm.cloud.google.com/v1alpha1 +kind: SpeechPhraseSet +metadata: + name: speechphraseset-minimal-${uniqueId} +spec: + projectRef: + external: ${projectId} + location: global # Expected resource location to be global + displayName: "Updated description" + phrases: + - value: "updated phrase" + boost: "2.0" \ No newline at end of file From 3833a108d381a76ddee71eed1afcb2752d722604 Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:18:30 +0000 Subject: [PATCH 3/6] SpeechPhraseSet: enable mockgcp testing --- config/tests/samples/create/harness.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/tests/samples/create/harness.go b/config/tests/samples/create/harness.go index 26a1c4403b9..cf236f1fc30 100644 --- a/config/tests/samples/create/harness.go +++ b/config/tests/samples/create/harness.go @@ -1013,6 +1013,7 @@ func MaybeSkip(t *testing.T, name string, resources []*unstructured.Unstructured case schema.GroupKind{Group: "recaptchaenterprise.cnrm.cloud.google.com", Kind: "ReCAPTCHAEnterpriseFirewallPolicy"}: case schema.GroupKind{Group: "speech.cnrm.cloud.google.com", Kind: "SpeechCustomClass"}: + case schema.GroupKind{Group: "speech.cnrm.cloud.google.com", Kind: "SpeechPhraseSet"}: default: t.Skipf("gk %v not suppported by mock gcp %v; skipping", gvk.GroupKind(), name) From 3cfde3984d1ba34789de225c7d061df7d3baa5d6 Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:18:53 +0000 Subject: [PATCH 4/6] SpeechPhraseSet: record real GCP --- ...object_speechphraseset-minimal.golden.yaml | 34 ++ .../speechphraseset-minimal/_http.log | 291 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_generated_object_speechphraseset-minimal.golden.yaml create mode 100644 pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log diff --git a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_generated_object_speechphraseset-minimal.golden.yaml b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_generated_object_speechphraseset-minimal.golden.yaml new file mode 100644 index 00000000000..6d42ba915bb --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_generated_object_speechphraseset-minimal.golden.yaml @@ -0,0 +1,34 @@ +apiVersion: speech.cnrm.cloud.google.com/v1alpha1 +kind: SpeechPhraseSet +metadata: + finalizers: + - cnrm.cloud.google.com/finalizer + - cnrm.cloud.google.com/deletion-defender + generation: 2 + labels: + cnrm-test: "true" + name: speechphraseset-minimal-${uniqueId} + namespace: ${uniqueId} +spec: + displayName: Updated description + location: global + phrases: + - boost: "2.0" + value: updated phrase + projectRef: + external: ${projectId} +status: + conditions: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: The resource is up to date + reason: UpToDate + status: "True" + type: Ready + externalRef: projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId} + observedGeneration: 2 + observedState: + createTime: "1970-01-01T00:00:00Z" + etag: abcdef123456 + state: ACTIVE + uid: 0123456789abcdef + updateTime: "1970-01-01T00:00:00Z" diff --git a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log new file mode 100644 index 00000000000..03feea6919c --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log @@ -0,0 +1,291 @@ +GET https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}?%24alt=json%3Benum-encoding%3Dint +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2FphraseSets%2Fspeechphraseset-minimal-${uniqueId} + +404 Not Found +Content-Type: application/json; charset=UTF-8 +Grpc-Status-Details-Bin: CAUSW1VuYWJsZSB0byBmaW5kIFBocmFzZVNldCBzcGVlY2hwaHJhc2VzZXQtbWluaW1hbC16ZHVqeTV0dmFubXJ0eGkgZnJvbSBwcm9qZWN0IDYwMDEzOTU2NjExNi4a+QEKJHR5cGUuZ29vZ2xlYXBpcy5jb20vdXRpbC5TdGF0dXNQcm90bxLQAQgFEgdnZW5lcmljGltVbmFibGUgdG8gZmluZCBQaHJhc2VTZXQgc3BlZWNocGhyYXNlc2V0LW1pbmltYWwtemR1ank1dHZhbm1ydHhpIGZyb20gcHJvamVjdCA2MDAxMzk1NjYxMTYuKmYLEIbWjScaXRJbVW5hYmxlIHRvIGZpbmQgUGhyYXNlU2V0IHNwZWVjaHBocmFzZXNldC1taW5pbWFsLXpkdWp5NXR2YW5tcnR4aSBmcm9tIHByb2plY3QgNjAwMTM5NTY2MTE2Lgw= +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DVKA8j0VmpmJQA== +X-Goog-Ext-9650773-Bin: DUB0PUE= +X-Xss-Protection: 0 + +{ + "error": { + "code": 404, + "message": "Unable to find PhraseSet speechphraseset-minimal-${uniqueId} from project ${projectNumber}.", + "status": "NOT_FOUND" + } +} + +--- + +POST https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets?%24alt=json%3Benum-encoding%3Dint&phraseSetId=speechphraseset-minimal-${uniqueId} +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: parent=projects%2F${projectId}%2Flocations%2Fglobal + +{ + "displayName": "Initial description", + "phrases": [ + { + "boost": 1, + "value": "initial phrase" + } + ] +} + +200 OK +Content-Type: application/json; charset=UTF-8 +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DTXM8z0VAABgQA== +X-Goog-Ext-9650773-Bin: DYl3PkE= +X-Xss-Protection: 0 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.speech.v2.OperationMetadata", + "createPhraseSetRequest": { + "parent": "projects/${projectNumber}/locations/global", + "phraseSet": { + "displayName": "Initial description", + "phrases": [ + { + "boost": 1, + "value": "initial phrase" + } + ] + }, + "phraseSetId": "speechphraseset-minimal-${uniqueId}" + }, + "createTime": "2024-04-01T12:34:56.123456Z", + "method": "google.cloud.speech.v2.Speech.CreatePhraseSet", + "progressPercent": 100, + "resource": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + }, + "name": "projects/${projectNumber}/locations/global/operations/${operationID}", + "response": { + "@type": "type.googleapis.com/google.cloud.speech.v2.PhraseSet", + "createTime": "2024-04-01T12:34:56.123456Z", + "displayName": "Initial description", + "etag": "abcdef0123A=", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 1, + "value": "initial phrase" + } + ], + "state": 2, + "uid": "111111111111111111111", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} + +--- + +GET https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}?%24alt=json%3Benum-encoding%3Dint +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2FphraseSets%2Fspeechphraseset-minimal-${uniqueId} + +200 OK +Content-Type: application/json; charset=UTF-8 +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DR5Rzj0VMzNTQA== +X-Goog-Ext-9650773-Bin: DWAvIUE= +X-Xss-Protection: 0 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "displayName": "Initial description", + "etag": "abcdef0123A=", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 1, + "value": "initial phrase" + } + ], + "state": 2, + "uid": "111111111111111111111", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +PATCH https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}?%24alt=json%3Benum-encoding%3Dint&updateMask=phrases%2CdisplayName +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: phrase_set.name=projects%2F${projectId}%2Flocations%2Fglobal%2FphraseSets%2Fspeechphraseset-minimal-${uniqueId} + +{ + "displayName": "Updated description", + "name": "projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 2, + "value": "updated phrase" + } + ] +} + +200 OK +Content-Type: application/json; charset=UTF-8 +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DQp0wT0VMzODQA== +X-Goog-Ext-9650773-Bin: DagiF0E= +X-Xss-Protection: 0 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.speech.v2.OperationMetadata", + "createTime": "2024-04-01T12:34:56.123456Z", + "method": "google.cloud.speech.v2.Speech.UpdatePhraseSet", + "progressPercent": 100, + "resource": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "updatePhraseSetRequest": { + "phraseSet": { + "displayName": "Updated description", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 2, + "value": "updated phrase" + } + ] + }, + "updateMask": "phrases,displayName" + }, + "updateTime": "2024-04-01T12:34:56.123456Z" + }, + "name": "projects/${projectNumber}/locations/global/operations/${operationID}", + "response": { + "@type": "type.googleapis.com/google.cloud.speech.v2.PhraseSet", + "createTime": "2024-04-01T12:34:56.123456Z", + "displayName": "Updated description", + "etag": "abcdef0123A=", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 2, + "value": "updated phrase" + } + ], + "state": 2, + "uid": "111111111111111111111", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} + +--- + +GET https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}?%24alt=json%3Benum-encoding%3Dint +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2FphraseSets%2Fspeechphraseset-minimal-${uniqueId} + +200 OK +Content-Type: application/json; charset=UTF-8 +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DQKpzz0VzcxsQA== +X-Goog-Ext-9650773-Bin: DQk8IkE= +X-Xss-Protection: 0 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "displayName": "Updated description", + "etag": "abcdef0123A=", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 2, + "value": "updated phrase" + } + ], + "state": 2, + "uid": "111111111111111111111", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +DELETE https://speech.googleapis.com/v2/projects/${projectId}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}?%24alt=json%3Benum-encoding%3Dint +Content-Type: application/json +User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion} +X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2FphraseSets%2Fspeechphraseset-minimal-${uniqueId} + +200 OK +Content-Type: application/json; charset=UTF-8 +Server: scaffolding on HTTPServer2 +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Goog-Ext-27651507-Bin: DVIaAj4Vmpl5QA== +X-Goog-Ext-9650773-Bin: DSBJS0E= +X-Xss-Protection: 0 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.speech.v2.OperationMetadata", + "createTime": "2024-04-01T12:34:56.123456Z", + "deletePhraseSetRequest": { + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}" + }, + "method": "google.cloud.speech.v2.Speech.DeletePhraseSet", + "progressPercent": 100, + "resource": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + }, + "name": "projects/${projectNumber}/locations/global/operations/${operationID}", + "response": { + "@type": "type.googleapis.com/google.cloud.speech.v2.PhraseSet", + "createTime": "2024-04-01T12:34:56.123456Z", + "deleteTime": "2024-04-01T12:34:56.123456Z", + "displayName": "Updated description", + "etag": "abcdef0123A=", + "expireTime": "2024-04-01T12:34:56.123456Z", + "name": "projects/${projectNumber}/locations/global/phraseSets/speechphraseset-minimal-${uniqueId}", + "phrases": [ + { + "boost": 2, + "value": "updated phrase" + } + ], + "state": 4, + "uid": "111111111111111111111", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} \ No newline at end of file From a91d41f96f70be29e8202f7f86e70e7ec120e3dc Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:37:56 +0000 Subject: [PATCH 5/6] SpeechPhraseSet: add mock implementation --- mockgcp/mockspeech/phraseset.go | 280 ++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 mockgcp/mockspeech/phraseset.go diff --git a/mockgcp/mockspeech/phraseset.go b/mockgcp/mockspeech/phraseset.go new file mode 100644 index 00000000000..e3decd026bf --- /dev/null +++ b/mockgcp/mockspeech/phraseset.go @@ -0,0 +1,280 @@ +// 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. + +// +tool:mockgcp-support +// proto.service: google.cloud.speech.v2.Speech +// proto.message: google.cloud.speech.v2.PhraseSet + +package mockspeech + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/fields" + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects" + pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/cloud/speech/v2" + "github.com/google/uuid" + longrunningpb "google.golang.org/genproto/googleapis/longrunning" +) + +func (s *SpeechV2) GetPhraseSet(ctx context.Context, req *pb.GetPhraseSetRequest) (*pb.PhraseSet, error) { + name, err := s.parsePhraseSetName(req.GetName()) + if err != nil { + return nil, err + } + fqn := name.String() + + obj := &pb.PhraseSet{} + if err := s.storage.Get(ctx, fqn, obj); err != nil { + if status.Code(err) == codes.NotFound { + // Adjusted error message based on typical Google API responses + return nil, status.Errorf(codes.NotFound, "Unable to find PhraseSet %s from project %d.", name.PhraseSetID, name.Project.Number) + } + return nil, err + } + + return obj, nil +} + +func (s *SpeechV2) CreatePhraseSet(ctx context.Context, req *pb.CreatePhraseSetRequest) (*longrunningpb.Operation, error) { + reqName := fmt.Sprintf("%s/phraseSets/%s", req.GetParent(), req.GetPhraseSetId()) + name, err := s.parsePhraseSetName(reqName) + if err != nil { + return nil, err + } + fqn := name.String() + now := time.Now() + + obj := proto.Clone(req.GetPhraseSet()).(*pb.PhraseSet) + obj.Name = fqn + obj.CreateTime = timestamppb.New(now) + obj.UpdateTime = timestamppb.New(now) + obj.Uid = uuid.New().String() + obj.State = pb.PhraseSet_ACTIVE // Assume immediate activation for mock + obj.Etag = fields.ComputeWeakEtag(obj) + + // Validate boost values + if obj.Boost < 0 || obj.Boost > 20 { + return nil, status.Errorf(codes.InvalidArgument, "phrase set boost %v must be between 0 and 20", obj.Boost) + } + for _, phrase := range obj.Phrases { + if phrase.Boost < 0 || phrase.Boost > 20 { + return nil, status.Errorf(codes.InvalidArgument, "phrase boost %v for phrase '%s' must be between 0 and 20", phrase.Boost, phrase.Value) + } + } + + if err := s.storage.Create(ctx, fqn, obj); err != nil { + return nil, err + } + + metadata := &pb.OperationMetadata{ + CreateTime: timestamppb.New(now), + UpdateTime: timestamppb.New(now), + Method: "google.cloud.speech.v2.Speech.CreatePhraseSet", + ProgressPercent: 100, + } + + // change project ID to project number + metadata.Resource = strings.Replace(obj.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1) + + // change project ID to project number in request details + req.Parent = strings.Replace(req.GetParent(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1) + metadata.Request = &pb.OperationMetadata_CreatePhraseSetRequest{ + CreatePhraseSetRequest: req, + } + + prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location) + + return s.operations.DoneLRO(ctx, prefix, metadata, obj) +} + +func (s *SpeechV2) UpdatePhraseSet(ctx context.Context, req *pb.UpdatePhraseSetRequest) (*longrunningpb.Operation, error) { + name, err := s.parsePhraseSetName(req.GetPhraseSet().GetName()) + if err != nil { + return nil, err + } + fqn := name.String() + now := time.Now() + + obj := &pb.PhraseSet{} + if err := s.storage.Get(ctx, fqn, obj); err != nil { + return nil, err + } + + paths := req.GetUpdateMask().GetPaths() + if len(paths) == 0 { + return nil, status.Errorf(codes.InvalidArgument, "update_mask must be provided") + } + + for i, path := range paths { + switch path { + case "displayName": // proto field name is display_name + obj.DisplayName = req.GetPhraseSet().GetDisplayName() + // HACK: to make the field mask valid when returning + req.UpdateMask.Paths[i] = "display_name" + case "phrases": + obj.Phrases = req.GetPhraseSet().GetPhrases() + case "boost": + obj.Boost = req.GetPhraseSet().GetBoost() + case "annotations": + obj.Annotations = req.GetPhraseSet().GetAnnotations() + default: + return nil, status.Errorf(codes.InvalidArgument, "update_mask path %q not valid for PhraseSet update", path) + } + } + + obj.UpdateTime = timestamppb.New(now) + obj.Etag = fields.ComputeWeakEtag(obj) + + if err := s.storage.Update(ctx, fqn, obj); err != nil { + return nil, err + } + + metadata := &pb.OperationMetadata{ + CreateTime: timestamppb.New(now), + UpdateTime: timestamppb.New(now), + Method: "google.cloud.speech.v2.Speech.UpdatePhraseSet", + ProgressPercent: 100, + } + + // change project ID to project number + metadata.Resource = strings.Replace(obj.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1) + + // change project ID to project number in request details + req.PhraseSet.Name = strings.Replace(req.PhraseSet.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1) + metadata.Request = &pb.OperationMetadata_UpdatePhraseSetRequest{ + UpdatePhraseSetRequest: req, + } + + prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location) + + return s.operations.DoneLRO(ctx, prefix, metadata, obj) +} + +func (s *SpeechV2) DeletePhraseSet(ctx context.Context, req *pb.DeletePhraseSetRequest) (*longrunningpb.Operation, error) { + name, err := s.parsePhraseSetName(req.GetName()) + if err != nil { + return nil, err + } + fqn := name.String() + now := time.Now() + + prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location) + + // change project ID to project number in request details + req.Name = strings.Replace(req.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1) + + obj := &pb.PhraseSet{} + if err := s.storage.Get(ctx, fqn, obj); err != nil { + if status.Code(err) == codes.NotFound { + if req.GetAllowMissing() { + // Return a completed LRO indicating success (no-op) + metadata := &pb.OperationMetadata{ + CreateTime: timestamppb.New(now), + UpdateTime: timestamppb.New(now), + Resource: strings.Replace(fqn, "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1), // Use project number in metadata + Method: "google.cloud.speech.v2.Speech.DeletePhraseSet", + ProgressPercent: 100, + Request: &pb.OperationMetadata_DeletePhraseSetRequest{ + DeletePhraseSetRequest: req, + }, + } + // Return a placeholder object matching the LRO response type + deletedPlaceholder := &pb.PhraseSet{ + Name: strings.Replace(fqn, "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1), + State: pb.PhraseSet_DELETED, + } + return s.operations.DoneLRO(ctx, prefix, metadata, deletedPlaceholder) + } + return nil, status.Errorf(codes.NotFound, "PhraseSet %q was not found.", name.PhraseSetID) + } + return nil, err + } + + // Validate Etag if provided + if req.GetEtag() != "" && req.GetEtag() != obj.Etag { + return nil, status.Errorf(codes.Aborted, "etag mismatch for PhraseSet %q", name.PhraseSetID) + } + + // Mark as deleted conceptually (although we delete immediately) + obj.State = pb.PhraseSet_DELETED + obj.DeleteTime = timestamppb.New(now) + // Set expire time, e.g., 30 days from now, though it won't be used if we delete immediately + obj.ExpireTime = timestamppb.New(now.Add(30 * 24 * time.Hour)) + + // Delete from storage + if err := s.storage.Delete(ctx, fqn, &pb.PhraseSet{}); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete PhraseSet %q: %v", fqn, err) + } + + metadata := &pb.OperationMetadata{ + CreateTime: timestamppb.New(now), + UpdateTime: timestamppb.New(now), + Method: "google.cloud.speech.v2.Speech.DeletePhraseSet", + ProgressPercent: 100, + Request: &pb.OperationMetadata_DeletePhraseSetRequest{ + DeletePhraseSetRequest: req, + }, + Resource: obj.GetName(), + } + + return s.operations.DoneLRO(ctx, prefix, metadata, obj) +} + +type phraseSetName struct { + Project *projects.ProjectData + Location string + PhraseSetID string +} + +func (n *phraseSetName) String() string { + return fmt.Sprintf("projects/%d/locations/%s/phraseSets/%s", n.Project.Number, n.Location, n.PhraseSetID) +} + +// parsePhraseSetName parses a string into a phraseSetName. +// The expected form is `projects/*/locations/*/phraseSets/*`. +func (s *MockService) parsePhraseSetName(name string) (*phraseSetName, error) { + tokens := strings.Split(name, "/") + + if len(tokens) == 6 && tokens[0] == "projects" && tokens[2] == "locations" && tokens[4] == "phraseSets" { + project, err := s.Projects.GetProjectByID(tokens[1]) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "project %q not found: %v", tokens[1], err) + } + + nameObj := &phraseSetName{ + Project: project, + Location: tokens[3], + PhraseSetID: tokens[5], + } + + // Basic validation for IDs - should not be empty + if nameObj.Location == "" || nameObj.PhraseSetID == "" { + return nil, status.Errorf(codes.InvalidArgument, "name %q has empty location or phrase set ID", name) + } + + return nameObj, nil + } + + return nil, status.Errorf(codes.InvalidArgument, "name %q is not in the expected format projects/*/locations/*/phraseSets/*", name) +} From 71c043644b212f256aa20ecd28cdb649337315f4 Mon Sep 17 00:00:00 2001 From: Jingyi Hu Date: Tue, 22 Apr 2025 09:38:10 +0000 Subject: [PATCH 6/6] SpeechPhraseSet: record mock GCP --- .../speechphraseset-minimal/_http.log | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log index 03feea6919c..1cee0f9be56 100644 --- a/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log +++ b/pkg/test/resourcefixture/testdata/basic/speech/v1alpha1/speechphraseset/speechphraseset-minimal/_http.log @@ -5,15 +5,12 @@ X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2Fphras 404 Not Found Content-Type: application/json; charset=UTF-8 -Grpc-Status-Details-Bin: CAUSW1VuYWJsZSB0byBmaW5kIFBocmFzZVNldCBzcGVlY2hwaHJhc2VzZXQtbWluaW1hbC16ZHVqeTV0dmFubXJ0eGkgZnJvbSBwcm9qZWN0IDYwMDEzOTU2NjExNi4a+QEKJHR5cGUuZ29vZ2xlYXBpcy5jb20vdXRpbC5TdGF0dXNQcm90bxLQAQgFEgdnZW5lcmljGltVbmFibGUgdG8gZmluZCBQaHJhc2VTZXQgc3BlZWNocGhyYXNlc2V0LW1pbmltYWwtemR1ank1dHZhbm1ydHhpIGZyb20gcHJvamVjdCA2MDAxMzk1NjYxMTYuKmYLEIbWjScaXRJbVW5hYmxlIHRvIGZpbmQgUGhyYXNlU2V0IHNwZWVjaHBocmFzZXNldC1taW5pbWFsLXpkdWp5NXR2YW5tcnR4aSBmcm9tIHByb2plY3QgNjAwMTM5NTY2MTE2Lgw= -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DVKA8j0VmpmJQA== -X-Goog-Ext-9650773-Bin: DUB0PUE= X-Xss-Protection: 0 { @@ -43,14 +40,12 @@ X-Goog-Request-Params: parent=projects%2F${projectId}%2Flocations%2Fglobal 200 OK Content-Type: application/json; charset=UTF-8 -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DTXM8z0VAABgQA== -X-Goog-Ext-9650773-Bin: DYl3PkE= X-Xss-Protection: 0 { @@ -104,14 +99,12 @@ X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2Fphras 200 OK Content-Type: application/json; charset=UTF-8 -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DR5Rzj0VMzNTQA== -X-Goog-Ext-9650773-Bin: DWAvIUE= X-Xss-Protection: 0 { @@ -150,14 +143,12 @@ X-Goog-Request-Params: phrase_set.name=projects%2F${projectId}%2Flocations%2Fglo 200 OK Content-Type: application/json; charset=UTF-8 -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DQp0wT0VMzODQA== -X-Goog-Ext-9650773-Bin: DagiF0E= X-Xss-Protection: 0 { @@ -211,14 +202,12 @@ X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2Fphras 200 OK Content-Type: application/json; charset=UTF-8 -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DQKpzz0VzcxsQA== -X-Goog-Ext-9650773-Bin: DQk8IkE= X-Xss-Protection: 0 { @@ -246,14 +235,12 @@ X-Goog-Request-Params: name=projects%2F${projectId}%2Flocations%2Fglobal%2Fphras 200 OK Content-Type: application/json; charset=UTF-8 -Server: scaffolding on HTTPServer2 +Server: ESF Vary: Origin Vary: X-Origin Vary: Referer X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN -X-Goog-Ext-27651507-Bin: DVIaAj4Vmpl5QA== -X-Goog-Ext-9650773-Bin: DSBJS0E= X-Xss-Protection: 0 {