diff --git a/fn.go b/fn.go index b03f8a0..f4e03cf 100644 --- a/fn.go +++ b/fn.go @@ -11,12 +11,14 @@ import ( "path/filepath" "strings" "text/template" + "time" "dario.cat/mergo" "github.com/crossplane-contrib/function-go-templating/input/v1beta1" "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" "github.com/crossplane/crossplane-runtime/v2/pkg/meta" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/json" @@ -44,6 +46,7 @@ type Function struct { log logging.Logger fsys fs.FS + ttl time.Duration defaultSource string defaultOptions string } @@ -58,6 +61,7 @@ type YamlErrorContext struct { const ( annotationKeyCompositionResourceName = "gotemplating.fn.crossplane.io/composition-resource-name" annotationKeyReady = "gotemplating.fn.crossplane.io/ready" + annotationKeyTTL = "gotemplating.fn.crossplane.io/ttl" metaAPIVersion = "meta.gotemplating.fn.crossplane.io/v1alpha1" ) @@ -65,10 +69,10 @@ const ( // RunFunction runs the Function. func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { //nolint:gocognit // this function needs to be refactored f.log.Debug("Running Function", "tag", req.GetMeta().GetTag()) + in := &v1beta1.GoTemplate{} - rsp := response.To(req, response.DefaultTTL) + rsp := response.To(req, f.ttl) - in := &v1beta1.GoTemplate{} if err := request.GetInput(req, in); err != nil { response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) return rsp, nil @@ -80,6 +84,13 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } } + if in.TTL != "" { + ttlDuration, err := time.ParseDuration(in.TTL) + if err == nil { + rsp.Meta.Ttl = durationpb.New(ttlDuration) + } + } + tg, err := NewTemplateSourceGetter(f.fsys, req.GetContext(), in) if err != nil { response.Fatal(rsp, errors.Wrap(err, "invalid function input")) @@ -202,6 +213,17 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) // Initialize the requirements. requirements := &fnv1.Requirements{ExtraResources: make(map[string]*fnv1.ResourceSelector), Resources: make(map[string]*fnv1.ResourceSelector)} + // Override the TTL if specified in the observed composite. + if v, found := observedComposite.Resource.GetAnnotations()[annotationKeyTTL]; found { + t, err := time.ParseDuration(v) + if err != nil { + f.log.Debug("Ignoring Ttl annotation, wrong format", v) + } + rsp.Meta.Ttl = durationpb.New(t) + // Remove meta annotation. + meta.RemoveAnnotations(observedComposite.Resource, annotationKeyTTL) + } + // Convert the rendered manifests to a list of desired composed resources. for _, obj := range objs { cd := resource.NewDesiredComposed() diff --git a/fn_test.go b/fn_test.go index 68e8b98..ec4a33c 100644 --- a/fn_test.go +++ b/fn_test.go @@ -5,6 +5,7 @@ import ( "embed" "fmt" "testing" + "time" "github.com/crossplane-contrib/function-go-templating/input/v1beta1" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -55,6 +56,7 @@ metadata: xrWithReadyTrueAndResourceName = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"xr-as-composed","gotemplating.fn.crossplane.io/ready":"True"},"name":"cool-xr"},"spec":{"count":2}}` xrWithReadyTrueAndStatus = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"annotations":{"gotemplating.fn.crossplane.io/ready":"True"},"name":"cool-xr"},"spec":{"count":2},"status":{"phase":"Ready","message":"Composite resource is ready"}}` xrWithStatusUpdate = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"annotations":{"gotemplating.fn.crossplane.io/ready":"False"},"name":"cool-xr"},"spec":{"count":2},"status":{"phase":"Updating","newField":"added"}}` + xrWithTTL = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"annotations":{"gotemplating.fn.crossplane.io/ttl": "5m"},"name":"cool-xr"},"spec":{"count":2}}` claimConditions = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ClaimConditions","conditions":[{"type":"TestCondition","status":"False","reason":"InstallFail","message":"failed to install","target":"ClaimAndComposite"},{"type":"ConditionTrue","status":"True","reason":"this condition is true","message":"we are true","target":"Composite"},{"type":"DatabaseReady","status":"True","reason":"Ready","message":"Database is ready"}]}` claimConditionsReservedKey = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ClaimConditions","conditions":[{"type":"Ready","status":"False","reason":"InstallFail","message":"I am using a reserved Condition","target":"ClaimAndComposite"}]}` @@ -1887,6 +1889,84 @@ func TestRunFunction(t *testing.T) { }, }, }, + "CustomInputTtl": { + reason: "The Function should use a custom TTL when instructed.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "templates"}, + Input: resource.MustStructObject( + &v1beta1.GoTemplate{ + Source: v1beta1.InlineSource, + Inline: &v1beta1.TemplateSourceInline{Template: cdTmpl}, + TTL: "20m", + }), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "templates", Ttl: durationpb.New(20 * time.Minute)}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xr), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(`{"apiVersion": "example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd","labels":{"belongsTo":"cool-xr"}}}`), + }, + }, + }, + }, + }, + }, + "CustomAnnotationTtl": { + reason: "The Function should use a custom TTL when given in the XR annotation.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "templates"}, + Input: resource.MustStructObject( + &v1beta1.GoTemplate{ + Source: v1beta1.InlineSource, + Inline: &v1beta1.TemplateSourceInline{Template: cdTmpl}, + TTL: "20m", // this should be overridden by the annotation + }), + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xrWithTTL), + }, + }, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xrWithTTL), + }, + }, + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "templates", Ttl: durationpb.New(5 * time.Minute)}, + Desired: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(xrWithTTL), + }, + Resources: map[string]*fnv1.Resource{ + "cool-cd": { + Resource: resource.MustStructJSON(`{"apiVersion": "example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd","labels":{"belongsTo":"cool-xr"}}}`), + }, + }, + }, + }, + }, + }, } for name, tc := range cases { @@ -1894,6 +1974,7 @@ func TestRunFunction(t *testing.T) { f := &Function{ log: logging.NewNopLogger(), fsys: testdataFS, + ttl: response.DefaultTTL, defaultSource: tc.args.defaultSource, defaultOptions: tc.args.defaultOptions, } diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 6ef2494..f4659f6 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -29,6 +29,10 @@ type GoTemplate struct { Inline *TemplateSourceInline `json:"inline,omitempty"` // FileSystem is the folder path where the templates are located FileSystem *TemplateSourceFileSystem `json:"fileSystem,omitempty"` + // TTL for which a response can be cached in time.Duration format + // +kubebuilder:default="1m0s" + // +optional + TTL string `json:"ttl"` // Environment is the key that defines the location of the templates in the environment Environment *TemplateSourceEnvironment `json:"environment,omitempty"` // Options to set for the template engine. Valid options are documented at https://pkg.go.dev/text/template#Template.Option diff --git a/main.go b/main.go index aacc52d..111c932 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,12 @@ package main import ( + "time" + "github.com/alecthomas/kong" "github.com/crossplane/function-sdk-go" + "github.com/crossplane/function-sdk-go/response" ) // CLI of this Function. @@ -15,6 +18,7 @@ type CLI struct { Address string `default:":9443" help:"Address at which to listen for gRPC connections."` TLSCertsDir string `env:"TLS_SERVER_CERTS_DIR" help:"Directory containing server certs (tls.key, tls.crt) and the CA used to verify client certificates (ca.crt)"` Insecure bool `help:"Run without mTLS credentials. If you supply this flag --tls-server-certs-dir will be ignored."` + TTL string `default:"${defaultTTL}" help:"Function global setting for response TTL."` MaxRecvMessageSize int `default:"4" help:"Maximum size of received messages in MB."` DefaultSource string `default:"" env:"FUNCTION_GO_TEMPLATING_DEFAULT_SOURCE" help:"Default template source to use when input is not provided to the function."` DefaultOptions string `default:"" env:"FUNCTION_GO_TEMPLATING_DEFAULT_OPTIONS" help:"Comma-separated default template options to use when input is not provided to the function."` @@ -27,12 +31,18 @@ func (c *CLI) Run() error { return err } + ttl, err := time.ParseDuration(c.TTL) + if err != nil { + return err + } + return function.Serve( &Function{ log: log, fsys: &osFS{}, defaultSource: c.DefaultSource, defaultOptions: c.DefaultOptions, + ttl: ttl, }, function.Listen(c.Network, c.Address), function.MTLSCertificates(c.TLSCertsDir), @@ -41,6 +51,10 @@ func (c *CLI) Run() error { } func main() { - ctx := kong.Parse(&CLI{}, kong.Description("A Crossplane Composition Function.")) + ctx := kong.Parse( + &CLI{}, + kong.Description("A Crossplane Composition Function."), + kong.Vars{"defaultTTL": response.DefaultTTL.String()}, + ) ctx.FatalIfErrorf(ctx.Run()) } diff --git a/package/input/gotemplating.fn.crossplane.io_gotemplates.yaml b/package/input/gotemplating.fn.crossplane.io_gotemplates.yaml index 59ba477..36ce342 100644 --- a/package/input/gotemplating.fn.crossplane.io_gotemplates.yaml +++ b/package/input/gotemplating.fn.crossplane.io_gotemplates.yaml @@ -87,6 +87,10 @@ spec: description: Source specifies the different types of input sources that can be used with this function type: string + ttl: + default: 1m0s + description: TTL for which a response can be cached in time.Duration format + type: string required: - source type: object