From 6766368e874cb288293928b783e3ab6df3adf5ff Mon Sep 17 00:00:00 2001 From: Yuwen Ma Date: Wed, 12 Jun 2024 13:23:36 +0000 Subject: [PATCH] feat: templating tool for direct controller builder --- dev/tools/controllerbuilder/cmd/root.go | 64 ++++ dev/tools/controllerbuilder/go.mod | 18 + dev/tools/controllerbuilder/go.sum | 27 ++ dev/tools/controllerbuilder/main.go | 21 ++ .../controllerbuilder/scaffold/controller.go | 102 ++++++ .../controllerbuilder/template/controller.go | 339 ++++++++++++++++++ 6 files changed, 571 insertions(+) create mode 100644 dev/tools/controllerbuilder/cmd/root.go create mode 100644 dev/tools/controllerbuilder/go.mod create mode 100644 dev/tools/controllerbuilder/go.sum create mode 100644 dev/tools/controllerbuilder/main.go create mode 100644 dev/tools/controllerbuilder/scaffold/controller.go create mode 100644 dev/tools/controllerbuilder/template/controller.go diff --git a/dev/tools/controllerbuilder/cmd/root.go b/dev/tools/controllerbuilder/cmd/root.go new file mode 100644 index 0000000000..fde64c65c2 --- /dev/null +++ b/dev/tools/controllerbuilder/cmd/root.go @@ -0,0 +1,64 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/scaffold" + "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/template" + "github.com/spf13/cobra" +) + +var ( + serviceName string + // TODO: Resource and kind name should be the same. Validation the uppercase/lowercase. + kind string + apiVersion string + + addCmd = &cobra.Command{ + Use: "add", + Short: "add direct controller", + RunE: func(cmd *cobra.Command, args []string) error { + // TODO(check kcc root) + cArgs := &template.ControllerArgs{ + Service: serviceName, + Version: apiVersion, + Kind: kind, + KindToLower: strings.ToLower(kind), + } + path, err := scaffold.BuildControllerPath(serviceName, kind) + if err != nil { + return err + } + return scaffold.Scaffold(path, cArgs) + }, + } +) + +func init() { + addCmd.PersistentFlags().StringVarP(&apiVersion, "version", "v", "v1alpha1", "the KRM API version. used to import the KRM API") + addCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", "", "the GCP service name") + addCmd.PersistentFlags().StringVarP(&kind, "resourceInKind", "r", "", "the GCP resource name under the GCP service. should be in camel case ") +} + +func Execute() { + if err := addCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/dev/tools/controllerbuilder/go.mod b/dev/tools/controllerbuilder/go.mod new file mode 100644 index 0000000000..020d47d4b7 --- /dev/null +++ b/dev/tools/controllerbuilder/go.mod @@ -0,0 +1,18 @@ +module github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder + +go 1.23 + +require ( + github.com/fatih/color v1.17.0 + github.com/spf13/cobra v1.8.0 + golang.org/x/tools v0.21.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/dev/tools/controllerbuilder/go.sum b/dev/tools/controllerbuilder/go.sum new file mode 100644 index 0000000000..8aa644a86b --- /dev/null +++ b/dev/tools/controllerbuilder/go.sum @@ -0,0 +1,27 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= +golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/dev/tools/controllerbuilder/main.go b/dev/tools/controllerbuilder/main.go new file mode 100644 index 0000000000..57088a3dd9 --- /dev/null +++ b/dev/tools/controllerbuilder/main.go @@ -0,0 +1,21 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/cmd" + +func main() { + cmd.Execute() +} diff --git a/dev/tools/controllerbuilder/scaffold/controller.go b/dev/tools/controllerbuilder/scaffold/controller.go new file mode 100644 index 0000000000..5ea3173a1c --- /dev/null +++ b/dev/tools/controllerbuilder/scaffold/controller.go @@ -0,0 +1,102 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scaffold + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + ccTemplate "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/template" + "github.com/fatih/color" + "golang.org/x/tools/imports" +) + +const ( + currRelPath = "dev/tools/controllerbuilder" + directControllerRelPath = "pkg/controller/direct" +) + +func Scaffold(path string, cArgs *ccTemplate.ControllerArgs) error { + tmpl, err := template.New(cArgs.Kind).Parse(ccTemplate.ControllerTemplate) + if err != nil { + return fmt.Errorf("parse controller template: %s", err) + } + // Apply the `service` and `resource` args to the controller template + out := &bytes.Buffer{} + if err := tmpl.Execute(out, cArgs); err != nil { + return err + } + // Write the generated controller.go to pkg/controller/direct//_controller.go + if err := WriteToFile(path, out.Bytes()); err != nil { + return err + } + // Format and adjust the go imports in the generated controller file. + if err := FormatImports(path, out.Bytes()); err != nil { + return err + } + color.HiGreen("New controller %s\nEnjoy it!\n", path) + return nil +} + +func BuildControllerPath(service, kind string) (string, error) { + pwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get current working directory: %w", err) + } + abs, err := filepath.Abs(pwd) + if err != nil { + return "", fmt.Errorf("get absolute path %s: %w", pwd, err) + } + seg := strings.Split(abs, currRelPath) + controllerDir := filepath.Join(seg[0], directControllerRelPath, service) + err = os.MkdirAll(controllerDir, os.ModePerm) + if err != nil { + return "", fmt.Errorf("create controller directory %s: %w", controllerDir, err) + } + controllerFilePath := filepath.Join(controllerDir, strings.ToLower(kind)+"_controller.go") + if _, err := os.Stat(controllerFilePath); err == nil { + return "", fmt.Errorf("controller file %s may already exist: %w", controllerFilePath, err) + } + return controllerFilePath, nil +} + +func FormatImports(path string, out []byte) error { + importOps := &imports.Options{ + Comments: true, + AllErrors: true, + Fragment: true} + formatedOut, err := imports.Process(path, out, importOps) + if err != nil { + return fmt.Errorf("format controller file %s: %w", path, err) + } + return WriteToFile(path, formatedOut) +} + +func WriteToFile(path string, out []byte) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create controller file %s: %w", path, err) + } + err = os.WriteFile(path, out, 0644) + if err != nil { + return fmt.Errorf("write controller file %s: %w", path, err) + } + defer f.Close() + return nil +} diff --git a/dev/tools/controllerbuilder/template/controller.go b/dev/tools/controllerbuilder/template/controller.go new file mode 100644 index 0000000000..cbb4054017 --- /dev/null +++ b/dev/tools/controllerbuilder/template/controller.go @@ -0,0 +1,339 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package template + +type ControllerArgs struct { + Service string + Version string + Kind string + KindToLower string +} + +const ControllerTemplate = ` +package {{.Service}} + +import ( + "context" + "reflect" + "strings" + "time" + + krm "github.com/GoogleCloudPlatform/k8s-config-connector/apis/{{.Service}}/{{.Version}}" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/config" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/directbase" + "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/controller/direct/references" + + // TODO(user): Update the import with the google cloud client + gcp "cloud.google.com/go/{{.Service}}/apiv1" + // TODO(user): Update the import with the google cloud client api protobuf + {{.Service}}pb "cloud.google.com/go/{{.Service}}/v1/{{.KindToLower}}pb" + "google.golang.org/api/option" + "google.golang.org/protobuf/types/known/fieldmaskpb" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/googleapis/gax-go/v2/apierror" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ctrlName = "{{.Service}}-controller" + +func init() { + directbase.ControllerBuilder.RegisterModel(krm.GroupVersionKind, NewModel) +} + +func NewModel(config *config.ControllerConfig) directbase.Model { + return &model{config: *config} +} + +var _ directbase.Model = &model{} + +type model struct { + config config.ControllerConfig +} + +func (m *model) client(ctx context.Context) (*gcp.Client, error) { + var opts []option.ClientOption + if m.config.UserAgent != "" { + opts = append(opts, option.WithUserAgent(m.config.UserAgent)) + } + if m.config.HTTPClient != nil { + opts = append(opts, option.WithHTTPClient(m.config.HTTPClient)) + } + if m.config.UserProjectOverride && m.config.BillingProject != "" { + opts = append(opts, option.WithQuotaProject(m.config.BillingProject)) + } + + gcpClient, err := gcp.NewRESTClient(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("building {{.Service}} client: %w", err) + } + return gcpClient, err +} + +func (m *model) AdapterForObject(ctx context.Context, reader client.Reader, u *unstructured.Unstructured) (directbase.Adapter, error) { + obj := &krm.{{.Kind}}{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &obj); err != nil { + return nil, fmt.Errorf("error converting to %T: %w", obj, err) + } + + // Get ResourceID + resourceID := ValueOf(obj.Spec.ResourceID) + if resourceID == "" { + resourceID = obj.GetName() + } + if resourceID == "" { + return nil, fmt.Errorf("cannot resolve resource ID") + } + + // TODO(user): Use the proper function to validate and resolve dependent KCC resources. + // i.e. ResolveProject, ResolveNetwork. etc + // TODO(kcc): ops.WithProjectRef, ops.WithNetworkRef + projectRef, err := references.ResolveProject(ctx, reader, obj, obj.Spec.ProjectRef) + if err != nil { + return nil, err + } + projectID := projectRef.ProjectID + if projectID == "" { + return nil, fmt.Errorf("cannot resolve project") + } + + // TODO(kcc): GetGCPClient as interface method. + // Get {{.Service}} GCP client + gcpClient, err := m.client(ctx) + if err != nil { + return nil, err + } + return &Adapter{ + resourceID: resourceID, + projectID: projectID, + gcpClient: gcpClient, + desired: obj, + }, nil +} + +type Adapter struct { + resourceID string + projectID string + gcpClient *gcp.Client + desired *krm.{{.Kind}} + actual *{{.Service}}pb.{{.Kind}} +} + +var _ directbase.Adapter = &Adapter{} + +func (a *Adapter) Find(ctx context.Context) (bool, error) { + if a.resourceID == "" { + return false, nil + } + + // TODO(user): write the gcp "GET" operation. + req := &{{.Service}}pb.Get{{.Kind}}Request{Name: a.fullyQualifiedName()} + {{.KindToLower}}pb, err := a.gcpClient.Get{{.Kind}}(ctx, req) + if err != nil { + if IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting {{.Kind}} %q: %w", a.fullyQualifiedName(), err) + } + + a.actual = {{.KindToLower}}pb + return true, nil +} + +func (a *Adapter) Create(ctx context.Context, u *unstructured.Unstructured) error { + log := klog.FromContext(ctx).WithName(ctrlName) + log.V(2).Info("creating object", "u", u) + + projectID := a.projectID + if projectID == "" { + return fmt.Errorf("project is empty") + } + if a.resourceID == "" { + return fmt.Errorf("resourceID is empty") + } + + desired := a.desired.DeepCopy() + resource := &{{.Service}}pb.{{.Kind}}{ + Name: a.fullyQualifiedName(), + } + // TODO(user): Please add the krm to proto mapping file under apis/{{.Service}}/{{.Version}} + err := krm.Convert_{{.Kind}}_KRM_TO_API_v1(desired, resource) + if err != nil { + return fmt.Errorf("converting {{.Kind}} spec to api: %w", err) + } + + // TODO(user): write the gcp "CREATE" operation. + req := &{{.Service}}pb.Create{{.Kind}}Request{} + op, err := a.gcpClient.Create{{.Kind}}(ctx, req) + if err != nil { + return fmt.Errorf("{{.Kind}} %s creating failed: %w", resource.Name, err) + } + // TODO(user): Adjust the response, depending on the LRO or not. + created, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("{{.Kind}} %s waiting creation failed: %w", resource.Name, err) + } + + status := &krm.{{.Kind}}Status{} + // TODO(user): Please add the proto to krm mapping file under apis/{{.Service}}/{{.Version}} + if err := krm.Convert_{{.Kind}}_API_v1_To_KRM_status(created, status); err != nil { + return fmt.Errorf("update {{.Kind}} status %w", err) + } + status.ObservedState.CreateTime = ToOpenAPIDateTime(created.GetCreateTime()) + status.ObservedState.UpdateTime = ToOpenAPIDateTime(created.GetUpdateTime()) + return setStatus(u, status) +} + +func (a *Adapter) Update(ctx context.Context, u *unstructured.Unstructured) error { + + updateMask := &fieldmaskpb.FieldMask{} + + // TODO(user): Add GCP mutable fields. + // TODO(kcc): Autogen "func immutable()" for each field + // TODO(kcc): autogen updateMastk.path for mutable gcp fields. + if !reflect.DeepEqual(a.desired.Spec.DisplayName, a.actual.DisplayName) { + updateMask.Paths = append(updateMask.Paths, "display_name") + } + + resource := &{{.Service}}pb.{{.Kind}}{ + Name: a.fullyQualifiedName(), + } + desired := a.desired.DeepCopy() + err := krm.Convert_{{.Kind}}_KRM_To_API_v1(desired, resource) + if err != nil { + return fmt.Errorf("converting {{.Kind}} spec to api: %w", err) + } + + // TODO(user): write the gcp "UPDATE" operation. + req := &{{.Service}}pb.Updat{{.Kind}}Request{} + op, err := a.gcpClient.Update{{.Kind}}(ctx, req) + if err != nil { + return fmt.Errorf("{{.Kind}} %s updating failed: %w", resource.Name, err) + } + // TODO(user): Adjust the response, depending on the LRO or not. + updated, err := op.Wait(ctx) + if err != nil { + return fmt.Errorf("{{.Kind}} %s waiting update failed: %w", resource.Name, err) + } + status := &krm.{{.Kind}}Status{} + if err := krm.Convert_{{.Kind}}_API_v1_To_KRM_status(updated, status); err != nil { + return fmt.Errorf("update {{.Kind}} status %w", err) + } + status.ObservedState.CreateTime = ToOpenAPIDateTime(updated.GetCreateTime()) + status.ObservedState.UpdateTime = ToOpenAPIDateTime(updated.GetUpdateTime()) + return setStatus(u, status) +} + +func (a *Adapter) Export(ctx context.Context) (*unstructured.Unstructured, error) { + // TODO(kcc) + return nil, nil +} + +// Delete implements the Adapter interface. +func (a *Adapter) Delete(ctx context.Context) (bool, error) { + if a.resourceID == "" { + return false, nil + } + req := &{{.Service}}pb.Delete{{.Kind}}Request{Name: a.fullyQualifiedName()} + op, err := a.gcpClient.Delete{{.Kind}}(ctx, req) + if err != nil { + return false, fmt.Errorf("deleting {{.Kind}} %s: %w", a.fullyQualifiedName(), err) + } + // TODO(user): Adjust the response, depending on the LRO or not. + err = op.Wait(ctx) + if err != nil { + return false, fmt.Errorf("waiting delete {{.Kind}} %s: %w", a.fullyQualifiedName(), err) + } + return true, nil +} + +// TODO(kcc): interface method +func (a *Adapter) fullyQualifiedName() string { + // TODO(user): Write the GCP URI for your resource + return fmt.Sprintf("projects/%s/{{.Kind}}s/%s", a.projectID, a.resourceID) +} + +// TODO(kcc): ops.WithParent +func (a *Adapter) getParent() string { + // TODO(user): Write the GCP URI parent for your resource + return fmt.Sprintf("projects/%s", a.projectID) +} + +func setStatus(u *unstructured.Unstructured, typedStatus any) error { + status, err := runtime.DefaultUnstructuredConverter.ToUnstructured(typedStatus) + if err != nil { + return fmt.Errorf("error converting status to unstructured: %w", err) + } + + old, _, _ := unstructured.NestedMap(u.Object, "status") + if old != nil { + status["conditions"] = old["conditions"] + status["observedGeneration"] = old["observedGeneration"] + } + + u.Object["status"] = status + + return nil +} + +func ValueOf[T any](p *T) T { + var v T + if p != nil { + v = *p + } + return v +} + +// IsNotFound returns true if the given error is an HTTP 404. +func IsNotFound(err error) bool { + return HasHTTPCode(err, 404) +} + +// HasHTTPCode returns true if the given error is an HTTP response with the given code. +func HasHTTPCode(err error, code int) bool { + if err == nil { + return false + } + apiError := &apierror.APIError{} + if errors.As(err, &apiError) { + if apiError.HTTPCode() == code { + return true + } + } else { + klog.Warningf("unexpected error type %T", err) + } + return false +} + +// LazyPtr returns a pointer to v, unless it is the empty value, in which case it returns nil. +// It is essentially the inverse of ValueOf, though it is lossy +// because we can't tell nil and empty apart without a pointer. +func LazyPtr[T comparable](v T) *T { + var defaultValue T + if v == defaultValue { + return nil + } + return &v +} + +func ToOpenAPIDateTime(ts *timestamppb.Timestamp) *string { + formatted := ts.AsTime().Format(time.RFC3339) + return &formatted +} + +`