Repository: GoogleCloudPlatform/k8s-config-connector
Pull request #4314: Direct Controller for asset-feed
Author: login: barney-s
Labels: name: lgtm
name: approved
name: review
Created: 2025-04-09T06:24:12Z | Merged: 2025-04-11T02:32:05Z



Diff:
diff --git a/apis/asset/v1alpha1/feed_types.go b/apis/asset/v1alpha1/feed_types.go
index 1534e033805..1905e4d0dd5 100644
--- a/apis/asset/v1alpha1/feed_types.go
+++ b/apis/asset/v1alpha1/feed_types.go
@@ -29,10 +29,17 @@ type AssetFeedParent struct {
 	FolderRef       *refv1beta1.FolderRef       `json:"folderRef,omitempty"`
 }
 
+// +kcc:proto=google.cloud.asset.v1.PubsubDestination
+type PubsubDestination struct {
+	// The name of the Pub/Sub topic to publish to.
+	//  Example: `projects/PROJECT_ID/topics/TOPIC_ID`.
+	// +kcc:proto:field=google.cloud.asset.v1.PubsubDestination.topic
+	TopicRef *refv1beta1.PubSubTopicRef `json:"topicRef,omitempty"`
+}
+
 // AssetFeedSpec defines the desired state of AssetFeed
 // +kcc:proto=google.cloud.asset.v1.Feed
 type AssetFeedSpec struct {
-	// +required
 	Parent AssetFeedParent `json:",inline"`
 
 	// The AssetFeed name. If not given, the metadata.name will be used.
diff --git a/apis/asset/v1alpha1/types.generated.go b/apis/asset/v1alpha1/types.generated.go
index a6c5ced542f..a5851f80bb0 100644
--- a/apis/asset/v1alpha1/types.generated.go
+++ b/apis/asset/v1alpha1/types.generated.go
@@ -162,14 +162,6 @@ type FeedOutputConfig struct {
 	PubsubDestination *PubsubDestination `json:"pubsubDestination,omitempty"`
 }
 
-// +kcc:proto=google.cloud.asset.v1.PubsubDestination
-type PubsubDestination struct {
-	// The name of the Pub/Sub topic to publish to.
-	//  Example: `projects/PROJECT_ID/topics/TOPIC_ID`.
-	// +kcc:proto:field=google.cloud.asset.v1.PubsubDestination.topic
-	Topic *string `json:"topic,omitempty"`
-}
-
 // +kcc:proto=google.type.Expr
 type Expr struct {
 	// Textual representation of an expression in Common Expression Language
diff --git a/apis/asset/v1alpha1/zz_generated.deepcopy.go b/apis/asset/v1alpha1/zz_generated.deepcopy.go
index 5b10c6bc00f..400fbe77c98 100644
--- a/apis/asset/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/asset/v1alpha1/zz_generated.deepcopy.go
@@ -674,9 +674,9 @@ func (in *Parent) DeepCopy() *Parent {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PubsubDestination) DeepCopyInto(out *PubsubDestination) {
 	*out = *in
-	if in.Topic != nil {
-		in, out := &in.Topic, &out.Topic
-		*out = new(string)
+	if in.TopicRef != nil {
+		in, out := &in.TopicRef, &out.TopicRef
+		*out = new(v1beta1.PubSubTopicRef)
 		**out = **in
 	}
 }
diff --git a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_assetfeeds.asset.cnrm.cloud.google.com.yaml b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_assetfeeds.asset.cnrm.cloud.google.com.yaml
index d974521102d..637db4547ec 100644
--- a/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_assetfeeds.asset.cnrm.cloud.google.com.yaml
+++ b/config/crds/resources/apiextensions.k8s.io_v1_customresourcedefinition_assetfeeds.asset.cnrm.cloud.google.com.yaml
@@ -127,10 +127,36 @@ spec:
                   pubsubDestination:
                     description: Destination on Pub/Sub.
                     properties:
-                      topic:
+                      topicRef:
                         description: 'The name of the Pub/Sub topic to publish to.
                           Example: `projects/PROJECT_ID/topics/TOPIC_ID`.'
-                        type: string
+                        oneOf:
+                        - not:
+                            required:
+                            - external
+                          required:
+                          - name
+                        - not:
+                            anyOf:
+                            - required:
+                              - name
+                            - required:
+                              - namespace
+                          required:
+                          - external
+                        properties:
+                          external:
+                            description: If provided must be in the format `projects/[project_id]/topics/[topic_id]`.
+                            type: string
+                          name:
+                            description: The `metadata.name` field of a `PubSubTopic`
+                              resource.
+                            type: string
+                          namespace:
+                            description: The `metadata.namespace` field of a `PubSubTopic`
+                              resource.
+                            type: string
+                        type: object
                     type: object
                 type: object
               folderRef:
@@ -231,7 +257,6 @@ spec:
                   be used.
                 type: string
             required:
-            - ""
             - feedOutputConfig
             type: object
           status:
diff --git a/config/tests/samples/create/harness.go b/config/tests/samples/create/harness.go
index 26786b1a52d..95ed1a00e75 100644
--- a/config/tests/samples/create/harness.go
+++ b/config/tests/samples/create/harness.go
@@ -760,6 +760,8 @@ func MaybeSkip(t *testing.T, name string, resources []*unstructured.Unstructured
 
 			case schema.GroupKind{Group: "artifactregistry.cnrm.cloud.google.com", Kind: "ArtifactRegistryRepository"}:
 
+			case schema.GroupKind{Group: "asset.cnrm.cloud.google.com", Kind: "AssetFeed"}:
+
 			case schema.GroupKind{Group: "backupdr.cnrm.cloud.google.com", Kind: "BackupDRBackupPlan"}:
 			case schema.GroupKind{Group: "backupdr.cnrm.cloud.google.com", Kind: "BackupDRBackupVault"}:
 			case schema.GroupKind{Group: "backupdr.cnrm.cloud.google.com", Kind: "BackupDRManagementServer"}:
diff --git a/mockgcp/mockasset/feed.go b/mockgcp/mockasset/feed.go
index db44ef54149..9385277bb6e 100755
--- a/mockgcp/mockasset/feed.go
+++ b/mockgcp/mockasset/feed.go
@@ -26,6 +26,7 @@ import (
 
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/emptypb"
 
 	pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/cloud/asset/v1"
@@ -33,12 +34,14 @@ import (
 
 func (s *AssetService) GetFeed(ctx context.Context, req *pb.GetFeedRequest) (*pb.Feed, error) {
 	obj := &pb.Feed{}
-	if err := s.storage.Get(ctx, req.Name, obj); err != nil {
+	name, err := s.parseFeedName(req.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := s.storage.Get(ctx, name.String(), obj); err != nil {
 		if status.Code(err) == codes.NotFound {
-			parts := strings.Split(req.Name, "/")
-			feedID := parts[len(parts)-1]
-			parent := strings.Join(parts[:len(parts)-2], "/")
-			return nil, status.Errorf(codes.NotFound, "feed %q not found in %q", feedID, parent)
+			return nil, status.Errorf(codes.NotFound, "Requested entity was not found.")
 		}
 		return nil, err
 	}
@@ -47,17 +50,16 @@ func (s *AssetService) GetFeed(ctx context.Context, req *pb.GetFeedRequest) (*pb
 }
 
 func (s *AssetService) CreateFeed(ctx context.Context, req *pb.CreateFeedRequest) (*pb.Feed, error) {
-	_, projectNumber, err := s.parseParent(req.Parent)
+	name, err := s.parseFeedName(req.Parent + "/feeds/" + req.FeedId)
 	if err != nil {
 		return nil, err
 	}
+	fqn := name.String()
 
-	feedName := fmt.Sprintf("projects/%s/feeds/%s", projectNumber, req.FeedId)
-
-	feed := req.Feed
-	feed.Name = feedName
+	feed := proto.Clone(req.Feed).(*pb.Feed)
+	feed.Name = fqn
 
-	if err := s.storage.Create(ctx, feedName, feed); err != nil {
+	if err := s.storage.Create(ctx, fqn, feed); err != nil {
 		return nil, err
 	}
 
@@ -65,8 +67,14 @@ func (s *AssetService) CreateFeed(ctx context.Context, req *pb.CreateFeedRequest
 }
 
 func (s *AssetService) UpdateFeed(ctx context.Context, req *pb.UpdateFeedRequest) (*pb.Feed, error) {
+	name, err := s.parseFeedName(req.Feed.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	fqn := name.String()
 	obj := &pb.Feed{}
-	if err := s.storage.Get(ctx, req.Feed.Name, obj); err != nil {
+	if err := s.storage.Get(ctx, fqn, obj); err != nil {
 		return nil, err
 	}
 
@@ -93,15 +101,20 @@ func (s *AssetService) UpdateFeed(ctx context.Context, req *pb.UpdateFeedRequest
 		}
 	}
 
-	if err := s.storage.Update(ctx, req.Feed.Name, obj); err != nil {
+	if err := s.storage.Update(ctx, fqn, obj); err != nil {
 		return nil, err
 	}
 	return obj, nil
 }
 
 func (s *AssetService) DeleteFeed(ctx context.Context, req *pb.DeleteFeedRequest) (*emptypb.Empty, error) {
+	name, err := s.parseFeedName(req.Name)
+	if err != nil {
+		return nil, err
+	}
+
 	deletedObj := &pb.Feed{}
-	if err := s.storage.Delete(ctx, req.Name, deletedObj); err != nil {
+	if err := s.storage.Delete(ctx, name.String(), deletedObj); err != nil {
 		if status.Code(err) == codes.NotFound {
 			return nil, status.Errorf(codes.NotFound, "feed %q not found", req.Name)
 		}
@@ -111,28 +124,47 @@ func (s *AssetService) DeleteFeed(ctx context.Context, req *pb.DeleteFeedRequest
 }
 
 type feedName struct {
-	parent string
-	feedID string
+	projectNumber  int64
+	folderID       string
+	organizationID string
+	feedID         string
 }
 
 func (n *feedName) String() string {
-	return fmt.Sprintf("%s/feeds/%s", n.parent, n.feedID)
+	if n.organizationID != "" {
+		return fmt.Sprintf("organizations/%s/feeds/%s", n.organizationID, n.feedID)
+	}
+	if n.folderID != "" {
+		return fmt.Sprintf("folders/%s/feeds/%s", n.folderID, n.feedID)
+	}
+	return fmt.Sprintf("projects/%d/feeds/%s", n.projectNumber, n.feedID)
 }
 
 // parseFeedName parses a string into an feedName.
 func (s *MockService) parseFeedName(name string) (*feedName, error) {
 	tokens := strings.Split(name, "/")
 
-	if len(tokens) == 4 && tokens[0] != "" && tokens[2] == "feeds" {
-		name := &feedName{
-			parent: strings.Join(tokens[0:2], "/"),
-			feedID: tokens[3],
-		}
-
-		return name, nil
+	if len(tokens) != 4 || tokens[2] != "feeds" {
+		return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid", name)
 	}
 
-	return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid", name)
+	feedName := &feedName{}
+	feedName.feedID = tokens[3]
+	switch tokens[0] {
+	case "projects":
+		project, err := s.Projects.GetProjectByIDOrNumber(tokens[1])
+		if err != nil {
+			return nil, err
+		}
+		feedName.projectNumber = project.Number
+	case "folders":
+		feedName.folderID = tokens[1]
+	case "organizations":
+		feedName.organizationID = tokens[1]
+	default:
+		return nil, status.Errorf(codes.InvalidArgument, "name %q is not valid", name)
+	}
+	return feedName, nil
 }
 
 var parentRegex = regexp.MustCompile(`^projects/([^/]+)$`)
diff --git a/pkg/controller/direct/asset/client.go b/pkg/controller/direct/asset/client.go
new file mode 100644
index 00000000000..7534633efc0
--- /dev/null
+++ b/pkg/controller/direct/asset/client.go
@@ -0,0 +1,49 @@
+// 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-client
+// proto.service: google.cloud.asset.v1.AssetService
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	api "cloud.google.com/go/asset/apiv1"
+	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/config"
+)
+
+type gcpClient struct {
+	config *config.ControllerConfig
+}
+
+func newGCPClient(ctx context.Context, config *config.ControllerConfig) (*gcpClient, error) {
+	gcpClient := &gcpClient{
+		config: config,
+	}
+	return gcpClient, nil
+}
+
+func (m *gcpClient) newAssetClient(ctx context.Context) (*api.Client, error) {
+	opts, err := m.config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+	client, err := api.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset client: %w", err)
+	}
+	return client, err
+}
diff --git a/pkg/controller/direct/asset/feed_controller.go b/pkg/controller/direct/asset/feed_controller.go
new file mode 100644
index 00000000000..0b3498008d9
--- /dev/null
+++ b/pkg/controller/direct/asset/feed_controller.go
@@ -0,0 +1,325 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.V(2).Info("getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.V(2).Info("creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.V(2).Info("successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
+		// Resource already exists. Log and attempt to Find to populate a.actual.
+		log.V(2).Info("asset feed already exists during creation attempt, attempting to fetch it", "name", a.id.String(), "warning", err)
+		found, findErr = a.Find(ctx)
+		if findErr != nil {
+			return fmt.Errorf("fetching existing asset feed %q after CreateFeed returned AlreadyExists failed: %w", a.id.String(), findErr)
+		}
+		if !found {
+			// This is unexpected if CreateFeed returned AlreadyExists
+			return fmt.Errorf("asset feed %q not found even though CreateFeed returned AlreadyExists", a.id.String())
+		}
+	} else {
+		// Other error during creation
+		return fmt.Errorf("creating asset feed %s: %w", a.id.String(), err)
+	}
+
+	// Update status
+	status := &krm.AssetFeedStatus{}
+	status.ExternalRef = direct.LazyPtr(a.actual.Name)
+	// Pass the fetched 'a.actual' to UpdateStatus for potential status mapping
+	return createOp.UpdateStatus(ctx, status, nil)
+}
+
+func (a *feedAdapter) Update(ctx context.Context, updateOp *directbase.UpdateOperation) error {
+	log := klog.FromContext(ctx)
+	log.V(2).Info("updating asset feed", "name", a.id, "actual", a.actual.Name)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+	resource.Name = a.id.String() // Name must be set for update
+
+	paths, err := common.CompareProtoMessage(resource, a.actual, common.BasicDiff)
+	if err != nil {
+		return fmt.Errorf("comparing proto messages: %w", err)
+	}
+
+	if len(paths) == 0 {
+		log.V(2).Info("no field needs update", "name", a.id)
+		// Update status even if no fields changed in spec
+		status := &krm.AssetFeedStatus{}
+
+		if mapCtx.Err() != nil {
+			return mapCtx.Err()
+		}
+		return updateOp.UpdateStatus(ctx, status, nil)
+	}
+
+	log.V(2).Info("updating asset feed fields", "paths", sets.List(paths))
+
+	updateMask := &fieldmaskpb.FieldMask{Paths: sets.List(paths)}
+	req := &pb.UpdateFeedRequest{
+		Feed:       resource,
+		UpdateMask: updateMask,
+	}
+	_, err = a.gcpClient.UpdateFeed(ctx, req)
+	if err != nil {
+		return fmt.Errorf("updating asset feed %s: %w", a.id.String(), err)
+	}
+	log.V(2).Info("successfully updated asset feed", "name", a.id)
+
+	status := &krm.AssetFeedStatus{}
+
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+	return updateOp.UpdateStatus(ctx, status, nil)
+}
+
+func (a *feedAdapter) Export(ctx context.Context) (*unstructured.Unstructured, error) {
+	if a.actual == nil {
+		return nil, fmt.Errorf("Find() not called")
+	}
+	u := &unstructured.Unstructured{}
+
+	obj := &krm.AssetFeed{}
+	mapCtx := &direct.MapContext{}
+	obj.Spec = direct.ValueOf(AssetFeedSpec_FromProto(mapCtx, a.actual))
+	if mapCtx.Err() != nil {
+		return nil, mapCtx.Err()
+	}
+
+	parentRef, id, err := krm.ParseFeedExternal(a.actual.Name)
+	if err != nil {
+		return nil, fmt.Errorf("parsing parent from name %q: %w", a.actual.Name, err)
+	}
+	if parentRef.ProjectID != "" {
+		obj.Spec.Parent.ProjectRef = &refs.ProjectRef{External: parentRef.String()}
+	} else if parentRef.FolderID != "" {
+		obj.Spec.Parent.FolderRef = &refs.FolderRef{External: parentRef.String()}
+	} else if parentRef.OrganizationID != "" {
+		obj.Spec.Parent.OrganizationRef = &refs.OrganizationRef{External: parentRef.String()}
+	} else {
+		return nil, fmt.Errorf("unknown parent type in name %q", a.actual.Name)
+	}
+
+	uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+	if err != nil {
+		return nil, err
+	}
+
+	// Initialize u with the converted object map
+	u = &unstructured.Unstructured{Object: uObj} // Assign to existing 'u'
+	// Set GVK and Name, which will modify u.Object directly
+	u.SetGroupVersionKind(krm.AssetFeedGVK)
+	u.SetName(id)
+
+	return u, nil
+}
+
+func (a *feedAdapter) Delete(ctx context.Context, deleteOp *directbase.DeleteOperation) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.V(2).Info(" deleting asset feed", "name", a.id)
+
+	req := &pb.DeleteFeedRequest{Name: a.id.String()}
+	err := a.gcpClient.DeleteFeed(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 asset feed, assuming it was already deleted", "name", a.id)
+			return true, nil
+		}
+		return false, fmt.Errorf("deleting asset feed %s: %w", a.id.String(), err)
+	}
+	log.V(2).Info(" successfully deleted asset feed", "name", a.id)
+
+	return true, nil
+}
diff --git a/pkg/controller/direct/asset/feed_mapper.go b/pkg/controller/direct/asset/feed_mapper.go
index 1d7bb88c8fb..26cd375392b 100644
--- a/pkg/controller/direct/asset/feed_mapper.go
+++ b/pkg/controller/direct/asset/feed_mapper.go
@@ -15,7 +15,9 @@
 package asset
 
 import (
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
 	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refsv1beta1 "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
 	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct"
 	exprpb "google.golang.org/genproto/googleapis/type/expr"
 )
@@ -43,3 +45,20 @@ func Expr_ToProto(mapCtx *direct.MapContext, in *krm.Expr) *exprpb.Expr {
 	out.Location = direct.ValueOf(in.Location)
 	return out
 }
+
+func PubsubDestination_FromProto(mapCtx *direct.MapContext, in *pb.PubsubDestination) *krm.PubsubDestination {
+	if in == nil {
+		return nil
+	}
+	out := &krm.PubsubDestination{}
+	out.TopicRef = &refsv1beta1.PubSubTopicRef{External: in.GetTopic()}
+	return out
+}
+func PubsubDestination_ToProto(mapCtx *direct.MapContext, in *krm.PubsubDestination) *pb.PubsubDestination {
+	if in == nil {
+		return nil
+	}
+	out := &pb.PubsubDestination{}
+	out.Topic = in.TopicRef.External
+	return out
+}
diff --git a/pkg/controller/direct/asset/mapper.generated.go b/pkg/controller/direct/asset/mapper.generated.go
index 5cdab93e5e8..25820b059b6 100644
--- a/pkg/controller/direct/asset/mapper.generated.go
+++ b/pkg/controller/direct/asset/mapper.generated.go
@@ -237,22 +237,6 @@ func IAMPolicyAnalysisQuery_ResourceSelector_ToProto(mapCtx *direct.MapContext,
 	out.FullResourceName = direct.ValueOf(in.FullResourceName)
 	return out
 }
-func PubsubDestination_FromProto(mapCtx *direct.MapContext, in *pb.PubsubDestination) *krm.PubsubDestination {
-	if in == nil {
-		return nil
-	}
-	out := &krm.PubsubDestination{}
-	out.Topic = direct.LazyPtr(in.GetTopic())
-	return out
-}
-func PubsubDestination_ToProto(mapCtx *direct.MapContext, in *krm.PubsubDestination) *pb.PubsubDestination {
-	if in == nil {
-		return nil
-	}
-	out := &pb.PubsubDestination{}
-	out.Topic = direct.ValueOf(in.Topic)
-	return out
-}
 func SavedQuery_QueryContent_FromProto(mapCtx *direct.MapContext, in *pb.SavedQuery_QueryContent) *krm.SavedQuery_QueryContent {
 	if in == nil {
 		return nil
diff --git a/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_generated_object_assetfeed-minimal.golden.yaml b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_generated_object_assetfeed-minimal.golden.yaml
new file mode 100644
index 00000000000..e59ee62214b
--- /dev/null
+++ b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_generated_object_assetfeed-minimal.golden.yaml
@@ -0,0 +1,30 @@
+apiVersion: asset.cnrm.cloud.google.com/v1alpha1
+kind: AssetFeed
+metadata:
+  finalizers:
+  - cnrm.cloud.google.com/finalizer
+  - cnrm.cloud.google.com/deletion-defender
+  generation: 2
+  labels:
+    cnrm-test: "true"
+  name: assetfeed-minimal-${uniqueId}
+  namespace: ${uniqueId}
+spec:
+  assetTypes:
+  - storage.googleapis.com/Bucket
+  - cloudresourcemanager.googleapis.com/Project
+  feedOutputConfig:
+    pubsubDestination:
+      topicRef:
+        name: kcc-assetfeed-minimal-${uniqueId}
+  projectRef:
+    external: ${projectNumber}
+status:
+  conditions:
+  - lastTransitionTime: "1970-01-01T00:00:00Z"
+    message: The resource is up to date
+    reason: UpToDate
+    status: "True"
+    type: Ready
+  externalRef: projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}
+  observedGeneration: 2
diff --git a/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_http.log b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_http.log
new file mode 100644
index 00000000000..f22c3574bca
--- /dev/null
+++ b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/_http.log
@@ -0,0 +1,325 @@
+GET https://pubsub.googleapis.com/v1/projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}?alt=json
+Content-Type: application/json
+User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion}
+
+404 Not Found
+Content-Type: application/json; charset=UTF-8
+Server: ESF
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+X-Content-Type-Options: nosniff
+X-Frame-Options: SAMEORIGIN
+X-Xss-Protection: 0
+
+{
+  "error": {
+    "code": 404,
+    "message": "Resource not found (resource=kcc-assetfeed-minimal-${uniqueId}).",
+    "status": "NOT_FOUND"
+  }
+}
+
+---
+
+PUT https://pubsub.googleapis.com/v1/projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}?alt=json
+Content-Type: application/json
+User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion}
+
+{
+  "labels": {
+    "cnrm-test": "true",
+    "managed-by-cnrm": "true"
+  }
+}
+
+200 OK
+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
+
+{
+  "labels": {
+    "cnrm-test": "true",
+    "managed-by-cnrm": "true"
+  },
+  "name": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+GET https://pubsub.googleapis.com/v1/projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}?alt=json
+Content-Type: application/json
+User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion}
+
+200 OK
+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
+
+{
+  "labels": {
+    "cnrm-test": "true",
+    "managed-by-cnrm": "true"
+  },
+  "name": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+GET https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds/assetfeed-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${projectNumber}%2Ffeeds%2Fassetfeed-minimal-${uniqueId}
+X-Goog-User-Project: ${projectNumber}
+
+404 Not Found
+Content-Type: application/json; charset=UTF-8
+Server: ESF
+Vary: Origin
+Vary: X-Origin
+Vary: Referer
+X-Content-Type-Options: nosniff
+X-Frame-Options: SAMEORIGIN
+X-Xss-Protection: 0
+
+{
+  "error": {
+    "code": 404,
+    "errors": [
+      {
+        "domain": "global",
+        "message": "Requested entity was not found.",
+        "reason": "notFound"
+      }
+    ],
+    "message": "Requested entity was not found.",
+    "status": "NOT_FOUND"
+  }
+}
+
+---
+
+POST https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds?%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: parent=projects%2F${projectNumber}
+X-Goog-User-Project: ${projectNumber}
+
+{
+  "feed": {
+    "assetTypes": [
+      "storage.googleapis.com/Bucket"
+    ],
+    "feedOutputConfig": {
+      "pubsubDestination": {
+        "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+      }
+    },
+    "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+  },
+  "feedId": "assetfeed-minimal-${uniqueId}",
+  "parent": "projects/${projectNumber}"
+}
+
+200 OK
+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
+
+{
+  "assetTypes": [
+    "storage.googleapis.com/Bucket"
+  ],
+  "feedOutputConfig": {
+    "pubsubDestination": {
+      "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+    }
+  },
+  "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+GET https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds/assetfeed-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${projectNumber}%2Ffeeds%2Fassetfeed-minimal-${uniqueId}
+X-Goog-User-Project: ${projectNumber}
+
+200 OK
+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
+
+{
+  "assetTypes": [
+    "storage.googleapis.com/Bucket"
+  ],
+  "feedOutputConfig": {
+    "pubsubDestination": {
+      "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+    }
+  },
+  "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+PATCH https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds/assetfeed-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: feed.name=projects%2F${projectNumber}%2Ffeeds%2Fassetfeed-minimal-${uniqueId}
+X-Goog-User-Project: ${projectNumber}
+
+{
+  "feed": {
+    "assetTypes": [
+      "storage.googleapis.com/Bucket",
+      "cloudresourcemanager.googleapis.com/Project"
+    ],
+    "feedOutputConfig": {
+      "pubsubDestination": {
+        "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+      }
+    },
+    "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+  },
+  "updateMask": "assetTypes"
+}
+
+200 OK
+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
+
+{
+  "assetTypes": [
+    "storage.googleapis.com/Bucket",
+    "cloudresourcemanager.googleapis.com/Project"
+  ],
+  "feedOutputConfig": {
+    "pubsubDestination": {
+      "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+    }
+  },
+  "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+GET https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds/assetfeed-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${projectNumber}%2Ffeeds%2Fassetfeed-minimal-${uniqueId}
+X-Goog-User-Project: ${projectNumber}
+
+200 OK
+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
+
+{
+  "assetTypes": [
+    "storage.googleapis.com/Bucket",
+    "cloudresourcemanager.googleapis.com/Project"
+  ],
+  "feedOutputConfig": {
+    "pubsubDestination": {
+      "topic": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+    }
+  },
+  "name": "projects/${projectNumber}/feeds/assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+DELETE https://cloudasset.googleapis.com/v1/projects/${projectNumber}/feeds/assetfeed-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${projectNumber}%2Ffeeds%2Fassetfeed-minimal-${uniqueId}
+X-Goog-User-Project: ${projectNumber}
+
+200 OK
+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
+
+{}
+
+---
+
+GET https://pubsub.googleapis.com/v1/projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}?alt=json
+Content-Type: application/json
+User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion}
+
+200 OK
+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
+
+{
+  "labels": {
+    "cnrm-test": "true",
+    "managed-by-cnrm": "true"
+  },
+  "name": "projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}"
+}
+
+---
+
+DELETE https://pubsub.googleapis.com/v1/projects/${projectId}/topics/kcc-assetfeed-minimal-${uniqueId}?alt=json
+Content-Type: application/json
+User-Agent: kcc/${kccVersion} (+https://github.com/GoogleCloudPlatform/k8s-config-connector) kcc/controller-manager/${kccVersion}
+
+200 OK
+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
+
+{}
\ No newline at end of file
diff --git a/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/create.yaml b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/create.yaml
new file mode 100644
index 00000000000..567a3c6edd9
--- /dev/null
+++ b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/create.yaml
@@ -0,0 +1,28 @@
+# 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: asset.cnrm.cloud.google.com/v1alpha1
+kind: AssetFeed
+metadata:
+  name: assetfeed-minimal-${uniqueId}
+spec:
+  projectRef:
+    external: "${projectNumber}"
+  # Either assetNames or assetTypes must be specified.
+  assetTypes:
+    - "storage.googleapis.com/Bucket"
+  feedOutputConfig:
+    pubsubDestination:
+      topicRef:
+        name: "kcc-assetfeed-minimal-${uniqueId}"
diff --git a/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/dependencies.yaml b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/dependencies.yaml
new file mode 100644
index 00000000000..d8d95f8b5d6
--- /dev/null
+++ b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/dependencies.yaml
@@ -0,0 +1,21 @@
+# 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: pubsub.cnrm.cloud.google.com/v1beta1
+kind: PubSubTopic
+metadata:
+  name: kcc-assetfeed-minimal-${uniqueId}
+spec:
+  projectRef:
+    external: "${projectNumber}"
\ No newline at end of file
diff --git a/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/update.yaml b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/update.yaml
new file mode 100644
index 00000000000..88d18186b99
--- /dev/null
+++ b/pkg/test/resourcefixture/testdata/basic/asset/v1alpha1/assetfeed/assetfeed-minimal/update.yaml
@@ -0,0 +1,29 @@
+# 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: asset.cnrm.cloud.google.com/v1alpha1
+kind: AssetFeed
+metadata:
+  name: assetfeed-minimal-${uniqueId}
+spec:
+  projectRef:
+    external: "${projectNumber}"
+  # Either assetNames or assetTypes must be specified.
+  assetTypes:
+    - "storage.googleapis.com/Bucket"
+    - "cloudresourcemanager.googleapis.com/Project" # Added for update test
+  feedOutputConfig:
+    pubsubDestination:
+      topicRef:
+        name: "kcc-assetfeed-minimal-${uniqueId}"


Reviews:
user: login: jingyih
body: I believe we need to test against real GCP and capture the log to ensure that the controller actually works.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T20:00:05Z
user: login: jingyih
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:52:37Z
user: login: barney-s
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T14:57:17Z
user: login: barney-s
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T14:59:27Z
user: login: jingyih
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:03:45Z
user: login: jingyih
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T18:06:35Z
user: login: yuwenma
body: /lgtm
/approve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T02:31:02Z
user: login: yuwenma
body: /lgtm
/approve


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T02:31:15Z
user: login: yuwenma
body: /lgtm
/approve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T02:31:34Z

Review comments:
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
path: pkg/controller/direct/asset/feed_controller.go
user: login: jingyih
body: nit: this is not consistent with other controllers. Same for the other logs.

```
log.V(2).Info // and also should not have the leading "---------"
```
created_at: 2025-04-10T07:46:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
path: pkg/controller/direct/asset/feed_controller.go
user: login: jingyih
body: Is there a specific reason for this check? Other adapters assume the resource doesn't exist within this function. The base controller always calls `Find()` to verify the resource's existence before proceeding with either `Create()` or `Update()`.
created_at: 2025-04-10T07:49:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
+		// Resource already exists. Log and attempt to Find to populate a.actual.
+		log.Info("---------asset feed already exists during creation attempt, attempting to fetch it", "name", a.id.String(), "warning", err)
+		found, findErr = a.Find(ctx)
+		if findErr != nil {
+			return fmt.Errorf("fetching existing asset feed %q after CreateFeed returned AlreadyExists failed: %w", a.id.String(), findErr)
+		}
+		if !found {
+			// This is unexpected if CreateFeed returned AlreadyExists
+			return fmt.Errorf("asset feed %q not found even though CreateFeed returned AlreadyExists", a.id.String())
+		}
+	} else {
+		// Other error during creation
+		return fmt.Errorf("creating asset feed %s: %w", a.id.String(), err)
+	}
+
+	// Update status
+	status := &krm.AssetFeedStatus{}
+	status.ExternalRef = direct.LazyPtr(a.actual.Name)
+	// Pass the fetched 'a.actual' to UpdateStatus for potential status mapping
+	return createOp.UpdateStatus(ctx, status, nil)
+}
+
+func (a *feedAdapter) Update(ctx context.Context, updateOp *directbase.UpdateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------updating asset feed", "name", a.id, "actual", a.actual.Name)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+	resource.Name = a.id.String() // Name must be set for update
+
+	paths, err := common.CompareProtoMessage(resource, a.actual, common.BasicDiff)
+	if err != nil {
+		return fmt.Errorf("comparing proto messages: %w", err)
+	}
+	// Name is output only, but is required in the update request object.
+	paths.Delete("name")
path: pkg/controller/direct/asset/feed_controller.go
user: login: jingyih
body: Yeah, `CompareProtoMessage` doesn't work if the proto fields are tagged incorrectly. But I'd expect "name" not to appear in the paths since you've already set the `name` field in `resource` (therefore both `resource` and `a.actual` should have the same name field?).
created_at: 2025-04-10T07:52:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
path: pkg/controller/direct/asset/feed_controller.go
user: login: barney-s
body: Its just defensive coding i guess. LLM generated and i modified a bit. 
created_at: 2025-04-10T14:57:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
+		// Resource already exists. Log and attempt to Find to populate a.actual.
+		log.Info("---------asset feed already exists during creation attempt, attempting to fetch it", "name", a.id.String(), "warning", err)
+		found, findErr = a.Find(ctx)
+		if findErr != nil {
+			return fmt.Errorf("fetching existing asset feed %q after CreateFeed returned AlreadyExists failed: %w", a.id.String(), findErr)
+		}
+		if !found {
+			// This is unexpected if CreateFeed returned AlreadyExists
+			return fmt.Errorf("asset feed %q not found even though CreateFeed returned AlreadyExists", a.id.String())
+		}
+	} else {
+		// Other error during creation
+		return fmt.Errorf("creating asset feed %s: %w", a.id.String(), err)
+	}
+
+	// Update status
+	status := &krm.AssetFeedStatus{}
+	status.ExternalRef = direct.LazyPtr(a.actual.Name)
+	// Pass the fetched 'a.actual' to UpdateStatus for potential status mapping
+	return createOp.UpdateStatus(ctx, status, nil)
+}
+
+func (a *feedAdapter) Update(ctx context.Context, updateOp *directbase.UpdateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------updating asset feed", "name", a.id, "actual", a.actual.Name)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+	resource.Name = a.id.String() // Name must be set for update
+
+	paths, err := common.CompareProtoMessage(resource, a.actual, common.BasicDiff)
+	if err != nil {
+		return fmt.Errorf("comparing proto messages: %w", err)
+	}
+	// Name is output only, but is required in the update request object.
+	paths.Delete("name")
path: pkg/controller/direct/asset/feed_controller.go
user: login: barney-s
body: i guess the mock and realgcp implementation were differing a bit. projectID vs projectNumber. ill comment it out and test.
created_at: 2025-04-10T14:59:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
path: pkg/controller/direct/asset/feed_controller.go
user: login: jingyih
body: I don't think we have any examples that has this code/pattern. Looks like LLM is becoming very creative.
https://github.com/search?q=repo%3AGoogleCloudPlatform%2Fk8s-config-connector%20path%3A%2F%5Epkg%5C%2Fcontroller%5C%2Fdirect%5C%2F%2F%20errors.IsAlreadyExists(err)&type=code
created_at: 2025-04-10T18:03:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,327 @@
+// 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.asset.v1.AssetService
+// proto.message: google.cloud.asset.v1.Feed
+// crd.type: AssetFeed
+// crd.version: v1alpha1
+
+package asset
+
+import (
+	"context"
+	"fmt"
+
+	gcp "cloud.google.com/go/asset/apiv1"
+	pb "cloud.google.com/go/asset/apiv1/assetpb"
+	"google.golang.org/api/option"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/asset/v1alpha1"
+	refs "github.com/GoogleCloudPlatform/k8s-config-connector/apis/refs/v1beta1"
+	"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/common"
+	"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.AssetFeedGVK, NewFeedModel)
+}
+
+func NewFeedModel(ctx context.Context, config *config.ControllerConfig) (directbase.Model, error) {
+	return &feedModel{config: *config}, nil
+}
+
+var _ directbase.Model = &feedModel{}
+
+type feedModel struct {
+	config config.ControllerConfig
+}
+
+func (m *feedModel) client(ctx context.Context, projectID string) (*gcp.Client, error) {
+	var opts []option.ClientOption
+
+	config := m.config
+	// Workaround for an unusual behaviour (bug?):
+	//  the service requires that a quota project be set
+	if !config.UserProjectOverride || config.BillingProject == "" {
+		config.UserProjectOverride = true
+		config.BillingProject = projectID
+	}
+	opts, err := config.RESTClientOptions()
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := gcp.NewRESTClient(ctx, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("building asset feed client: %w", err)
+	}
+
+	return gcpClient, err
+}
+
+func (m *feedModel) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) {
+	obj := &krm.AssetFeed{}
+	if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil {
+		return nil, fmt.Errorf("error converting to %T: %w", obj, err)
+	}
+
+	id, err := krm.NewFeedIdentity(ctx, reader, obj)
+	if err != nil {
+		return nil, err
+	}
+
+	gcpClient, err := m.client(ctx, id.Parent().ProjectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &feedAdapter{
+		gcpClient: gcpClient,
+		id:        id,
+		desired:   obj,
+		reader:    reader,
+	}, nil
+}
+
+func (m *feedModel) AdapterForURL(ctx context.Context, url string) (directbase.Adapter, error) {
+	// TODO: Support URLs
+	return nil, nil
+}
+
+type feedAdapter struct {
+	gcpClient *gcp.Client
+	id        *krm.FeedIdentity
+	desired   *krm.AssetFeed
+	actual    *pb.Feed
+	reader    client.Reader
+}
+
+var _ directbase.Adapter = &feedAdapter{}
+
+func (a *feedAdapter) Find(ctx context.Context) (bool, error) {
+	log := klog.FromContext(ctx)
+	log.Info("---------getting asset feed", "name", a.id)
+
+	name := a.id.String()
+	if a.actual != nil {
+		name = a.actual.Name
+	}
+	req := &pb.GetFeedRequest{Name: name}
+	actual, err := a.gcpClient.GetFeed(ctx, req)
+	if err != nil {
+		if direct.IsNotFound(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("getting asset feed %q from gcp: %w", a.id.String(), err)
+	}
+
+	a.actual = actual
+	return true, nil
+}
+
+func (a *feedAdapter) normalizeReferences(ctx context.Context) error {
+	obj := a.desired
+	if obj.Spec.FeedOutputConfig.PubsubDestination != nil && obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef != nil {
+		topic, err := refs.ResolvePubSubTopic(ctx, a.reader, obj, obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef)
+		if err != nil {
+			return fmt.Errorf("resolving pubsub topic ref: %w", err)
+		}
+		obj.Spec.FeedOutputConfig.PubsubDestination.TopicRef.External = topic.String()
+	}
+	return nil
+}
+
+func (a *feedAdapter) Create(ctx context.Context, createOp *directbase.CreateOperation) error {
+	log := klog.FromContext(ctx)
+	log.Info("---------creating asset feed", "name", a.id)
+	mapCtx := &direct.MapContext{}
+
+	if err := a.normalizeReferences(ctx); err != nil {
+		return fmt.Errorf("normalizing references: %w", err)
+	}
+
+	desired := a.desired.DeepCopy()
+	resource := AssetFeedSpec_ToProto(mapCtx, &desired.Spec)
+	if mapCtx.Err() != nil {
+		return mapCtx.Err()
+	}
+
+	resource.Name = a.id.String() // Name is required for CreateFeed
+
+	req := &pb.CreateFeedRequest{
+		Parent: a.id.Parent().String(),
+		FeedId: a.id.ID(),
+		Feed:   resource,
+	}
+
+	// Attempt to create the feed
+	actual, err := a.gcpClient.CreateFeed(ctx, req)
+
+	// Regardless of CreateFeed result (success or AlreadyExists),
+	// we need to ensure a.actual is populated by calling Find.
+	found := false
+	var findErr error
+
+	if err == nil {
+		log.Info("---------successfully created asset feed in gcp, attempting to fetch it", "name", a.id)
+		a.actual = actual
+		found = true
+	} else if errors.IsAlreadyExists(err) {
path: pkg/controller/direct/asset/feed_controller.go
user: login: jingyih
body: I prefer we just remove this pattern from the PR. If this error happens, we should fix the base controller.
created_at: 2025-04-10T18:06:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: barney-s
created_at: 2025-04-10T07:17:01Z
body: > I believe we need to test against real GCP and capture the log to ensure that the controller actually works.

Done,
author_association: COLLABORATOR
user: login: google-oss-prow[bot]
created_at: 2025-04-11T02:31:45Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/GoogleCloudPlatform/k8s-config-connector/pull/4314#pullrequestreview-2759013314" title="Approved">yuwenma</a>*

The full list of commands accepted by this bot can be found [here](https://oss.gprow.dev/command-help?repo=GoogleCloudPlatform%2Fk8s-config-connector).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/GoogleCloudPlatform/k8s-config-connector/blob/master/OWNERS)~~ [yuwenma]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR