diff --git a/README.md b/README.md index f425604..6b5c96e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,35 @@ To learn how to use this SDK: If you just want to jump in and get started, consider using the [function-template-go] template repository. +## Standard CLI options + +Embed `function.CLI` to get the standard flags and environment variables +(`--address`, `--debug`, `--insecure`, `--tls-certs-dir`, `--ttl`, and more). +Add your own flags alongside it: + +```go +type CLI struct { + function.CLI `kong:"embed"` + + MyFlag string `default:"foo" env:"MY_FLAG" help:"My custom flag."` +} + +func (c *CLI) Run() error { + log, err := c.Logger() + if err != nil { + return err + } + // Use c.TTL with response.To(req, c.TTL) in RunFunction. + return function.Serve(&Function{log: log, ttl: c.TTL, myFlag: c.MyFlag}, c.StandardOptions()...) +} + +func main() { + function.Parse(&CLI{}, "My function.") +} +``` + +See the [package docs] for the full list of options. + [go]: https://go.dev [functions]: https://docs.crossplane.io/latest/concepts/composition-functions [contributing guidelines]: https://github.com/crossplane/crossplane/tree/master/contributing diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..28d62d6 --- /dev/null +++ b/cli.go @@ -0,0 +1,100 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 function + +import ( + "time" + + "github.com/alecthomas/kong" + + "github.com/crossplane/function-sdk-go/logging" + "github.com/crossplane/function-sdk-go/response" +) + +// CLI provides standard flags and environment variables for Composition +// Functions. It is designed to be used with [github.com/alecthomas/kong]. +// +// Without custom flags, use CLI directly with [Parse]: +// +// type CLI struct { +// function.CLI `kong:"embed"` +// // MyFlag string `default:"foo" env:"MY_FLAG" help:"My custom flag."` +// } +// +// func (c *CLI) Run() error { +// log, err := c.Logger() +// if err != nil { +// return err +// } +// // Use c.TTL with response.To(req, c.TTL) in RunFunction. +// return function.Serve(&Function{log: log, ttl: c.TTL}, c.StandardOptions()...) +// // or with custom flags: +// // return function.Serve(&Function{log: log, ttl: c.TTL, myFlag: c.MyFlag}, c.StandardOptions()...) +// } +// +// func main() { +// function.Parse(&CLI{}, "My function.") +// } +type CLI struct { + Address string `default:":9443" env:"ADDRESS" help:"Address at which to listen for gRPC connections."` + Debug bool `env:"DEBUG" help:"Emit debug logs in addition to info logs." short:"d"` + Insecure bool `env:"INSECURE" help:"Run without mTLS credentials. If you supply this flag --tls-server-certs-dir will be ignored."` + MaxRecvMessageSize int `aliases:"max-grpc-message-size" default:"4" env:"MAX_RECV_MESSAGE_SIZE,MAX_GRPC_MESSAGE_SIZE" help:"Maximum size of received messages in MB."` + MaxSendMessageSize int `env:"MAX_SEND_MESSAGE_SIZE" help:"Maximum size of sent messages in MB. Defaults to max-recv-message-size when unset."` + Network string `default:"tcp" env:"NETWORK" help:"Network on which to listen for gRPC connections."` + TTL time.Duration `default:"${default_ttl}" env:"TTL" help:"TTL for which a response can be cached. Pass it to response.To. Set to 0 to disable caching."` + TLSCertsDir string `aliases:"tls-server-certs-dir" env:"TLS_SERVER_CERTS_DIR" help:"Directory containing server certs (tls.key, tls.crt) and the CA used to verify client certificates (ca.crt)." name:"tls-certs-dir"` +} + +// StandardOptions returns the ServeOptions derived from standard CLI flags. +func (c *CLI) StandardOptions() []ServeOption { + sendSize := c.MaxSendMessageSize + if sendSize == 0 { + sendSize = c.MaxRecvMessageSize + } + opts := []ServeOption{ + Listen(c.Network, c.Address), + Insecure(c.Insecure), + MaxRecvMessageSize(c.MaxRecvMessageSize * 1024 * 1024), + MaxSendMessageSize(sendSize * 1024 * 1024), + } + if !c.Insecure { + opts = append(opts, MTLSCertificates(c.TLSCertsDir)) + } + return opts +} + +// Logger returns a new logger configured from CLI flags. +func (c *CLI) Logger() (logging.Logger, error) { + return NewLogger(c.Debug) +} + +// kongVars returns the variables interpolated into CLI struct tags. +func kongVars() kong.Vars { + return kong.Vars{"default_ttl": response.DefaultTTL.String()} +} + +// Parse parses CLI flags using kong and runs the command. The cli argument must +// have a Run() error method. An optional description is used as CLI help text. +func Parse(cli any, description ...string) { + options := []kong.Option{kongVars()} + if len(description) > 0 { + options = append(options, kong.Description(description[0])) + } + ctx := kong.Parse(cli, options...) + ctx.FatalIfErrorf(ctx.Run()) +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..478bccb --- /dev/null +++ b/cli_test.go @@ -0,0 +1,162 @@ +/* +Copyright 2026 The Crossplane Authors. + +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 function + +import ( + "testing" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/function-sdk-go/response" +) + +func TestCLIParse(t *testing.T) { + type want struct { + cli CLI + maxRecvMsgSize int + maxSendMsgSize int + } + + defaults := CLI{ + Address: ":9443", + MaxRecvMessageSize: 4, + Network: "tcp", + TTL: response.DefaultTTL, + } + + cases := map[string]struct { + reason string + args []string + env map[string]string + want want + }{ + "Defaults": { + reason: "With no flags or environment variables we should use the defaults.", + want: want{ + cli: defaults, + maxRecvMsgSize: 4 * 1024 * 1024, + maxSendMsgSize: 4 * 1024 * 1024, + }, + }, + "Flags": { + reason: "We should accept the standard flag names.", + args: []string{ + "--address=:8080", "--debug", "--insecure", "--network=unix", + "--max-recv-message-size=8", "--max-send-message-size=2", + "--ttl=30s", "--tls-certs-dir=/certs", + }, + want: want{ + cli: CLI{ + Address: ":8080", + Debug: true, + Insecure: true, + MaxRecvMessageSize: 8, + MaxSendMessageSize: 2, + Network: "unix", + TTL: 30 * time.Second, + TLSCertsDir: "/certs", + }, + maxRecvMsgSize: 8 * 1024 * 1024, + maxSendMsgSize: 2 * 1024 * 1024, + }, + }, + "Aliases": { + reason: "We should accept the flag names existing functions already use.", + args: []string{"--insecure", "--max-grpc-message-size=16", "--tls-server-certs-dir=/certs"}, + want: want{ + cli: func() CLI { + c := defaults + c.Insecure = true + c.MaxRecvMessageSize = 16 + c.TLSCertsDir = "/certs" + return c + }(), + maxRecvMsgSize: 16 * 1024 * 1024, + maxSendMsgSize: 16 * 1024 * 1024, + }, + }, + "EnvironmentVariables": { + reason: "We should accept the environment variables existing functions already use.", + env: map[string]string{ + "INSECURE": "true", + "MAX_GRPC_MESSAGE_SIZE": "16", + "TLS_SERVER_CERTS_DIR": "/certs", + "TTL": "0s", + }, + want: want{ + cli: func() CLI { + c := defaults + c.Insecure = true + c.MaxRecvMessageSize = 16 + c.TLSCertsDir = "/certs" + c.TTL = 0 + return c + }(), + maxRecvMsgSize: 16 * 1024 * 1024, + maxSendMsgSize: 16 * 1024 * 1024, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + + c := &CLI{} + p, err := kong.New(c, kongVars()) + if err != nil { + t.Fatalf("\n%s\nkong.New(): unexpected error: %v", tc.reason, err) + } + if _, err := p.Parse(tc.args); err != nil { + t.Fatalf("\n%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err) + } + if diff := cmp.Diff(tc.want.cli, *c); diff != "" { + t.Errorf("\n%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + + so := &ServeOptions{} + for _, o := range c.StandardOptions() { + if err := o(so); err != nil { + t.Fatalf("\n%s\nStandardOptions(): unexpected error: %v", tc.reason, err) + } + } + if so.MaxRecvMsgSize != tc.want.maxRecvMsgSize { + t.Errorf("\n%s\nStandardOptions(): MaxRecvMsgSize: want %d, got %d", tc.reason, tc.want.maxRecvMsgSize, so.MaxRecvMsgSize) + } + if so.MaxSendMsgSize != tc.want.maxSendMsgSize { + t.Errorf("\n%s\nStandardOptions(): MaxSendMsgSize: want %d, got %d", tc.reason, tc.want.maxSendMsgSize, so.MaxSendMsgSize) + } + }) + } +} + +func TestStandardOptionsInsecureIgnoresTLSCertsDir(t *testing.T) { + c := &CLI{Insecure: true, TLSCertsDir: "/does/not/exist", MaxRecvMessageSize: 4} + so := &ServeOptions{} + for _, o := range c.StandardOptions() { + if err := o(so); err != nil { + t.Fatalf("StandardOptions() with --insecure: unexpected error: %v", err) + } + } + if so.Credentials == nil { + t.Fatal("StandardOptions() with --insecure: expected insecure credentials") + } +} diff --git a/go.mod b/go.mod index ac17acc..3ab529d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/crossplane/function-sdk-go go 1.26.7 require ( + github.com/alecthomas/kong v1.16.1 github.com/bufbuild/buf v1.74.0 github.com/crossplane/crossplane-runtime/v2 v2.4.2 github.com/crossplane/crossplane/apis/v2 v2.4.2 diff --git a/go.sum b/go.sum index a2654dc..6e5b374 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,12 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= +github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -175,6 +181,8 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/E github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= diff --git a/sdk.go b/sdk.go index 816bc95..1168ff3 100644 --- a/sdk.go +++ b/sdk.go @@ -48,6 +48,7 @@ const ( DefaultNetwork = "tcp" DefaultAddress = ":9443" DefaultMaxRecvMsgSize = 1024 * 1024 * 4 + DefaultMaxSendMsgSize = 1024 * 1024 * 4 DefaultMetricsAddress = ":8080" ) @@ -56,6 +57,7 @@ type ServeOptions struct { Network string Address string MaxRecvMsgSize int + MaxSendMsgSize int Credentials credentials.TransportCredentials HealthServer healthgrpc.HealthServer @@ -142,6 +144,15 @@ func MaxRecvMessageSize(sz int) ServeOption { } } +// MaxSendMessageSize returns a ServeOption to set the max message size in bytes the server can send. +// If this is not set, gRPC uses the default limit. +func MaxSendMessageSize(sz int) ServeOption { + return func(o *ServeOptions) error { + o.MaxSendMsgSize = sz + return nil + } +} + // WithHealthServer lets the server start with a health server that can be called // to verify that the server is ready to accept connections. // @@ -191,6 +202,7 @@ func Serve(fn v1.FunctionRunnerServiceServer, o ...ServeOption) error { Network: DefaultNetwork, Address: DefaultAddress, MaxRecvMsgSize: DefaultMaxRecvMsgSize, + MaxSendMsgSize: DefaultMaxSendMsgSize, MetricsAddress: DefaultMetricsAddress, MetricsRegistry: prometheus.DefaultRegisterer.(*prometheus.Registry), // Use default registry } @@ -214,6 +226,7 @@ func Serve(fn v1.FunctionRunnerServiceServer, o ...ServeOption) error { // Create server options serverOpts := []grpc.ServerOption{ grpc.MaxRecvMsgSize(so.MaxRecvMsgSize), + grpc.MaxSendMsgSize(so.MaxSendMsgSize), grpc.Creds(so.Credentials), }