Skip to content

Commit 2536e67

Browse files
authored
Implement MockGCP for NetworkServices LBRouteExtension (#6855)
Implement MockGCP CRUD support for LBRouteExtension, including service registration and LRO handling. Verified via gcloud test scripts. Addresses #6513 - [x] Run `make ready-pr` to ensure this PR is ready for review. - [x] Perform necessary E2E testing for changed resources.
2 parents a671872 + e7640d2 commit 2536e67

File tree

7 files changed

+2479
-5
lines changed

7 files changed

+2479
-5
lines changed

mockgcp/GEMINI.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,25 @@ The project uses a "golden file" testing approach. This involves:
4949
3. Running the tests against the mock implementation and comparing the actual output to the golden files.
5050

5151
This ensures that the mock implementation accurately reflects the behavior of the real GCP APIs.
52+
53+
### Normalization and Scoping
54+
55+
Mock services often need to normalize responses to ensure stable golden logs (e.g., replacing timestamps or IDs with placeholders). This is typically implemented in `normalize.go` using the `Previsit` and `ConfigureVisitor` methods.
56+
57+
**Critical Rule: Previsit Scoping**
58+
59+
The `Previsit` method is **globally executed** for every event across all registered services. To prevent one service's normalization rules from affecting another service's tests, you MUST explicitly scope the `Previsit` logic to the relevant service URL.
60+
61+
Example of correct scoping:
62+
63+
```go
64+
func (s *MockService) Previsit(event mockgcpregistry.Event, replacements mockgcpregistry.NormalizingVisitor) {
65+
// Only apply normalization if the request is for this service
66+
if !strings.Contains(event.URL(), "myservice.googleapis.com") {
67+
return
68+
}
69+
// ... normalization logic ...
70+
}
71+
```
72+
73+
Failure to scope `Previsit` can lead to unintended log changes in unrelated services, causing "log corruption" and massive diffs in unrelated test data.
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// +tool:mockgcp-support
16+
// proto.service: google.cloud.networkservices.v1.NetworkServices
17+
// proto.message: google.cloud.networkservices.v1.LbRouteExtension
18+
19+
package mocknetworkservices
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"strconv"
25+
"strings"
26+
"time"
27+
28+
"google.golang.org/grpc/codes"
29+
"google.golang.org/grpc/status"
30+
"google.golang.org/protobuf/proto"
31+
"google.golang.org/protobuf/types/known/emptypb"
32+
"google.golang.org/protobuf/types/known/timestamppb"
33+
34+
"github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects"
35+
"github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/pkg/storage"
36+
37+
pb "cloud.google.com/go/networkservices/apiv1/networkservicespb"
38+
longrunningpb "google.golang.org/genproto/googleapis/longrunning"
39+
)
40+
41+
func (s *NetworkServicesServer) GetLbRouteExtension(ctx context.Context, req *pb.GetLbRouteExtensionRequest) (*pb.LbRouteExtension, error) {
42+
name, err := s.parseLbRouteExtensionName(req.Name)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
fqn := name.String()
48+
49+
obj := &pb.LbRouteExtension{}
50+
if err := s.storage.Get(ctx, fqn, obj); err != nil {
51+
if status.Code(err) == codes.NotFound {
52+
return nil, status.Errorf(codes.NotFound, "Resource '%s' was not found", fqn)
53+
}
54+
return nil, err
55+
}
56+
57+
return obj, nil
58+
}
59+
func (s *NetworkServicesServer) ListLbRouteExtensions(ctx context.Context, req *pb.ListLbRouteExtensionsRequest) (*pb.ListLbRouteExtensionsResponse, error) {
60+
response := &pb.ListLbRouteExtensionsResponse{}
61+
62+
parent, err := s.parseLbRouteExtensionParent(req.Parent)
63+
if err != nil {
64+
return nil, err
65+
}
66+
prefix := parent.String() + "/lbRouteExtensions/"
67+
68+
findKind := (&pb.LbRouteExtension{}).ProtoReflect().Descriptor()
69+
if err := s.storage.List(ctx, findKind, storage.ListOptions{
70+
Prefix: prefix,
71+
}, func(obj proto.Message) error {
72+
item := obj.(*pb.LbRouteExtension)
73+
response.LbRouteExtensions = append(response.LbRouteExtensions, item)
74+
return nil
75+
}); err != nil {
76+
return nil, err
77+
}
78+
79+
return response, nil
80+
}
81+
82+
func (s *NetworkServicesServer) CreateLbRouteExtension(ctx context.Context, req *pb.CreateLbRouteExtensionRequest) (*longrunningpb.Operation, error) {
83+
reqName := req.Parent + "/lbRouteExtensions/" + req.LbRouteExtensionId
84+
name, err := s.parseLbRouteExtensionName(reqName)
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
fqn := name.String()
90+
91+
now := time.Now()
92+
93+
obj := proto.Clone(req.LbRouteExtension).(*pb.LbRouteExtension)
94+
obj.Name = fqn
95+
obj.CreateTime = timestamppb.New(now)
96+
obj.UpdateTime = timestamppb.New(now)
97+
98+
if err := s.normalizeLbRouteExtension(ctx, obj); err != nil {
99+
return nil, err
100+
}
101+
102+
if err := s.storage.Create(ctx, fqn, obj); err != nil {
103+
return nil, err
104+
}
105+
106+
lroPrefix := fmt.Sprintf("projects/%s/locations/%s", name.Project.ID, name.Location)
107+
lroMetadata := &pb.OperationMetadata{
108+
CreateTime: timestamppb.New(now),
109+
Target: name.String(),
110+
Verb: "create",
111+
ApiVersion: "v1",
112+
RequestedCancellation: false,
113+
}
114+
return s.operations.StartLRO(ctx, lroPrefix, lroMetadata, func() (proto.Message, error) {
115+
lroMetadata.EndTime = timestamppb.New(time.Now())
116+
return obj, nil
117+
})
118+
}
119+
120+
func (s *NetworkServicesServer) UpdateLbRouteExtension(ctx context.Context, req *pb.UpdateLbRouteExtensionRequest) (*longrunningpb.Operation, error) {
121+
reqName := req.GetLbRouteExtension().GetName()
122+
123+
name, err := s.parseLbRouteExtensionName(reqName)
124+
if err != nil {
125+
return nil, err
126+
}
127+
128+
fqn := name.String()
129+
obj := &pb.LbRouteExtension{}
130+
if err := s.storage.Get(ctx, fqn, obj); err != nil {
131+
return nil, err
132+
}
133+
134+
now := time.Now()
135+
paths := req.GetUpdateMask().GetPaths()
136+
if len(paths) == 0 {
137+
req.LbRouteExtension.CreateTime = obj.CreateTime
138+
req.LbRouteExtension.UpdateTime = timestamppb.New(now)
139+
req.LbRouteExtension.Name = obj.Name
140+
obj = req.LbRouteExtension
141+
} else {
142+
// gcloud uses camelCase for some fields in updateMask; handle both.
143+
for _, path := range paths {
144+
switch path {
145+
case "labels":
146+
obj.Labels = req.GetLbRouteExtension().GetLabels()
147+
case "description":
148+
obj.Description = req.GetLbRouteExtension().GetDescription()
149+
case "name":
150+
if req.GetLbRouteExtension().GetName() != obj.GetName() {
151+
return nil, status.Errorf(codes.InvalidArgument, "field name is immutable")
152+
}
153+
case "forwardingRules", "forwarding_rules":
154+
obj.ForwardingRules = req.GetLbRouteExtension().GetForwardingRules()
155+
case "extensionChains", "extension_chains":
156+
obj.ExtensionChains = req.GetLbRouteExtension().GetExtensionChains()
157+
case "loadBalancingScheme", "load_balancing_scheme":
158+
if req.GetLbRouteExtension().GetLoadBalancingScheme() != obj.GetLoadBalancingScheme() {
159+
return nil, status.Errorf(codes.InvalidArgument, "field load_balancing_scheme is immutable")
160+
}
161+
case "metadata":
162+
obj.Metadata = req.GetLbRouteExtension().GetMetadata()
163+
default:
164+
return nil, status.Errorf(codes.InvalidArgument, "update_mask path %q not valid", path)
165+
}
166+
}
167+
obj.UpdateTime = timestamppb.New(now)
168+
}
169+
170+
if err := s.normalizeLbRouteExtension(ctx, obj); err != nil {
171+
return nil, err
172+
}
173+
174+
if err := s.storage.Update(ctx, fqn, obj); err != nil {
175+
return nil, err
176+
}
177+
178+
lroPrefix := fmt.Sprintf("projects/%s/locations/%s", name.Project.ID, name.Location)
179+
lroMetadata := &pb.OperationMetadata{
180+
CreateTime: timestamppb.New(now),
181+
Target: name.String(),
182+
Verb: "update",
183+
ApiVersion: "v1",
184+
RequestedCancellation: false,
185+
}
186+
return s.operations.StartLRO(ctx, lroPrefix, lroMetadata, func() (proto.Message, error) {
187+
lroMetadata.EndTime = timestamppb.New(time.Now())
188+
return obj, nil
189+
})
190+
}
191+
192+
func (s *NetworkServicesServer) DeleteLbRouteExtension(ctx context.Context, req *pb.DeleteLbRouteExtensionRequest) (*longrunningpb.Operation, error) {
193+
name, err := s.parseLbRouteExtensionName(req.Name)
194+
if err != nil {
195+
return nil, err
196+
}
197+
198+
fqn := name.String()
199+
200+
deleted := &pb.LbRouteExtension{}
201+
if err := s.storage.Delete(ctx, fqn, deleted); err != nil {
202+
return nil, err
203+
}
204+
205+
now := time.Now()
206+
lroMetadata := &pb.OperationMetadata{
207+
CreateTime: timestamppb.New(now),
208+
Target: name.String(),
209+
Verb: "delete",
210+
ApiVersion: "v1",
211+
RequestedCancellation: false,
212+
}
213+
lroPrefix := fmt.Sprintf("projects/%s/locations/%s", name.Project.ID, name.Location)
214+
return s.operations.StartLRO(ctx, lroPrefix, lroMetadata, func() (proto.Message, error) {
215+
lroMetadata.EndTime = timestamppb.New(time.Now())
216+
result := &emptypb.Empty{}
217+
return result, nil
218+
})
219+
}
220+
221+
type lbRouteExtensionParent struct {
222+
Project *projects.ProjectData
223+
Location string
224+
}
225+
226+
func (p *lbRouteExtensionParent) String() string {
227+
return "projects/" + p.Project.ID + "/locations/" + p.Location
228+
}
229+
230+
func (s *NetworkServicesServer) parseLbRouteExtensionParent(parent string) (*lbRouteExtensionParent, error) {
231+
tokens := strings.Split(parent, "/")
232+
233+
if len(tokens) == 4 && tokens[0] == "projects" && tokens[2] == "locations" {
234+
projectName, err := projects.ParseProjectName(tokens[0] + "/" + tokens[1])
235+
if err != nil {
236+
return nil, err
237+
}
238+
project, err := s.Projects.GetProject(projectName)
239+
if err != nil {
240+
return nil, err
241+
}
242+
243+
return &lbRouteExtensionParent{
244+
Project: project,
245+
Location: tokens[3],
246+
}, nil
247+
} else {
248+
return nil, status.Errorf(codes.InvalidArgument, "parent %q is not valid", parent)
249+
}
250+
}
251+
252+
type lbRouteExtensionName struct {
253+
Project *projects.ProjectData
254+
Location string
255+
LbRouteExtensionName string
256+
}
257+
258+
func (n *lbRouteExtensionName) String() string {
259+
return "projects/" + n.Project.ID + "/locations/" + n.Location + "/lbRouteExtensions/" + n.LbRouteExtensionName
260+
}
261+
262+
func (s *NetworkServicesServer) parseLbRouteExtensionName(name string) (*lbRouteExtensionName, error) {
263+
tokens := strings.Split(name, "/")
264+
265+
if len(tokens) == 6 && tokens[0] == "projects" && tokens[2] == "locations" && tokens[4] == "lbRouteExtensions" {
266+
projectName, err := projects.ParseProjectName(tokens[0] + "/" + tokens[1])
267+
if err != nil {
268+
return nil, err
269+
}
270+
project, err := s.Projects.GetProject(projectName)
271+
if err != nil {
272+
return nil, err
273+
}
274+
275+
name := &lbRouteExtensionName{
276+
Project: project,
277+
Location: tokens[3],
278+
LbRouteExtensionName: tokens[5],
279+
}
280+
281+
return name, nil
282+
} else {
283+
return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid", name)
284+
}
285+
}
286+
287+
func (s *NetworkServicesServer) normalizeLbRouteExtension(ctx context.Context, obj *pb.LbRouteExtension) error {
288+
for i, rule := range obj.ForwardingRules {
289+
newRule, err := s.replaceProjectIDWithNumberInURL(ctx, rule)
290+
if err != nil {
291+
return err
292+
}
293+
obj.ForwardingRules[i] = newRule
294+
}
295+
for _, chain := range obj.ExtensionChains {
296+
for _, extension := range chain.Extensions {
297+
newService, err := s.replaceProjectIDWithNumberInURL(ctx, extension.Service)
298+
if err != nil {
299+
return err
300+
}
301+
extension.Service = newService
302+
}
303+
}
304+
return nil
305+
}
306+
307+
func (s *NetworkServicesServer) replaceProjectIDWithNumberInURL(ctx context.Context, url string) (string, error) {
308+
if !strings.HasPrefix(url, "https://www.googleapis.com/compute/v1/projects/") &&
309+
!strings.HasPrefix(url, "https://compute.googleapis.com/compute/v1/projects/") {
310+
return url, nil
311+
}
312+
313+
// Format is https://[hostname]/compute/v1/projects/[projectID]/...
314+
tokens := strings.Split(url, "/")
315+
if len(tokens) < 7 {
316+
return url, nil
317+
}
318+
319+
projectIDOrNumber := tokens[6]
320+
project, err := s.Projects.GetProjectByIDOrNumber(projectIDOrNumber)
321+
if err != nil {
322+
return url, nil // Should we return error or just return original URL? Returning original for now.
323+
}
324+
325+
tokens[6] = strconv.FormatInt(project.Number, 10)
326+
return strings.Join(tokens, "/"), nil
327+
}

mockgcp/mocknetworkservices/mesh.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ import (
2727
pb "cloud.google.com/go/networkservices/apiv1/networkservicespb"
2828
)
2929

30-
type NetworkServicesServer struct {
31-
*MockService
32-
pb.UnimplementedNetworkServicesServer
33-
}
34-
3530
func (s *NetworkServicesServer) ListMeshes(ctx context.Context, req *pb.ListMeshesRequest) (*pb.ListMeshesResponse, error) {
3631
return nil, status.Errorf(codes.Unimplemented, "method ListMeshes not implemented")
3732
}

0 commit comments

Comments
 (0)