diff --git a/config/tests/samples/create/harness.go b/config/tests/samples/create/harness.go index a41c2ee96e..ad917fb916 100644 --- a/config/tests/samples/create/harness.go +++ b/config/tests/samples/create/harness.go @@ -443,6 +443,8 @@ func MaybeSkip(t *testing.T, name string, resources []*unstructured.Unstructured case schema.GroupKind{Group: "serviceusage.cnrm.cloud.google.com", Kind: "Service"}: case schema.GroupKind{Group: "serviceusage.cnrm.cloud.google.com", Kind: "ServiceIdentity"}: + case schema.GroupKind{Group: "tags.cnrm.cloud.google.com", Kind: "TagsTagKey"}: + default: t.Skipf("gk %v not suppported by mock gcp; skipping", gvk.GroupKind()) } diff --git a/mockgcp/common/httpmux/json.go b/mockgcp/common/httpmux/json.go new file mode 100644 index 0000000000..a52c6e994c --- /dev/null +++ b/mockgcp/common/httpmux/json.go @@ -0,0 +1,73 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpmux + +import ( + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "k8s.io/klog/v2" +) + +func MarshalAsJSON(obj proto.Message) ([]byte, error) { + return protojson.MarshalOptions{Resolver: &protoResolver{}}.Marshal(obj) +} + +type protoResolver struct { +} + +var _ protoregistry.ExtensionTypeResolver = &protoResolver{} + +func (r *protoResolver) FindExtensionByName(message protoreflect.FullName) (protoreflect.ExtensionType, error) { + return protoregistry.GlobalTypes.FindExtensionByName(r.remapName(message)) +} + +func (r *protoResolver) FindExtensionByNumber(message protoreflect.FullName, field protoreflect.FieldNumber) (protoreflect.ExtensionType, error) { + return protoregistry.GlobalTypes.FindExtensionByNumber(r.remapName(message), field) +} + +var _ protoregistry.MessageTypeResolver = &protoResolver{} + +func (r *protoResolver) FindMessageByName(message protoreflect.FullName) (protoreflect.MessageType, error) { + return protoregistry.GlobalTypes.FindMessageByName(r.remapName(message)) +} + +func (r *protoResolver) FindMessageByURL(url string) (protoreflect.MessageType, error) { + if strings.HasPrefix(url, "type.googleapis.com/google.") { + s := "type.googleapis.com/mockgcp." + strings.TrimPrefix(url, "type.googleapis.com/google.") + mt, err := protoregistry.GlobalTypes.FindMessageByURL(s) + if err != nil { + klog.Warningf("FindMessageByURL(%q) failed: %v", s, err) + } else { + return mt, nil + } + } + + return protoregistry.GlobalTypes.FindMessageByURL(url) +} + +func (r *protoResolver) remapName(name protoreflect.FullName) protoreflect.FullName { + // Remap names with a prefix of "google."" to be "mockgcp.", so we can find them. + + s := string(name) + if strings.HasPrefix(s, "google.") { + s = "mockgcp." + strings.TrimPrefix(s, "google.") + return protoreflect.FullName(s) + } + return name +} diff --git a/mockgcp/common/httpmux/mux.go b/mockgcp/common/httpmux/mux.go index d6c0a0c0d0..faaa2416d6 100644 --- a/mockgcp/common/httpmux/mux.go +++ b/mockgcp/common/httpmux/mux.go @@ -27,13 +27,16 @@ import ( // NewServeMux constructs an http server with our error handling etc func NewServeMux(ctx context.Context, conn *grpc.ClientConn, handlers ...func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error) (*runtime.ServeMux, error) { + resolver := &protoResolver{} marshaler := &runtime.HTTPBodyMarshaler{ Marshaler: &runtime.JSONPb{ MarshalOptions: protojson.MarshalOptions{ EmitUnpopulated: false, + Resolver: resolver, }, UnmarshalOptions: protojson.UnmarshalOptions{ DiscardUnknown: true, + Resolver: resolver, }, }, } diff --git a/mockgcp/common/operations/http.go b/mockgcp/common/operations/http.go new file mode 100644 index 0000000000..0c91fe3ed0 --- /dev/null +++ b/mockgcp/common/operations/http.go @@ -0,0 +1,75 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package operations + +import ( + "context" + "net/http" + + "cloud.google.com/go/longrunning/autogen/longrunningpb" + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/httpmux" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/klog/v2" +) + +func (s *Operations) RegisterOperationsHandler(prefix string) func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + forwardResponseOptions := mux.GetForwardResponseOptions() + + // GET /{prefix}/operations/{name} + if err := mux.HandlePath("GET", "/"+prefix+"/operations/{name}", func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) { + ctx := r.Context() + name := pathParams["name"] + req := &longrunningpb.GetOperationRequest{Name: "operations/" + name} + op, err := s.GetOperation(ctx, req) + if err != nil { + if status.Code(err) == codes.NotFound { + klog.Infof("operation not found %+v", req) + w.WriteHeader(http.StatusNotFound) + return + } + klog.Warningf("error getting operation %T: %v", err, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + for _, forwardResponseOption := range forwardResponseOptions { + err := forwardResponseOption(ctx, w, op) + if err != nil { + klog.Warningf("error running forwardResponseOption %T: %v", forwardResponseOption, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } + + b, err := httpmux.MarshalAsJSON(op) + if err != nil { + klog.Warningf("error converting to proto %T: %v", err, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write(b) + }); err != nil { + return err + } + return nil + } +} diff --git a/mockgcp/common/operations/operations.go b/mockgcp/common/operations/operations.go index 849d324b8d..85175da5f9 100644 --- a/mockgcp/common/operations/operations.go +++ b/mockgcp/common/operations/operations.go @@ -17,6 +17,7 @@ package operations import ( "context" "fmt" + "strings" "time" pb "google.golang.org/genproto/googleapis/longrunning" @@ -61,7 +62,7 @@ func (s *Operations) NewLRO(ctx context.Context) (*pb.Operation, error) { return op, nil } -func (s *Operations) StartLRO(ctx context.Context, callback func() (proto.Message, error)) (*pb.Operation, error) { +func (s *Operations) StartLRO(ctx context.Context, metadata proto.Message, callback func() (proto.Message, error)) (*pb.Operation, error) { now := time.Now() millis := now.UnixMilli() id := uuid.NewUUID() @@ -71,6 +72,15 @@ func (s *Operations) StartLRO(ctx context.Context, callback func() (proto.Messag op.Name = fmt.Sprintf("operations/operation-%d-%s", millis, id) op.Done = false + if metadata != nil { + metadataAny, err := anypb.New(metadata) + if err != nil { + return nil, fmt.Errorf("error building anypb for metadata: %w", err) + } + rewriteTypes(metadataAny) + + op.Metadata = metadataAny + } fqn := op.Name if err := s.storage.Create(ctx, fqn, op); err != nil { @@ -98,6 +108,8 @@ func (s *Operations) StartLRO(ctx context.Context, callback func() (proto.Messag klog.Warningf("error building anypb for result: %v", err) finished.Result = &pb.Operation_Response{} } else { + rewriteTypes(resultAny) + finished.Result = &pb.Operation_Response{ Response: resultAny, } @@ -112,6 +124,13 @@ func (s *Operations) StartLRO(ctx context.Context, callback func() (proto.Messag return op, nil } +func rewriteTypes(any *anypb.Any) { + // Fix our mockgcp hack + if strings.HasPrefix(any.TypeUrl, "type.googleapis.com/mockgcp.") { + any.TypeUrl = "type.googleapis.com/google." + strings.TrimPrefix(any.TypeUrl, "type.googleapis.com/mockgcp.") + } +} + // Gets the latest state of a long-running operation. Clients can use this // method to poll the operation result at intervals as recommended by the API // service. diff --git a/mockgcp/mockresourcemanager/service.go b/mockgcp/mockresourcemanager/service.go index f0db287bba..c4c3d8ea8f 100644 --- a/mockgcp/mockresourcemanager/service.go +++ b/mockgcp/mockresourcemanager/service.go @@ -16,16 +16,12 @@ package mockresourcemanager import ( "context" - "encoding/json" "net/http" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/httpmux" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/operations" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects" pb_v1 "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/cloud/resourcemanager/v1" @@ -69,66 +65,18 @@ func (s *MockService) ExpectedHost() string { func (s *MockService) Register(grpcServer *grpc.Server) { pb_v1.RegisterProjectsServer(grpcServer, s.projectsV1) pb_v3.RegisterProjectsServer(grpcServer, s.projectsV3) + pb_v3.RegisterTagKeysServer(grpcServer, &TagKeys{MockService: s}) } func (s *MockService) NewHTTPMux(ctx context.Context, conn *grpc.ClientConn) (http.Handler, error) { - mux := runtime.NewServeMux(runtime.WithErrorHandler(customErrorHandler)) - - if err := pb_v1.RegisterProjectsHandler(ctx, mux, conn); err != nil { - return nil, err - } - - if err := pb_v3.RegisterProjectsHandler(ctx, mux, conn); err != nil { + mux, err := httpmux.NewServeMux(ctx, conn, + pb_v1.RegisterProjectsHandler, + pb_v3.RegisterProjectsHandler, + pb_v3.RegisterTagKeysHandler, + s.operations.RegisterOperationsHandler("v3")) + if err != nil { return nil, err } return mux, nil } - -type wrappedStatus struct { - Error *wrappedError `json:"error,omitempty"` -} - -type wrappedError struct { - Code int `json:"code,omitempty"` - Message string `json:"message,omitempty"` - Status string `json:"status,omitempty"` -} - -// customErrorHandler wraps errors in an error blockk -func customErrorHandler(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) { - s := status.Convert(err) - // pb := s.Proto() - - w.Header().Del("Trailer") - w.Header().Del("Transfer-Encoding") - - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - - httpStatusCode := runtime.HTTPStatusFromCode(s.Code()) - wrapped := &wrappedStatus{ - Error: &wrappedError{ - Code: httpStatusCode, - Message: s.Message(), - }, - } - - switch s.Code() { - case codes.PermissionDenied: - wrapped.Error.Status = "PERMISSION_DENIED" - case codes.AlreadyExists: - wrapped.Error.Status = "ALREADY_EXISTS" - } - - buf, merr := json.Marshal(wrapped) - if merr != nil { - klog.Warningf("Failed to marshal error message %q: %v", s, merr) - runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err) - return - } - - w.WriteHeader(httpStatusCode) - if _, err := w.Write(buf); err != nil { - klog.Warningf("Failed to write response: %v", err) - } -} diff --git a/mockgcp/mockresourcemanager/tagkeys.go b/mockgcp/mockresourcemanager/tagkeys.go new file mode 100644 index 0000000000..59ad35497e --- /dev/null +++ b/mockgcp/mockresourcemanager/tagkeys.go @@ -0,0 +1,227 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mockresourcemanager + +import ( + "context" + "crypto/md5" + "encoding/base64" + "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" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/klog/v2" + + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects" + pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/cloud/resourcemanager/v3" + longrunningpb "google.golang.org/genproto/googleapis/longrunning" +) + +type TagKeys struct { + *MockService + pb.UnimplementedTagKeysServer +} + +func (s *TagKeys) GetTagKey(ctx context.Context, req *pb.GetTagKeyRequest) (*pb.TagKey, error) { + name, err := s.parseTagKeyName(req.Name) + if err != nil { + return nil, err + } + + fqn := name.String() + + obj := &pb.TagKey{} + if err := s.storage.Get(ctx, fqn, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "tagKey %q not found", name) + } else { + return nil, status.Errorf(codes.Internal, "error reading tagKey: %v", err) + } + } + + // We should verify that this is part of on of our projects, but ... it's a mock + + return obj, nil +} + +func (s *TagKeys) CreateTagKey(ctx context.Context, req *pb.CreateTagKeyRequest) (*longrunningpb.Operation, error) { + var namespacedName string + + parent := req.GetTagKey().GetParent() + if strings.HasPrefix(parent, "projects/") { + projectName, err := projects.ParseProjectName(parent) + if err != nil { + return nil, err + } + project, err := s.projectsInternal.GetProject(projectName) + if err != nil { + return nil, err + } + namespacedName = project.ID + "/" + req.GetTagKey().GetShortName() + // Parent is normalized to the project number + req.GetTagKey().Parent = fmt.Sprintf("projects/%d", project.Number) + } else if strings.HasPrefix(parent, "organizations/") { + // We should check that the org exists, permissions etc, but ... it's a mock + namespacedName = strings.TrimPrefix(parent, "organizations/") + "/" + req.GetTagKey().GetShortName() + } else { + return nil, status.Errorf(codes.InvalidArgument, "parent %q is not valid", parent) + } + + if req.ValidateOnly { + return nil, fmt.Errorf("ValidateOnly not yet implemented") + } + + name := &tagKeyName{ + ID: time.Now().UnixNano(), + } + + fqn := name.String() + now := timestamppb.Now() + + obj := proto.Clone(req.TagKey).(*pb.TagKey) + + obj.CreateTime = now + obj.UpdateTime = now + obj.NamespacedName = namespacedName + obj.Etag = base64.StdEncoding.EncodeToString(computeEtag(obj)) + obj.Name = fqn + + if err := s.storage.Create(ctx, fqn, obj); err != nil { + return nil, status.Errorf(codes.Internal, "error creating tagKey: %v", err) + } + + metadata := &pb.CreateTagKeyMetadata{} + return s.operations.StartLRO(ctx, metadata, func() (proto.Message, error) { + return obj, nil + }) +} + +func (s *TagKeys) UpdateTagKey(ctx context.Context, req *pb.UpdateTagKeyRequest) (*longrunningpb.Operation, error) { + reqName := req.GetTagKey().GetName() + name, err := s.parseTagKeyName(reqName) + if err != nil { + return nil, err + } + + if req.ValidateOnly { + return nil, fmt.Errorf("ValidateOnly not yet implemented") + } + + fqn := name.String() + obj := &pb.TagKey{} + if err := s.storage.Get(ctx, fqn, obj); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "tagKey %q not found", reqName) + } + return nil, status.Errorf(codes.Internal, "error reading tagKey: %v", err) + } + + // We should verify that this is part of on of our projects, but ... it's a mock + + // Fields to be updated. The mask may only contain `description` or + // `etag`. If omitted entirely, both `description` and `etag` are assumed to + // be significant. + paths := req.GetUpdateMask().GetPaths() + for _, path := range paths { + switch path { + case "description": + obj.Description = req.GetTagKey().GetDescription() + default: + return nil, status.Errorf(codes.InvalidArgument, "update_mask path %q not valid", path) + } + } + if len(paths) == 0 { + obj.Description = req.GetTagKey().GetDescription() + } + + if err := s.storage.Update(ctx, fqn, obj); err != nil { + return nil, status.Errorf(codes.Internal, "error updating tagKey: %v", err) + } + + metadata := &pb.UpdateTagKeyMetadata{} + return s.operations.StartLRO(ctx, metadata, func() (proto.Message, error) { + return obj, nil + }) +} + +func (s *TagKeys) DeleteTagKey(ctx context.Context, req *pb.DeleteTagKeyRequest) (*longrunningpb.Operation, error) { + name, err := s.parseTagKeyName(req.Name) + if err != nil { + return nil, err + } + + fqn := name.String() + + deleted := &pb.TagKey{} + if err := s.storage.Delete(ctx, fqn, deleted); err != nil { + if apierrors.IsNotFound(err) { + return nil, status.Errorf(codes.NotFound, "tagKey %q not found", name) + } else { + return nil, status.Errorf(codes.Internal, "error deleting tagKey: %v", err) + } + } + + // We should verify that this is part of on of our projects, but ... it's a mock + + metadata := &pb.DeleteTagKeyMetadata{} + return s.operations.StartLRO(ctx, metadata, func() (proto.Message, error) { + return deleted, nil + }) +} + +type tagKeyName struct { + ID int64 +} + +func (n *tagKeyName) String() string { + return fmt.Sprintf("tagKeys/%d", n.ID) +} + +// parseTagKeyName parses a string into a tagKeyName. +// The expected form is tagKeys/ +func (s *MockService) parseTagKeyName(name string) (*tagKeyName, error) { + tokens := strings.Split(name, "/") + + if len(tokens) == 2 && tokens[0] == "tagKeys" { + + n, err := strconv.ParseInt(tokens[1], 10, 64) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid (bad id)", name) + } + name := &tagKeyName{ + ID: n, + } + + return name, nil + } else { + return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid", name) + } +} + +func computeEtag(obj proto.Message) []byte { + // TODO: Do we risk exposing internal fields? Doesn't matter on a mock, I guess + b, err := proto.Marshal(obj) + if err != nil { + klog.Fatalf("failed to marshal proto object: %v", err) + } + hash := md5.Sum(b) + return hash[:] +} diff --git a/pkg/test/http_recorder.go b/pkg/test/http_recorder.go index b93bb05ceb..81bbd48048 100644 --- a/pkg/test/http_recorder.go +++ b/pkg/test/http_recorder.go @@ -238,3 +238,24 @@ func (r *Response) RemoveHeader(key string) { func (r *Request) RemoveHeader(key string) { r.Header.Del(key) } + +func (r *Response) ParseBody() map[string]any { + return parseBody(r.Body) +} + +func (r *Request) ParseBody() map[string]any { + return parseBody(r.Body) +} + +func parseBody(s string) map[string]any { + if s == "" { + return nil + } + obj := make(map[string]any) + if err := json.Unmarshal([]byte(s), &obj); err != nil { + klog.Fatalf("error from json.Unmarshal(%q): %v", s, err) + return nil + } + + return obj +} diff --git a/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeybasic/_http.log b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeybasic/_http.log new file mode 100644 index 0000000000..2295b00d5b --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeybasic/_http.log @@ -0,0 +1,207 @@ +POST https://cloudresourcemanager.googleapis.com/v3/tagKeys?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +{ + "description": "For keyname resources.", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}" +} + +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 + +{ + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.CreateTagKeyMetadata" + }, + "name": "operations/${operationId}" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/operations/${operationID}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.CreateTagKeyMetadata" + }, + "name": "operations/${operationId}", + "response": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.TagKey", + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "123450001/keyname${uniqueId}", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "123450001/keyname${uniqueId}", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "123450001/keyname${uniqueId}", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "123450001/keyname${uniqueId}", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +DELETE https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.DeleteTagKeyMetadata" + }, + "name": "operations/${operationId}" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/operations/${operationID}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.DeleteTagKeyMetadata" + }, + "name": "operations/${operationId}", + "response": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.TagKey", + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "123450001/keyname${uniqueId}", + "parent": "organizations/123450001", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} \ No newline at end of file diff --git a/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/_http.log b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/_http.log new file mode 100644 index 0000000000..ecbb7d5cf5 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/_http.log @@ -0,0 +1,207 @@ +POST https://cloudresourcemanager.googleapis.com/v3/tagKeys?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +{ + "description": "For keyname resources.", + "parent": "projects/${projectId}", + "shortName": "keyname${uniqueId}" +} + +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 + +{ + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.CreateTagKeyMetadata" + }, + "name": "operations/${operationId}" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/operations/${operationID}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.CreateTagKeyMetadata" + }, + "name": "operations/${operationId}", + "response": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.TagKey", + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "${projectId}/keyname${uniqueId}", + "parent": "projects/${projectNumber}", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "${projectId}/keyname${uniqueId}", + "parent": "projects/${projectNumber}", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "${projectId}/keyname${uniqueId}", + "parent": "projects/${projectNumber}", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "${projectId}/keyname${uniqueId}", + "parent": "projects/${projectNumber}", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" +} + +--- + +DELETE https://cloudresourcemanager.googleapis.com/v3/tagKeys/${tagKeyId}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.DeleteTagKeyMetadata" + }, + "name": "operations/${operationId}" +} + +--- + +GET https://cloudresourcemanager.googleapis.com/v3/operations/${operationID}?alt=json +Content-Type: application/json +User-Agent: Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev + +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 + +{ + "done": true, + "metadata": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.DeleteTagKeyMetadata" + }, + "name": "operations/${operationId}", + "response": { + "@type": "type.googleapis.com/google.cloud.resourcemanager.v3.TagKey", + "createTime": "2024-04-01T12:34:56.123456Z", + "description": "For keyname resources.", + "etag": "abcdef0123A=", + "name": "tagKeys/${tagKeyId}", + "namespacedName": "${projectId}/keyname${uniqueId}", + "parent": "projects/${projectNumber}", + "shortName": "keyname${uniqueId}", + "updateTime": "2024-04-01T12:34:56.123456Z" + } +} \ No newline at end of file diff --git a/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/create.yaml b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/create.yaml new file mode 100644 index 0000000000..19c1262655 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/tags/v1beta1/tagstagkey/tagkeyproject/create.yaml @@ -0,0 +1,22 @@ +# Copyright 2023 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: tags.cnrm.cloud.google.com/v1beta1 +kind: TagsTagKey +metadata: + name: tagstagkey-${uniqueId} +spec: + description: For keyname resources. + parent: projects/${projectId} + shortName: keyname${uniqueId} diff --git a/tests/e2e/unified_test.go b/tests/e2e/unified_test.go index ef17048454..71349ae1c7 100644 --- a/tests/e2e/unified_test.go +++ b/tests/e2e/unified_test.go @@ -16,8 +16,10 @@ package e2e import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/GoogleCloudPlatform/k8s-config-connector/config/tests/samples/create" @@ -165,6 +167,53 @@ func TestAllInSeries(t *testing.T) { if os.Getenv("GOLDEN_REQUEST_CHECKS") != "" { events := h.Events + operationIDs := map[string]bool{} + for _, req := range events.HTTPEvents { + url := req.Request.URL + id := "" + if index := strings.Index(url, "/v3/operations/"); index != -1 { + id = strings.TrimPrefix(url[index:], "/v3/operations/") + } + id = strings.TrimSuffix(id, "?alt=json") + if id != "" { + operationIDs[id] = true + } + } + + for _, req := range events.HTTPEvents { + url := req.Request.URL + for id := range operationIDs { + if strings.Contains(url, "/operations/"+id) { + url = strings.ReplaceAll(url, "/operations/"+id, "/operations/${operationID}") + } + } + req.Request.URL = url + } + + pathIDs := map[string]string{} + for _, req := range events.HTTPEvents { + if !strings.Contains(req.Request.URL, "/operations/${operationID}") { + continue + } + responseBody := req.Response.ParseBody() + if responseBody == nil { + continue + } + name, _, _ := unstructured.NestedString(responseBody, "response", "name") + if strings.HasPrefix(name, "tagKeys/") { + pathIDs[name] = "tagKeys/${tagKeyId}" + } + } + + // Replace any dynamic IDs that appear in URLs + for _, req := range events.HTTPEvents { + url := req.Request.URL + for k, v := range pathIDs { + url = strings.ReplaceAll(url, "/"+k, "/"+v) + } + req.Request.URL = url + } + // TODO: Fix how we poll / wait for objects being ready. events.RemoveRequests(func(e *test.LogEntry) bool { if e.Response.StatusCode == 404 && e.Request.Method == "GET" { @@ -173,42 +222,61 @@ func TestAllInSeries(t *testing.T) { return false }) - jsonMutators := []test.JSONMutator{} - - jsonMutators = append(jsonMutators, func(obj map[string]any) { - _, found, _ := unstructured.NestedString(obj, "uniqueId") - if found { - unstructured.SetNestedField(obj, "111111111111111111111", "uniqueId") - } - }) - jsonMutators = append(jsonMutators, func(obj map[string]any) { - _, found, _ := unstructured.NestedString(obj, "oauth2ClientId") - if found { - unstructured.SetNestedField(obj, "888888888888888888888", "oauth2ClientId") - } - }) - jsonMutators = append(jsonMutators, func(obj map[string]any) { - _, found, _ := unstructured.NestedString(obj, "etag") - if found { - unstructured.SetNestedField(obj, "abcdef0123A=", "etag") + // Remove operation polling requests (ones where the operation is not ready) + events.RemoveRequests(func(e *test.LogEntry) bool { + if !strings.Contains(e.Request.URL, "/operations/${operationID}") { + return false } - }) - jsonMutators = append(jsonMutators, func(obj map[string]any) { - _, found, _ := unstructured.NestedString(obj, "serviceAccount", "etag") - if found { - unstructured.SetNestedField(obj, "abcdef0123A=", "serviceAccount", "etag") + responseBody := e.Response.ParseBody() + if responseBody == nil { + return false } + done, _, _ := unstructured.NestedBool(responseBody, "done") + return !done // remove if not done }) + + jsonMutators := []test.JSONMutator{} + addReplacement := func(path string, newValue string) { + tokens := strings.Split(path, ".") + jsonMutators = append(jsonMutators, func(obj map[string]any) { + _, found, _ := unstructured.NestedString(obj, tokens...) + if found { + unstructured.SetNestedField(obj, newValue, tokens...) + } + }) + } + + addReplacement("uniqueId", "111111111111111111111") + addReplacement("oauth2ClientId", "888888888888888888888") + + addReplacement("etag", "abcdef0123A=") + addReplacement("serviceAccount.etag", "abcdef0123A=") + addReplacement("response.etag", "abcdef0123A=") + + addReplacement("createTime", "2024-04-01T12:34:56.123456Z") + addReplacement("response.createTime", "2024-04-01T12:34:56.123456Z") + addReplacement("updateTime", "2024-04-01T12:34:56.123456Z") + addReplacement("response.updateTime", "2024-04-01T12:34:56.123456Z") + events.PrettifyJSON(jsonMutators...) + // Remove headers that just aren't very relevant to testing events.RemoveHTTPResponseHeader("Date") events.RemoveHTTPResponseHeader("Alt-Svc") + got := events.FormatHTTP() expectedPath := filepath.Join(fixture.SourceDir, "_http.log") normalizers := []func(string) string{} normalizers = append(normalizers, h.IgnoreComments) normalizers = append(normalizers, h.ReplaceString(uniqueID, "${uniqueId}")) normalizers = append(normalizers, h.ReplaceString(project.ProjectID, "${projectId}")) + normalizers = append(normalizers, h.ReplaceString(fmt.Sprintf("%d", project.ProjectNumber), "${projectNumber}")) + for k, v := range pathIDs { + normalizers = append(normalizers, h.ReplaceString(k, v)) + } + for k := range operationIDs { + normalizers = append(normalizers, h.ReplaceString("operations/"+k, "operations/${operationId}")) + } h.CompareGoldenFile(expectedPath, got, normalizers...) } })