Skip to content

Commit f1ff44c

Browse files
authored
Greenfield: Implement direct controller, E2E fixtures, and fuzzer for MigrationCenterGroup (#11093)
This PR implements the direct controller, fuzzer, and minimal/maximal E2E fixtures for the greenfield resource `MigrationCenterGroup` under the `migrationcenter.cnrm.cloud.google.com/v1alpha1` API version. ### Summary of Changes: 1. **Direct Controller**: Implemented the core reconciliation logic (Adapter: Find, Create, Update, Delete, Export) in `pkg/controller/direct/migrationcenter/migrationcentergroup_controller.go`. 2. **Mapper Support**: Generated the `mapper.generated.go` by updating `apis/migrationcenter/v1alpha1/generate.sh` to include `generate-mapper` and standard `goimports` formatting. 3. **Fuzzer Support**: Implemented `migrationcentergroup_fuzzer.go` and registered it with `fuzztesting.RegisterKRMFuzzer`. 4. **Registration**: Registered the newly introduced direct controller in `pkg/controller/resourceconfig/static_config.go` and `pkg/controller/direct/register/register.go`. 5. **E2E Fixtures**: Added pre-recorded minimal and maximal E2E fixtures with corresponding golden logs (`_http.log` and `_generated_object_*.golden.yaml`). 6. **Journal Entry**: Logged findings regarding the service name and proto message mappings in `.gemini/journals/migrationcenter.md`. Fixes #11086 --- This PR was generated by the **overseer** agent (powered by the gemini-3.5-flash model).
2 parents c4e68c3 + b57e4d5 commit f1ff44c

19 files changed

Lines changed: 1025 additions & 10 deletions

File tree

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
### [2026-06-29] MigrationCenterGroup Types, CRD, and IdentityV2 Scaffolding
2-
- **Context**: Implementing initial types, CRD, and IdentityV2 for `MigrationCenterGroup` under `apis/migrationcenter/v1alpha1/`.
3-
- **Problem**: The Migration Center API/resource is missing from `cloudassetinventory_names.jsonl` (not supported by CAIS yet), which causes CAIS URL template matches validation `TestRegisteredTemplatesMatchCAI` in `pkg/gcpurls/registry_test.go` to fail on missing registered templates.
4-
- **Solution**: Added the URL template `//migrationcenter.googleapis.com/projects/{}/locations/{}/groups/{}` as an exception under `ignoredTemplates` in `pkg/gcpurls/registry_test.go`.
5-
- **Impact**: Ensures that `gcpurls` verification tests compile and pass smoothly without requiring support in CAIS metadata yet.
1+
# Migration Center Journal
2+
3+
### 2026-07-01 Greenfield controller for MigrationCenterGroup
4+
- **Context**: Implementing Greenfield direct controller and E2E fixtures for `MigrationCenterGroup` under `v1alpha1`.
5+
- **Problem**: Migration Center is a newly introduced service with no existing mappers, controllers, or MockGCP implementation.
6+
- **Solution**: Scaffolded a brand new direct controller in `pkg/controller/direct/migrationcenter/`, generated mappers by updating the `generate.sh` script to include `--generate-mapper` and `goimports` formatting, and registered it in the dynamic static config mapping and `register.go`. Created both minimal and maximal KRM E2E test fixtures, then enabled the `migrationcenter.googleapis.com` API on the real GCP sandbox project to record golden objects and HTTP traffic.
7+
- **Impact**: Demonstrates standard Greenfield controller patterns with LRO creation/update/deletion, structured reporting, and full E2E validation against real GCP.

apis/migrationcenter/v1alpha1/generate.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,13 @@ go run . generate-types \
2929
--include-skipped-output \
3030
--resource MigrationCenterGroup:Group
3131

32+
go run . generate-mapper \
33+
--service google.cloud.migrationcenter.v1 \
34+
--api-version migrationcenter.cnrm.cloud.google.com/v1alpha1 \
35+
--include-skipped-output
36+
3237
cd ${REPO_ROOT}
3338
dev/tasks/generate-crds
39+
40+
go run -mod=readonly golang.org/x/tools/cmd/goimports@${GOLANG_X_TOOLS_VERSION} -w pkg/controller/direct/migrationcenter/
41+

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ require (
5959
cloud.google.com/go/managedkafka v0.8.1
6060
cloud.google.com/go/memorystore v0.9.0
6161
cloud.google.com/go/metastore v1.19.0
62+
cloud.google.com/go/migrationcenter v1.6.0
6263
cloud.google.com/go/monitoring v1.29.0
6364
cloud.google.com/go/netapp v1.12.0
6465
cloud.google.com/go/networkmanagement v1.28.0

go.sum

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/controller/direct/migrationcenter/mapper.generated.go

Lines changed: 73 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright 2026 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+
package migrationcenter
16+
17+
import (
18+
"context"
19+
"fmt"
20+
21+
gcp "cloud.google.com/go/migrationcenter/apiv1"
22+
pb "cloud.google.com/go/migrationcenter/apiv1/migrationcenterpb"
23+
krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/migrationcenter/v1alpha1"
24+
refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
25+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/config"
26+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct"
27+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/directbase"
28+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/registry"
29+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/tags"
30+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/mappers"
31+
"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/structuredreporting"
32+
"google.golang.org/api/option"
33+
"google.golang.org/protobuf/proto"
34+
35+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
36+
"k8s.io/apimachinery/pkg/runtime"
37+
"k8s.io/klog/v2"
38+
)
39+
40+
func init() {
41+
registry.RegisterModel(krm.MigrationCenterGroupGVK, NewModel)
42+
}
43+
44+
func NewModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
45+
return &model{config: *config}, nil
46+
}
47+
48+
var _ directbase.Model = &model{}
49+
50+
type model struct {
51+
config config.ControllerConfig
52+
}
53+
54+
func (m *model) client(ctx context.Context) (*gcp.Client, error) {
55+
var opts []option.ClientOption
56+
opts, err := m.config.RESTClientOptions()
57+
if err != nil {
58+
return nil, err
59+
}
60+
gcpClient, err := gcp.NewRESTClient(ctx, opts...)
61+
if err != nil {
62+
return nil, fmt.Errorf("building migrationcenter client: %w", err)
63+
}
64+
return gcpClient, nil
65+
}
66+
67+
func (m *model) AdapterForObject(ctx context.Context, op *directbase.AdapterForObjectOperation) (directbase.Adapter, error) {
68+
u := op.GetUnstructured()
69+
reader := op.Reader
70+
obj := &krm.MigrationCenterGroup{}
71+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
72+
return nil, fmt.Errorf("error converting to %T: %w", obj, err)
73+
}
74+
identity, err := obj.GetIdentity(ctx, reader)
75+
if err != nil {
76+
return nil, err
77+
}
78+
groupIdentity := identity.(*krm.MigrationCenterGroupIdentity)
79+
gcpClient, err := m.client(ctx)
80+
if err != nil {
81+
return nil, err
82+
}
83+
return &Adapter{
84+
id: groupIdentity,
85+
gcpClient: gcpClient,
86+
desired: obj,
87+
}, nil
88+
}
89+
90+
func (m *model) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
91+
return nil, nil
92+
}
93+
94+
type Adapter struct {
95+
id *krm.MigrationCenterGroupIdentity
96+
gcpClient *gcp.Client
97+
desired *krm.MigrationCenterGroup
98+
actual *pb.Group
99+
}
100+
101+
var _ directbase.Adapter = &Adapter{}
102+
103+
func (a *Adapter) Find(ctx context.Context) (bool, error) {
104+
log := klog.FromContext(ctx)
105+
106+
if a.id.Group == "" { // resource is not yet created
107+
return false, nil
108+
}
109+
fqn := a.id.String()
110+
log.V(2).Info("getting MigrationCenterGroup", "name", fqn)
111+
112+
req := &pb.GetGroupRequest{Name: fqn}
113+
grouppb, err := a.gcpClient.GetGroup(ctx, req)
114+
if err != nil {
115+
if direct.IsNotFound(err) {
116+
return false, nil
117+
}
118+
return false, fmt.Errorf("getting MigrationCenterGroup %q: %w", fqn, err)
119+
}
120+
121+
a.actual = grouppb
122+
return true, nil
123+
}
124+
125+
func (a *Adapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
126+
fqn := a.id.String()
127+
log := klog.FromContext(ctx)
128+
log.V(2).Info("creating MigrationCenterGroup", "name", fqn)
129+
130+
mapCtx := &direct.MapContext{}
131+
desired := a.desired.DeepCopy()
132+
resource := MigrationCenterGroupSpec_ToProto(mapCtx, &desired.Spec)
133+
if mapCtx.Err() != nil {
134+
return mapCtx.Err()
135+
}
136+
137+
parent := a.id.ParentString()
138+
req := &pb.CreateGroupRequest{
139+
Parent: parent,
140+
GroupId: a.id.Group,
141+
Group: resource,
142+
}
143+
op, err := a.gcpClient.CreateGroup(ctx, req)
144+
if err != nil {
145+
return fmt.Errorf("creating Group %s: %w", fqn, err)
146+
}
147+
created, err := op.Wait(ctx)
148+
if err != nil {
149+
return fmt.Errorf("waiting Group %s creation: %w", fqn, err)
150+
}
151+
log.V(2).Info("successfully created Group", "name", created.Name)
152+
153+
return a.updateStatus(ctx, createOp, created)
154+
}
155+
156+
func (a *Adapter) Update(ctx context.Context, updateOp *directbase.UpdateOperation) error {
157+
u := updateOp.GetUnstructured()
158+
159+
fqn := a.id.String()
160+
log := klog.FromContext(ctx)
161+
log.V(2).Info("updating MigrationCenterGroup", "name", fqn)
162+
163+
mapCtx := &direct.MapContext{}
164+
desiredPb := MigrationCenterGroupSpec_ToProto(mapCtx, &a.desired.Spec)
165+
if mapCtx.Err() != nil {
166+
return mapCtx.Err()
167+
}
168+
169+
maskedActual, err := mappers.OnlySpecFields(a.actual, MigrationCenterGroupSpec_FromProto, MigrationCenterGroupSpec_ToProto)
170+
if err != nil {
171+
return err
172+
}
173+
174+
clonedDesired := proto.Clone(desiredPb).(*pb.Group)
175+
176+
diffs, updateMask, err := tags.DiffForTopLevelFields(ctx, clonedDesired.ProtoReflect(), maskedActual.ProtoReflect())
177+
if err != nil {
178+
return err
179+
}
180+
181+
if !diffs.HasDiff() {
182+
log.V(2).Info("no field needs update", "name", fqn)
183+
return nil
184+
}
185+
186+
diffs.Object = u
187+
structuredreporting.ReportDiff(ctx, diffs)
188+
189+
desiredPb.Name = a.actual.Name
190+
req := &pb.UpdateGroupRequest{
191+
Group: desiredPb,
192+
UpdateMask: updateMask,
193+
}
194+
op, err := a.gcpClient.UpdateGroup(ctx, req)
195+
if err != nil {
196+
return fmt.Errorf("updating Group %s: %w", fqn, err)
197+
}
198+
updated, err := op.Wait(ctx)
199+
if err != nil {
200+
return fmt.Errorf("waiting Group %s update: %w", fqn, err)
201+
}
202+
log.V(2).Info("successfully updated Group", "name", fqn)
203+
204+
return a.updateStatus(ctx, updateOp, updated)
205+
}
206+
207+
func (a *Adapter) updateStatus(ctx context.Context, op directbase.Operation, latest *pb.Group) error {
208+
mapCtx := &direct.MapContext{}
209+
status := &krm.MigrationCenterGroupStatus{}
210+
status.ObservedState = MigrationCenterGroupObservedState_FromProto(mapCtx, latest)
211+
if mapCtx.Err() != nil {
212+
return mapCtx.Err()
213+
}
214+
status.ExternalRef = &latest.Name
215+
return op.UpdateStatus(ctx, status, nil)
216+
}
217+
218+
func (a *Adapter) Export(ctx context.Context) (*unstructured.Unstructured, error) {
219+
if a.actual == nil {
220+
return nil, fmt.Errorf("Find() not called")
221+
}
222+
u := &unstructured.Unstructured{}
223+
224+
obj := &krm.MigrationCenterGroup{}
225+
mapCtx := &direct.MapContext{}
226+
obj.Spec = direct.ValueOf(MigrationCenterGroupSpec_FromProto(mapCtx, a.actual))
227+
obj.Spec.ResourceID = &a.id.Group
228+
if mapCtx.Err() != nil {
229+
return nil, mapCtx.Err()
230+
}
231+
obj.Spec.ProjectRef = &refs.ProjectRef{Name: a.id.Project}
232+
obj.Spec.Location = a.id.Location
233+
uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
234+
if err != nil {
235+
return nil, err
236+
}
237+
u.Object = uObj
238+
u.SetName(a.id.Group)
239+
u.SetGroupVersionKind(krm.MigrationCenterGroupGVK)
240+
return u, nil
241+
}
242+
243+
// Delete implements the Adapter interface.
244+
func (a *Adapter) Delete(ctx context.Context, deleteOp *directbase.DeleteOperation) (bool, error) {
245+
log := klog.FromContext(ctx)
246+
fqn := a.id.String()
247+
log.V(2).Info("deleting Group", "name", fqn)
248+
249+
req := &pb.DeleteGroupRequest{Name: fqn}
250+
op, err := a.gcpClient.DeleteGroup(ctx, req)
251+
if err != nil {
252+
if direct.IsNotFound(err) {
253+
return true, nil
254+
}
255+
return false, fmt.Errorf("deleting Group %s: %w", fqn, err)
256+
}
257+
err = op.Wait(ctx)
258+
if err != nil {
259+
return false, fmt.Errorf("waiting Group %s deletion: %w", fqn, err)
260+
}
261+
log.V(2).Info("successfully deleted Group", "name", fqn)
262+
return true, nil
263+
}

0 commit comments

Comments
 (0)