diff --git a/.gitignore b/.gitignore index 3b735ec..abc3840 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ *.so *.dylib +# Jetbrains IDEs +.idea + # Test binary, built with `go test -c` *.test @@ -15,7 +18,7 @@ *.out # Dependency directories (remove the comment below to include it) -# vendor/ +vendor/ # Go workspace file go.work diff --git a/README.md b/README.md index a6d7ee5..0be9ed0 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,43 @@ $ crossplane render xr.yaml composition-k8s.yaml functions.yaml -o observed-k8s. See the [composition functions documentation][docs-functions] to learn more about `crossplane render`. +## Function Response Caching + +You can set the `ttl` input to control the Function response cache time-to-live. +This is useful for tuning reconciliation behavior in large compositions. + +```yaml + - step: auto-detect-ready-resources + functionRef: + name: function-auto-ready + input: + apiVersion: autoready.fn.crossplane.io/v1beta1 + kind: Input + ttl: 5m +``` + +There is also a `--ttl` input parameter to the function that can be used to set the default TTL used when it is not set +in the composition function input. Use a `DeploymentRuntimeConfig` to set this parameter. + +```yaml +apiVersion: pkg.crossplane.io/v1beta1 +kind: DeploymentRuntimeConfig +metadata: + name: function-auto-ready +spec: + deploymentTemplate: + spec: + selector: {} + template: + spec: + containers: + - name: package-runtime + args: + - --debug + - --ttl="5m" +``` + + ## Developing this function This function uses [Go][go], [Docker][docker], and the [Crossplane CLI][cli] to diff --git a/fn.go b/fn.go index 1808895..e3816e5 100644 --- a/fn.go +++ b/fn.go @@ -4,8 +4,10 @@ import ( "context" "time" + "google.golang.org/protobuf/types/known/durationpb" corev1 "k8s.io/api/core/v1" + "github.com/crossplane/function-auto-ready/input/v1beta1" "github.com/crossplane/function-sdk-go/errors" "github.com/crossplane/function-sdk-go/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" @@ -23,14 +25,28 @@ type Function struct { fnv1.UnimplementedFunctionRunnerServiceServer log logging.Logger - TTL time.Duration + ttl time.Duration } // RunFunction runs the Function. func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { f.log.Debug("Running Function", "tag", req.GetMeta().GetTag()) - rsp := response.To(req, f.TTL) + rsp := response.To(req, f.ttl) + + in := &v1beta1.Input{} + if err := request.GetInput(req, in); err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) + return rsp, nil + } + if in.TTL != "" { + dur, err := time.ParseDuration(in.TTL) + if err != nil { + response.Fatal(rsp, errors.Wrapf(err, "cannot set ttl")) + return rsp, nil + } + rsp.Meta.Ttl = durationpb.New(dur) + } oxr, err := request.GetObservedCompositeResource(req) if err != nil { diff --git a/fn_test.go b/fn_test.go index 9340bf2..28eccf8 100644 --- a/fn_test.go +++ b/fn_test.go @@ -3,7 +3,9 @@ package main import ( "context" "testing" + "time" + "github.com/crossplane/function-auto-ready/input/v1beta1" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/protobuf/testing/protocmp" @@ -301,7 +303,7 @@ func TestRunFunction(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := &Function{log: logging.NewNopLogger(), TTL: response.DefaultTTL} + f := &Function{log: logging.NewNopLogger(), ttl: response.DefaultTTL} rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { @@ -314,3 +316,63 @@ func TestRunFunction(t *testing.T) { }) } } + +func TestRunFunctionCacheTTL(t *testing.T) { + xr := `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":1}}` + + cases := map[string]struct { + reason string + input *v1beta1.Input + want *fnv1.RunFunctionResponse + }{ + "InputTTL": { + reason: "Set the response ttl value from the input specified", + input: &v1beta1.Input{TTL: "5m"}, + want: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(5 * time.Minute)}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "second": { + Resource: resource.MustStructJSON(xr), + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &Function{log: logging.NewNopLogger()} + req := &fnv1.RunFunctionRequest{ + Input: resource.MustStructObject(tc.input), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{}, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "second": { + Resource: resource.MustStructJSON(xr), + }, + }, + }, + } + rsp, err := f.RunFunction(context.Background(), req) + if diff := cmp.Diff(tc.want, rsp, protocmp.Transform()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(nil, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 95e3e09..e505510 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( google.golang.org/protobuf v1.36.11 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 + sigs.k8s.io/controller-tools v0.20.0 ) require ( @@ -86,7 +87,6 @@ require ( k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect sigs.k8s.io/controller-runtime v0.23.1 // indirect - sigs.k8s.io/controller-tools v0.20.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect diff --git a/input/generate.go b/input/generate.go new file mode 100644 index 0000000..551821d --- /dev/null +++ b/input/generate.go @@ -0,0 +1,15 @@ +//go:build generate +// +build generate + +// NOTE(negz): See the below link for details on what is happening here. +// https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module + +// Remove existing and generate new input manifests +//go:generate rm -rf ../package/input/ +//go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen paths=./v1beta1 object crd:crdVersions=v1 output:artifacts:config=../package/input + +package input + +import ( + _ "sigs.k8s.io/controller-tools/cmd/controller-gen" //nolint:typecheck +) diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go new file mode 100644 index 0000000..6ca42c6 --- /dev/null +++ b/input/v1beta1/input.go @@ -0,0 +1,27 @@ +// Package v1beta1 contains the input type for this Function +// +kubebuilder:object:generate=true +// +groupName=autoready.fn.crossplane.io +// +versionName=v1beta1 +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// This isn't a custom resource, in the sense that we never install its CRD. +// It is a KRM-like object, so we generate a CRD to describe its schema. + +// Input is used to provide inputs to this Function. +// +kubebuilder:object:root=true +// +kubebuilder:storageversion +// +kubebuilder:resource:categories=crossplane +type Input struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + // TTL for which a response can be cached in time.Duration format + // +kubebuilder:default="1m0s" + // +optional + TTL string `json:"ttl"` +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000..3b7e60f --- /dev/null +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,34 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Input) DeepCopyInto(out *Input) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Input. +func (in *Input) DeepCopy() *Input { + if in == nil { + return nil + } + out := new(Input) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Input) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} diff --git a/main.go b/main.go index e3f9c63..00f10cc 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,7 @@ func (c *CLI) Run() error { ttl = *c.TTL } - return function.Serve(&Function{log: log, TTL: ttl}, + return function.Serve(&Function{log: log, ttl: ttl}, function.Listen(c.Network, c.Address), function.MTLSCertificates(c.TLSCertsDir), function.Insecure(c.Insecure), diff --git a/package/input/autoready.fn.crossplane.io_inputs.yaml b/package/input/autoready.fn.crossplane.io_inputs.yaml new file mode 100644 index 0000000..f10a581 --- /dev/null +++ b/package/input/autoready.fn.crossplane.io_inputs.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: inputs.autoready.fn.crossplane.io +spec: + group: autoready.fn.crossplane.io + names: + categories: + - crossplane + kind: Input + listKind: InputList + plural: inputs + singular: input + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: Input is used to provide inputs to this Function. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + ttl: + default: 1m0s + description: TTL for which a response can be cached in time.Duration format + type: string + type: object + served: true + storage: true