- Proto Format Desired State: Convert the KRM Spec to its Proto representation once in
AdapterForObject and store it as a proto struct pointer (e.g., *pb.MyResource or desired) in the adapter, rather than duplicating conversion logic in both Create and Update. The adapter should avoid holding references to raw KRM objects for desired state to keep the interfaces clean, consistent with actual (which is also of proto type), and avoid redundant conversions. This ensures that desired is stored in the same proto format as actual.
- Handling Non-API / KRM-only Spec Fields: If the KRM Spec has fields that are not represented in the GCP resource's proto message (such as client-side behavioral options or custom installation flags, e.g.,
SkipInitialVersionCreation), do NOT mix them into the proto. Instead, parse and store them as separate, explicitly-named fields on the adapter struct (e.g., desiredSkipInitialVersionCreation bool).
- NormalizeReferences: Always call
common.NormalizeReferences in AdapterForObject to resolve any resource references:
if err := common.NormalizeReferences(ctx, reader, obj, nil); err != nil {
return nil, fmt.Errorf("normalizing references: %w", err)
}
- Identity Parent Paths: Create a
ParentString() method on the resource's identity type (e.g., KMSCryptoKeyIdentity) instead of constructing formatting string patterns manually inside the controller. This keeps parent paths canonical and reusable.
- Client Creation Options: Do NOT build the authenticated HTTP client manually. Instead, retrieve configuration options using
RESTClientOptions() and construct the REST client:
var opts []option.ClientOption
opts, err := m.config.RESTClientOptions()
if err != nil {
return nil, err
}
gcpClient, err := gcp.NewConfigRESTClient(ctx, opts...)
- Diff Comparison & Structured Diff (
tags.DiffForTopLevelFields): Always prefer using top-level field tags-based diff comparison via tags.DiffForTopLevelFields over recursive/magical comparison functions (such as common.CompareProtoMessageStructuredDiff or common.CompareProtoMessage which are deprecated/discouraged due to unpredictable behaviors in BasicDiff).
func compareResource(ctx context.Context, actual, desired *pb.MyResource) (*structuredreporting.Diff, *fieldmaskpb.FieldMask, error) {
maskedActual, err := mappers.OnlySpecFields(actual, MyResourceSpec_FromProto, MyResourceSpec_ToProto)
if err != nil {
return nil, nil, err
}
maskedActual.Name = desired.Name
clonedDesired := proto.CloneOf(desired)
populateDefaults := func(obj *pb.MyResource) {
if obj.SomeDefaultedField == nil {
obj.SomeDefaultedField = ...
}
}
populateDefaults(maskedActual)
populateDefaults(clonedDesired)
diffs, updateMask, err := tags.DiffForTopLevelFields(ctx, clonedDesired.ProtoReflect(), maskedActual.ProtoReflect())
if err != nil {
return nil, nil, err
}
return diffs, updateMask, nil
}
- Reconciling Empty or Incomplete LRO Responses: Many GCP APIs (such as Dataproc's
UpdateCluster LRO) return an empty response (google.protobuf.Empty), or do not fully populate read-only status fields (such as state, metrics, or instance names) during resource creation. If you map status directly from such incomplete/empty LRO responses, you will inadvertently clear status fields in Kubernetes.
- Rule: Always perform a GET operation (
Get<Resource>) immediately after a Create or Update LRO successfully completes to fetch the fully-populated resource before calling updateStatus.
- Propagating KRM Metadata Labels: Metadata labels (such as
managed-by-cnrm: true or custom user-supplied labels) must be explicitly mapped and propagated on both Create and Update operations so they are correctly synchronized to GCP.
- Structured Reporting & updateStatus: In the
Update method, use diffs.HasDiff() to report exact diffs back to the user via structuredreporting.ReportDiff. Always call a helper updateStatus function to update the Kube status at the end of both Create and Update reconciliation paths:
func (a *MyResourceAdapter) updateStatus(ctx context.Context, op directbase.Operation, latest *pb.MyResource) error {
mapCtx := &direct.MapContext{}
status := MyResourceStatus_FromProto(mapCtx, latest)
if mapCtx.Err() != nil {
return mapCtx.Err()
}
return op.UpdateStatus(ctx, status, nil)
}
- Delete Idempotency Check: In the
Delete method, check if the resource has already been deleted using direct.IsNotFound(err) to ensure idempotency, returning true, nil to gracefully exit:
if err != nil {
if direct.IsNotFound(err) {
return true, nil
}
return false, err
}
- Immutable Resources: If a direct resource is completely immutable in GCP (meaning no fields can be updated once created), the
Update method must STILL perform the comparison check on spec fields. If a diff is detected, return a descriptive error (e.g. fmt.Errorf("<Kind> is immutable and cannot be updated")) so that the error/diff is surfaced on the resource status rather than silently doing nothing. Also, register the model using registry.CannotBeDeleted() if deletion is not supported.
- Mutable-but-Unreadable Fields: If certain spec fields (such as keys, passwords, or specific metadata/launchStage fields) are mutable but cannot be read back from the GCP API (meaning they are write-only or missing from the GET response), never check
desired inside populateDefaults. Instead:
- Check the resource's
updateTime or change cookie to see if the GCP side is fully reconciled and unchanged (meaning generation == observedGeneration and GCP updateTime matches status updateTime on a successful/Ready status).
- If the update time matches and there are no external modifications, copy those mutable-but-unreadable fields from
desired to maskedActual in the comparison function to avoid false diffs.
- If the update time does not match (or the resource is not ready), do NOT copy them. This treats the unreadable fields as having changed (since we cannot prove they didn't), correctly forcing an update.