From a631073b527291b62cd492ca8fc861cce1d11247 Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Tue, 9 Dec 2025 23:21:39 +1300 Subject: [PATCH 1/7] WIP integration testing test bench --- cmd/rpc/main.go | 53 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 ++ iac/go-demo/go.mod | 10 +++++---- iac/go-demo/go.sum | 16 ++++++++------ iac/go-demo/main.go | 12 +++++----- 6 files changed, 77 insertions(+), 17 deletions(-) create mode 100644 cmd/rpc/main.go diff --git a/cmd/rpc/main.go b/cmd/rpc/main.go new file mode 100644 index 0000000..1867ada --- /dev/null +++ b/cmd/rpc/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/rpc" + "os" + "time" + + "github.com/aws/aws-lambda-go/lambda/messages" +) + +var port = flag.Int("port", 8001, "Port that the lambda is listening on. Should be equal to the _LAMBDA_SERVER_PORT env var of the Golang lambda running.") +var stdinIsPayload = flag.Bool("stdin-is-payload", false, "Use STDIN as the payload of the request. All other invoke parameters will be left empty") +var deadline = flag.Int64("deadline", 1, "Lambda invocation deadline (in seconds). Used only if stdin-is-payload is enabled") + +// echo '{"args":["-a"]}' | ./main --stdin-is-payload --port=8001 | jq -r .Payload | base64 -d +func main() { + flag.Parse() + + client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%d", *port)) + if err != nil { + panic(err) + } + + args := messages.InvokeRequest{} + if *stdinIsPayload { + buf := bytes.Buffer{} + if _, err := io.Copy(&buf, os.Stdin); err != nil { + panic(err) + } + + args.Payload = buf.Bytes() + args.Deadline.Seconds = time.Now().Unix() + *deadline + } else { + // Assume stdin contains a JSON-encoded InvokeRequest. + if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { + panic(err) + } + } + + var reply messages.InvokeResponse + if err := client.Call("Function.Invoke", args, &reply); err != nil { + panic(err) + } + + if err := json.NewEncoder(os.Stdout).Encode(reply); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod index bb9cadb..4f66fb2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/aws/aws-lambda-go v1.51.0 // indirect github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect diff --git a/go.sum b/go.sum index abf39ef..fc6b2b7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/JayJamieson/go-lambda-invoke v0.0.0-20241203104456-7a8a6587f398 h1:AKAgccffTx3xYMAf6Bdcrsw4vSbxNcb1rYaAZZ7CFms= github.com/JayJamieson/go-lambda-invoke v0.0.0-20241203104456-7a8a6587f398/go.mod h1:kFa+IKA/gJF2uXa7AZhzW9bk1lNmF4TypeGWA+6bm2w= +github.com/aws/aws-lambda-go v1.51.0 h1:/THH60NjiAs3K5TWet3Gx5w8MdR7oPOQH9utaKYY1JQ= +github.com/aws/aws-lambda-go v1.51.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= diff --git a/iac/go-demo/go.mod b/iac/go-demo/go.mod index 5f686b5..12eee4a 100644 --- a/iac/go-demo/go.mod +++ b/iac/go-demo/go.mod @@ -1,14 +1,16 @@ module go-demo -go 1.22.0 +go 1.23 + +toolchain go1.24.4 require ( - github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7 + github.com/JayJamieson/cobra-lambda v0.1.1 github.com/aws/aws-lambda-go v1.47.0 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.10.2 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/iac/go-demo/go.sum b/iac/go-demo/go.sum index c70275b..b5a1935 100644 --- a/iac/go-demo/go.sum +++ b/iac/go-demo/go.sum @@ -1,8 +1,8 @@ -github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7 h1:7LH1KZKjHhbou3m4T+4eWoBdERbbh0yKgkwpWTdTEV0= -github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7/go.mod h1:QyPjx0bT+o+7oVw3LNTjwrrAka4A+HIIiSF1BOXoNNQ= +github.com/JayJamieson/cobra-lambda v0.1.1 h1:6n1OelK4nMX1/Stvis/JKZ4m8Kq30LgTH9/rye2ScWo= +github.com/JayJamieson/cobra-lambda v0.1.1/go.mod h1:QyPjx0bT+o+7oVw3LNTjwrrAka4A+HIIiSF1BOXoNNQ= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -10,12 +10,14 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/iac/go-demo/main.go b/iac/go-demo/main.go index 5c20914..7d3b5b1 100644 --- a/iac/go-demo/main.go +++ b/iac/go-demo/main.go @@ -22,19 +22,19 @@ var rootCmd = &cobra.Command{ } func Handler(ctx context.Context, event json.RawMessage) (any, error) { - - args := make([]string, 0, 10) - err := json.Unmarshal(event, &args) + args, err := wrapper.UnmarshalEvent(event) if err != nil { return nil, err } - w := wrapper.NewCobraLambda(ctx, rootCmd) - result, err := w.Execute(args) + w := wrapper.NewCobraLambdaCLI(ctx, rootCmd) + result, err := w.Execute(args.Args) + // TODO: implement err != nil checks before deserializing return map[string]any{ - "stdout": result.Output, + "stdout": result.Stdout, + "error": err.Error(), }, nil } From baa8a0918a447eeccc1044cfcb4eccb0d696001a Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:59:17 +1300 Subject: [PATCH 2/7] WIP --- cmd/rpc/main.go | 58 ++++++++++++++++++++---------------------- iac/go-demo/main.go | 10 ++++++-- main.go | 2 +- types.go | 5 ---- wrapper/lambda_test.go | 10 ++++---- wrapper/wrapper.go | 11 ++++---- 6 files changed, 48 insertions(+), 48 deletions(-) delete mode 100644 types.go diff --git a/cmd/rpc/main.go b/cmd/rpc/main.go index 1867ada..ecd5741 100644 --- a/cmd/rpc/main.go +++ b/cmd/rpc/main.go @@ -1,53 +1,51 @@ package main import ( - "bytes" "encoding/json" - "flag" "fmt" - "io" "net/rpc" "os" "time" + "github.com/JayJamieson/cobra-lambda/wrapper" "github.com/aws/aws-lambda-go/lambda/messages" ) -var port = flag.Int("port", 8001, "Port that the lambda is listening on. Should be equal to the _LAMBDA_SERVER_PORT env var of the Golang lambda running.") -var stdinIsPayload = flag.Bool("stdin-is-payload", false, "Use STDIN as the payload of the request. All other invoke parameters will be left empty") -var deadline = flag.Int64("deadline", 1, "Lambda invocation deadline (in seconds). Used only if stdin-is-payload is enabled") - -// echo '{"args":["-a"]}' | ./main --stdin-is-payload --port=8001 | jq -r .Payload | base64 -d func main() { - flag.Parse() + client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%d", 8001)) - client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%d", *port)) if err != nil { - panic(err) + fmt.Printf("%v\n", err) + os.Exit(1) } - args := messages.InvokeRequest{} - if *stdinIsPayload { - buf := bytes.Buffer{} - if _, err := io.Copy(&buf, os.Stdin); err != nil { - panic(err) - } - - args.Payload = buf.Bytes() - args.Deadline.Seconds = time.Now().Unix() + *deadline - } else { - // Assume stdin contains a JSON-encoded InvokeRequest. - if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { - panic(err) - } + argsEvent := wrapper.CobraLambdaEvent{Args: os.Args[1:]} + payload, err := json.Marshal(argsEvent) + + if err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) } - var reply messages.InvokeResponse - if err := client.Call("Function.Invoke", args, &reply); err != nil { - panic(err) + args := messages.InvokeRequest{ + Payload: payload, + Deadline: messages.InvokeRequest_Timestamp{ + Seconds: time.Now().Unix() + 10, + }, } - if err := json.NewEncoder(os.Stdout).Encode(reply); err != nil { - panic(err) + invokeResponse := &messages.InvokeResponse{} + if err := client.Call("Function.Invoke", args, &invokeResponse); err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) } + + output := &wrapper.CobraLambdaOutput{} + + if err := json.Unmarshal(invokeResponse.Payload, &output); err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } + + fmt.Print(output.Stdout) } diff --git a/iac/go-demo/main.go b/iac/go-demo/main.go index 7d3b5b1..b4cbd41 100644 --- a/iac/go-demo/main.go +++ b/iac/go-demo/main.go @@ -31,10 +31,16 @@ func Handler(ctx context.Context, event json.RawMessage) (any, error) { w := wrapper.NewCobraLambdaCLI(ctx, rootCmd) result, err := w.Execute(args.Args) - // TODO: implement err != nil checks before deserializing + if err != nil { + return map[string]any{ + "stdout": result.Stdout, + "error": err.Error(), + }, nil + } + return map[string]any{ "stdout": result.Stdout, - "error": err.Error(), + "error": nil, }, nil } diff --git a/main.go b/main.go index 1e4aa9b..1f6b12e 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,7 @@ func main() { os.Exit(1) } - output := &ExecutionOutput{} + output := &wrapper.CobraLambdaOutput{} err = lambda.InvokeSync(ctx, client, &lambda.InvokeInput{ Name: funcName, diff --git a/types.go b/types.go deleted file mode 100644 index 4a823e4..0000000 --- a/types.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -type ExecutionOutput struct { - Stdout string `json:"stdout"` -} diff --git a/wrapper/lambda_test.go b/wrapper/lambda_test.go index 42d7b43..bea0058 100644 --- a/wrapper/lambda_test.go +++ b/wrapper/lambda_test.go @@ -44,7 +44,7 @@ func TestNewCobrLambdaHandler_BasicExecution(t *testing.T) { } // Check the result is an OutputCapture - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -115,7 +115,7 @@ func TestNewCobrLambdaHandler_CommandError(t *testing.T) { } // Result should still contain output capture - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture even on error, got %T", result) } @@ -163,7 +163,7 @@ func TestNewCobrLambdaHandler_WithSubcommands(t *testing.T) { t.Fatalf("Handler returned error: %v", err) } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -210,7 +210,7 @@ func TestNewCobrLambdaHandler_EmptyArgs(t *testing.T) { t.Error("Command was not executed") } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -261,7 +261,7 @@ func TestNewCobrLambdaHandler_ContextPropagation(t *testing.T) { t.Error("Context was not properly propagated to command") } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } diff --git a/wrapper/wrapper.go b/wrapper/wrapper.go index 2c7f041..d28c7c3 100644 --- a/wrapper/wrapper.go +++ b/wrapper/wrapper.go @@ -9,9 +9,10 @@ import ( "github.com/spf13/cobra" ) -// OutputCapture holds captured output from both Cobra command and os.Stdout/Stderr -type OutputCapture struct { +// CobraLambdaOutput holds captured output from both Cobra command and os.Stdout/Stderr +type CobraLambdaOutput struct { Stdout string `json:"stdout"` + Error string `json:"error"` } type CobraLambda struct { @@ -35,7 +36,7 @@ func NewCobraLambdaCLI(ctx context.Context, cmd *cobra.Command) *CobraLambda { // Execute runs the Cobra command with the given arguments and captures all output // This method is thread-safe and will restore os.Stdout/Stderr even if the command panics // Note: Only one execution can run at a time per wrapper instance to avoid interference -func (w *CobraLambda) Execute(args []string) (*OutputCapture, error) { +func (w *CobraLambda) Execute(args []string) (*CobraLambdaOutput, error) { w.mu.Lock() defer w.mu.Unlock() @@ -100,14 +101,14 @@ func (w *CobraLambda) Execute(args []string) (*OutputCapture, error) { os.Stdout = w.originalStdout os.Stderr = w.originalStderr - return &OutputCapture{ + return &CobraLambdaOutput{ Stdout: sharedBuffer.String(), }, execErr } // ExecuteWithContext is a convenience method that runs Execute with the provided context overriding // context passed in from NewCobraLambda and restoring to original context after execution -func (w *CobraLambda) ExecuteContext(ctx context.Context, args []string) (*OutputCapture, error) { +func (w *CobraLambda) ExecuteContext(ctx context.Context, args []string) (*CobraLambdaOutput, error) { w.cmd.SetContext(ctx) output, err := w.Execute(args) w.cmd.SetContext(w.ctx) From 901d5c54e92361ce3b9e390f528de83314ed303b Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:00:17 +1300 Subject: [PATCH 3/7] WIP --- cmd/rpc/main.go | 123 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 9 deletions(-) diff --git a/cmd/rpc/main.go b/cmd/rpc/main.go index ecd5741..8aa8529 100644 --- a/cmd/rpc/main.go +++ b/cmd/rpc/main.go @@ -3,27 +3,92 @@ package main import ( "encoding/json" "fmt" + "net" "net/rpc" "os" + "os/exec" + "syscall" "time" "github.com/JayJamieson/cobra-lambda/wrapper" "github.com/aws/aws-lambda-go/lambda/messages" ) +const ( + lambdaServerPort = "8001" + helpMessage = `Usage: rpc [lambda-binary-path] [args...] + +Runs a Go Lambda function locally over RPC. + +Arguments: + lambda-binary-path Path to the compiled Lambda binary + args... Arguments to pass to the Lambda function + +The Lambda binary will be started with _LAMBDA_SERVER_PORT=8001 and invoked over RPC. +` +) + func main() { - client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%d", 8001)) + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, helpMessage) + os.Exit(1) + } + + if os.Args[1] == "-h" || os.Args[1] == "--help" { + fmt.Print(helpMessage) + os.Exit(0) + } + + lambdaPath := os.Args[1] + lambdaArgs := []string{} + if len(os.Args) > 2 { + lambdaArgs = os.Args[2:] + } + + // Check if lambda binary exists + if _, err := os.Stat(lambdaPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: Lambda binary not found at %s\n", lambdaPath) + os.Exit(1) + } + + // Start the Lambda process with _LAMBDA_SERVER_PORT environment variable + cmd := exec.Command("go", "run", lambdaPath) + cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", lambdaServerPort)) + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to start Lambda process: %v\n", err) + os.Exit(1) + } + // Ensure we kill the process on exit + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }() + + // Wait for the Lambda server to be ready + if err := waitForServer(lambdaServerPort, 5*time.Second); err != nil { + fmt.Fprintf(os.Stderr, "Lambda server failed to start: %v\n", err) + os.Exit(1) + } + + // Connect to the Lambda RPC server + client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%s", lambdaServerPort)) if err != nil { - fmt.Printf("%v\n", err) + fmt.Fprintf(os.Stderr, "Failed to connect to Lambda server: %v\n", err) os.Exit(1) } + defer client.Close() - argsEvent := wrapper.CobraLambdaEvent{Args: os.Args[1:]} + // Prepare the invocation request + argsEvent := wrapper.CobraLambdaEvent{Args: lambdaArgs} payload, err := json.Marshal(argsEvent) - if err != nil { - fmt.Printf("%v\n", err) + fmt.Fprintf(os.Stderr, "Failed to marshal event: %v\n", err) os.Exit(1) } @@ -34,18 +99,58 @@ func main() { }, } + // Invoke the Lambda function invokeResponse := &messages.InvokeResponse{} if err := client.Call("Function.Invoke", args, &invokeResponse); err != nil { - fmt.Printf("%v\n", err) + fmt.Fprintf(os.Stderr, "Lambda invocation failed: %v\n", err) os.Exit(1) } - output := &wrapper.CobraLambdaOutput{} + // Check for Lambda execution errors + if invokeResponse.Error != nil { + fmt.Fprintf(os.Stderr, "Lambda execution error: %s\n", invokeResponse.Error.Message) + os.Exit(1) + } + // Parse the response + output := &wrapper.CobraLambdaOutput{} if err := json.Unmarshal(invokeResponse.Payload, &output); err != nil { - fmt.Printf("%v\n", err) + fmt.Fprintf(os.Stderr, "Failed to unmarshal response: %v\n", err) os.Exit(1) } - fmt.Print(output.Stdout) + // Print the output + fmt.Print("cli", output.Stdout) + + // Send SIGTERM to the Lambda process + fmt.Println("\nSending SIGTERM to Lambda process...") + if err := cmd.Process.Signal(syscall.SIGKILL); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err) + } + + // Wait a moment for the signal to be processed + time.Sleep(100 * time.Millisecond) + + // Try to call Function.Ping after sending SIGTERM + fmt.Println("Attempting to call Function.Ping after SIGTERM...") + pingResponse := &messages.PingResponse{} + if err := client.Call("Function.Ping", messages.PingRequest{}, pingResponse); err != nil { + fmt.Fprintf(os.Stderr, "Function.Ping failed (expected): %v\n", err) + } else { + fmt.Println("Function.Ping succeeded unexpectedly") + } +} + +// waitForServer polls the given port until it's available or timeout is reached +func waitForServer(port string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 100*time.Millisecond) + if err == nil { + conn.Close() + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for server on port %s", port) } From cbfa5ef3fd6687349f87f09a05c365804bb0a4b8 Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:55:36 +1300 Subject: [PATCH 4/7] WIP abstract local cli runner into reusable api --- cli/runner.go | 146 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/rpc/main.go | 99 +++++++++++++++++++++----------- 2 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 cli/runner.go diff --git a/cli/runner.go b/cli/runner.go new file mode 100644 index 0000000..ce3aa3a --- /dev/null +++ b/cli/runner.go @@ -0,0 +1,146 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "syscall" +) + +// RunMode represents how the Lambda function should be executed +type RunMode int + +const ( + // ModeBinary runs a compiled Lambda binary + ModeBinary RunMode = iota + // ModeGoRun uses 'go run' to execute the Lambda function + ModeGoRun +) + +// Runner handles the execution of Lambda functions +type Runner struct { + Mode RunMode + Debug bool + ServerPort string +} + +// CommandConfig contains the parsed command configuration +type CommandConfig struct { + LambdaPath string + LambdaArgs []string +} + +// NewRunner creates a new Runner with the specified mode +func NewRunner(mode RunMode, debug bool, serverPort string) *Runner { + return &Runner{ + Mode: mode, + Debug: debug, + ServerPort: serverPort, + } +} + +// ParseArgs parses remaining arguments after flag parsing based on the run mode +// args should be the result of flag.Args() after flag.Parse() +func (r *Runner) ParseArgs(args []string) (*CommandConfig, error) { + config := &CommandConfig{} + r.Debugf("Parsing arguments: %v", args) + switch r.Mode { + case ModeBinary: + if len(args) < 1 { + return nil, fmt.Errorf("missing lambda path argument") + } + config.LambdaPath = args[0] + if len(args) > 1 { + config.LambdaArgs = args[1:] + } + + case ModeGoRun: + // For go run mode, we expect: -- lambda-path [args...] + if len(args) < 2 { + return nil, fmt.Errorf("missing lambda path argument (expected after '--')") + } + // if args[0] != "--" { + // return nil, fmt.Errorf("expected '--' separator before lambda path, got: %s", args[0]) + // } + config.LambdaPath = args[0] + if len(args) > 2 { + config.LambdaArgs = args[2:] + } + + default: + return nil, fmt.Errorf("unknown run mode: %d", r.Mode) + } + + return config, nil +} + +// CreateCommand creates an exec.Command based on the run mode +func (r *Runner) CreateCommand(config *CommandConfig) (*exec.Cmd, error) { + var cmd *exec.Cmd + + switch r.Mode { + case ModeBinary: + // Check if lambda binary exists + if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { + return nil, fmt.Errorf("lambda binary not found at %s", config.LambdaPath) + } + cmd = exec.Command(config.LambdaPath, config.LambdaArgs...) + + case ModeGoRun: + // Check if lambda source file exists + if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { + return nil, fmt.Errorf("lambda source file not found at %s", config.LambdaPath) + } + // Construct args: go run [lambda-args...] + args := append([]string{"run", config.LambdaPath}, config.LambdaArgs...) + cmd = exec.Command("go", args...) + + default: + return nil, fmt.Errorf("unknown run mode: %d", r.Mode) + } + + // Set up process group for proper signal handling + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + // Add the Lambda server port to environment + cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", r.ServerPort)) + + // Only show stdout/stderr if debug mode is enabled + if r.Debug { + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = nil + cmd.Stderr = nil + } + + return cmd, nil +} + +// KillProcessGroup kills the entire process group +func (r *Runner) KillProcessGroup(cmd *exec.Cmd, signal syscall.Signal) error { + if cmd.Process == nil { + return fmt.Errorf("process not started") + } + + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + return fmt.Errorf("failed to get process group ID: %w", err) + } + + // Kill the entire process group (note the negative sign) + if err := syscall.Kill(-pgid, signal); err != nil { + return fmt.Errorf("failed to send signal %v: %w", signal, err) + } + + return nil +} + +// Debugf prints debug messages if debug mode is enabled +func (r *Runner) Debugf(format string, args ...interface{}) { + if r.Debug { + fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...) + } +} diff --git a/cmd/rpc/main.go b/cmd/rpc/main.go index 8aa8529..f1ce174 100644 --- a/cmd/rpc/main.go +++ b/cmd/rpc/main.go @@ -2,70 +2,99 @@ package main import ( "encoding/json" + "flag" "fmt" "net" "net/rpc" "os" - "os/exec" "syscall" "time" + "github.com/JayJamieson/cobra-lambda/cli" "github.com/JayJamieson/cobra-lambda/wrapper" "github.com/aws/aws-lambda-go/lambda/messages" ) const ( lambdaServerPort = "8001" - helpMessage = `Usage: rpc [lambda-binary-path] [args...] + helpMessage = `Usage: rpc [flags] [lambda-path] [args...] + rpc [flags] -- [lambda-path] [args...] Runs a Go Lambda function locally over RPC. +Flags: + --debug Enable debug logging + --go-run Use 'go run' instead of compiled binary (requires '--' separator) + Arguments: - lambda-binary-path Path to the compiled Lambda binary - args... Arguments to pass to the Lambda function + lambda-path Path to the compiled Lambda binary or source file + args... Arguments to pass to the Lambda function + +Examples: + # Run compiled binary + rpc ./lambda-binary arg1 arg2 + + # Run with go run + rpc --go-run -- cmd/lambda/main.go arg1 arg2 + + # Debug mode + rpc --debug ./lambda-binary The Lambda binary will be started with _LAMBDA_SERVER_PORT=8001 and invoked over RPC. ` ) -func main() { - if len(os.Args) < 2 { - fmt.Fprint(os.Stderr, helpMessage) - os.Exit(1) - } +var ( + debugFlag = flag.Bool("debug", false, "Enable debug logging") + goRunFlag = flag.Bool("go-run", false, "Use 'go run' instead of compiled binary") +) - if os.Args[1] == "-h" || os.Args[1] == "--help" { +func main() { + flag.Usage = func() { fmt.Print(helpMessage) - os.Exit(0) } + flag.Parse() - lambdaPath := os.Args[1] - lambdaArgs := []string{} - if len(os.Args) > 2 { - lambdaArgs = os.Args[2:] + // Determine run mode + mode := cli.ModeBinary + if *goRunFlag { + mode = cli.ModeGoRun } - // Check if lambda binary exists - if _, err := os.Stat(lambdaPath); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: Lambda binary not found at %s\n", lambdaPath) + // Create runner + runner := cli.NewRunner(mode, *debugFlag, lambdaServerPort) + + // Parse arguments based on mode (use flag.Args() which contains non-flag arguments) + config, err := runner.ParseArgs(flag.Args()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + flag.Usage() os.Exit(1) } - // Start the Lambda process with _LAMBDA_SERVER_PORT environment variable - cmd := exec.Command("go", "run", lambdaPath) - cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", lambdaServerPort)) - cmd.Stdout = nil - cmd.Stderr = nil + runner.Debugf("Mode: %v", mode) + runner.Debugf("Lambda path: %s", config.LambdaPath) + runner.Debugf("Lambda args: %v", config.LambdaArgs) + + // Create the command + cmd, err := runner.CreateCommand(config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } if err := cmd.Start(); err != nil { fmt.Fprintf(os.Stderr, "Failed to start Lambda process: %v\n", err) os.Exit(1) } + runner.Debugf("Lambda process started with PID: %d", cmd.Process.Pid) + // Ensure we kill the process on exit defer func() { if cmd.Process != nil { - _ = cmd.Process.Kill() + runner.Debugf("Cleaning up Lambda process...") + _ = runner.KillProcessGroup(cmd, syscall.SIGKILL) _ = cmd.Wait() } }() @@ -76,6 +105,8 @@ func main() { os.Exit(1) } + runner.Debugf("Lambda server is ready on port %s", lambdaServerPort) + // Connect to the Lambda RPC server client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%s", lambdaServerPort)) if err != nil { @@ -84,8 +115,10 @@ func main() { } defer client.Close() + runner.Debugf("Connected to Lambda RPC server") + // Prepare the invocation request - argsEvent := wrapper.CobraLambdaEvent{Args: lambdaArgs} + argsEvent := wrapper.CobraLambdaEvent{Args: config.LambdaArgs} payload, err := json.Marshal(argsEvent) if err != nil { fmt.Fprintf(os.Stderr, "Failed to marshal event: %v\n", err) @@ -100,6 +133,7 @@ func main() { } // Invoke the Lambda function + runner.Debugf("Invoking Lambda function...") invokeResponse := &messages.InvokeResponse{} if err := client.Call("Function.Invoke", args, &invokeResponse); err != nil { fmt.Fprintf(os.Stderr, "Lambda invocation failed: %v\n", err) @@ -120,24 +154,25 @@ func main() { } // Print the output - fmt.Print("cli", output.Stdout) + fmt.Print(output.Stdout) // Send SIGTERM to the Lambda process - fmt.Println("\nSending SIGTERM to Lambda process...") - if err := cmd.Process.Signal(syscall.SIGKILL); err != nil { + runner.Debugf("Sending SIGTERM to Lambda process...") + if err := runner.KillProcessGroup(cmd, syscall.SIGTERM); err != nil { fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err) } // Wait a moment for the signal to be processed - time.Sleep(100 * time.Millisecond) + time.Sleep(500 * time.Millisecond) // Try to call Function.Ping after sending SIGTERM - fmt.Println("Attempting to call Function.Ping after SIGTERM...") + runner.Debugf("Attempting to call Function.Ping after SIGTERM...") pingResponse := &messages.PingResponse{} + if err := client.Call("Function.Ping", messages.PingRequest{}, pingResponse); err != nil { - fmt.Fprintf(os.Stderr, "Function.Ping failed (expected): %v\n", err) + runner.Debugf("Function.Ping failed (expected): %v", err) } else { - fmt.Println("Function.Ping succeeded unexpectedly") + runner.Debugf("Function.Ping succeeded unexpectedly") } } From 55a29fdf62b36f49daafae518529f5339df86961 Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:41:21 +1300 Subject: [PATCH 5/7] refactored project structure and added local invoker/debugger cli --- flag.go => cli/flag/flag.go | 18 ++--------- cli/runner.go | 61 +++++++++++++----------------------- main.go => cmd/clctl/main.go | 19 +++++++++-- cmd/{rpc => cldebug}/main.go | 12 +------ 4 files changed, 41 insertions(+), 69 deletions(-) rename flag.go => cli/flag/flag.go (77%) rename main.go => cmd/clctl/main.go (63%) rename cmd/{rpc => cldebug}/main.go (91%) diff --git a/flag.go b/cli/flag/flag.go similarity index 77% rename from flag.go rename to cli/flag/flag.go index f696c88..64098b4 100644 --- a/flag.go +++ b/cli/flag/flag.go @@ -1,4 +1,4 @@ -package main +package flag import ( "errors" @@ -7,21 +7,7 @@ import ( var ErrHelp = errors.New("flag: help requested") -var HelpMessage = `Cobra Lambda -Usage of cobra-lambda: - With arguements: - - cl - cobra-lambda --name [function name] -arg1 123 -arg2 foo --arg3 - - Without arguments: - cl - cobra-lambda --name [function name] - -Arguments after --name will be forwarded to remote cli named [function name] -` - -func parseFuncName(args []string) (string, bool, error) { +func ParseFuncName(args []string) (string, bool, error) { if len(args) == 0 { return "", false, nil } diff --git a/cli/runner.go b/cli/runner.go index ce3aa3a..c91ccf2 100644 --- a/cli/runner.go +++ b/cli/runner.go @@ -4,10 +4,10 @@ import ( "fmt" "os" "os/exec" + "strings" "syscall" ) -// RunMode represents how the Lambda function should be executed type RunMode int const ( @@ -17,20 +17,17 @@ const ( ModeGoRun ) -// Runner handles the execution of Lambda functions type Runner struct { Mode RunMode Debug bool ServerPort string } -// CommandConfig contains the parsed command configuration type CommandConfig struct { LambdaPath string LambdaArgs []string } -// NewRunner creates a new Runner with the specified mode func NewRunner(mode RunMode, debug bool, serverPort string) *Runner { return &Runner{ Mode: mode, @@ -43,28 +40,31 @@ func NewRunner(mode RunMode, debug bool, serverPort string) *Runner { // args should be the result of flag.Args() after flag.Parse() func (r *Runner) ParseArgs(args []string) (*CommandConfig, error) { config := &CommandConfig{} + r.Debugf("Parsing arguments: %v", args) + + if len(args) < 1 { + return nil, fmt.Errorf("missing lambda path argument") + } + switch r.Mode { case ModeBinary: - if len(args) < 1 { - return nil, fmt.Errorf("missing lambda path argument") - } + config.LambdaPath = args[0] if len(args) > 1 { config.LambdaArgs = args[1:] } case ModeGoRun: - // For go run mode, we expect: -- lambda-path [args...] - if len(args) < 2 { - return nil, fmt.Errorf("missing lambda path argument (expected after '--')") + isGoFile := strings.HasSuffix(args[0], ".go") + + if !isGoFile { + return nil, fmt.Errorf("invalid go file: %s", args[0]) } - // if args[0] != "--" { - // return nil, fmt.Errorf("expected '--' separator before lambda path, got: %s", args[0]) - // } + config.LambdaPath = args[0] - if len(args) > 2 { - config.LambdaArgs = args[2:] + if len(args) > 1 { + config.LambdaArgs = args[1:] } default: @@ -74,52 +74,35 @@ func (r *Runner) ParseArgs(args []string) (*CommandConfig, error) { return config, nil } -// CreateCommand creates an exec.Command based on the run mode func (r *Runner) CreateCommand(config *CommandConfig) (*exec.Cmd, error) { var cmd *exec.Cmd + if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { + return nil, fmt.Errorf("not found at %s", config.LambdaPath) + } + switch r.Mode { case ModeBinary: - // Check if lambda binary exists - if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { - return nil, fmt.Errorf("lambda binary not found at %s", config.LambdaPath) - } cmd = exec.Command(config.LambdaPath, config.LambdaArgs...) case ModeGoRun: - // Check if lambda source file exists - if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { - return nil, fmt.Errorf("lambda source file not found at %s", config.LambdaPath) - } // Construct args: go run [lambda-args...] args := append([]string{"run", config.LambdaPath}, config.LambdaArgs...) cmd = exec.Command("go", args...) - default: return nil, fmt.Errorf("unknown run mode: %d", r.Mode) } - // Set up process group for proper signal handling + // needed in order to properly kill lambda process when run with go run cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, } - // Add the Lambda server port to environment cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", r.ServerPort)) - // Only show stdout/stderr if debug mode is enabled - if r.Debug { - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - } else { - cmd.Stdout = nil - cmd.Stderr = nil - } - return cmd, nil } -// KillProcessGroup kills the entire process group func (r *Runner) KillProcessGroup(cmd *exec.Cmd, signal syscall.Signal) error { if cmd.Process == nil { return fmt.Errorf("process not started") @@ -130,7 +113,6 @@ func (r *Runner) KillProcessGroup(cmd *exec.Cmd, signal syscall.Signal) error { return fmt.Errorf("failed to get process group ID: %w", err) } - // Kill the entire process group (note the negative sign) if err := syscall.Kill(-pgid, signal); err != nil { return fmt.Errorf("failed to send signal %v: %w", signal, err) } @@ -138,8 +120,7 @@ func (r *Runner) KillProcessGroup(cmd *exec.Cmd, signal syscall.Signal) error { return nil } -// Debugf prints debug messages if debug mode is enabled -func (r *Runner) Debugf(format string, args ...interface{}) { +func (r *Runner) Debugf(format string, args ...any) { if r.Debug { fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...) } diff --git a/main.go b/cmd/clctl/main.go similarity index 63% rename from main.go rename to cmd/clctl/main.go index 1f6b12e..9ecf9fc 100644 --- a/main.go +++ b/cmd/clctl/main.go @@ -6,10 +6,25 @@ import ( "fmt" "os" + "github.com/JayJamieson/cobra-lambda/cli/flag" "github.com/JayJamieson/cobra-lambda/wrapper" lambda "github.com/JayJamieson/go-lambda-invoke" ) +var HelpMessage = `Cobra Lambda +Usage of cobra-lambda: + With arguements: + + clctl + cobra-lambda --name [function name] -arg1 123 -arg2 foo --arg3 + + Without arguments: + clctl + cobra-lambda --name [function name] + +Arguments after --name will be forwarded to remote cli named [function name] +` + func main() { ctx := context.Background() @@ -25,9 +40,9 @@ func main() { os.Exit(1) } - funcName, ok, err := parseFuncName(os.Args[1:]) + funcName, ok, err := flag.ParseFuncName(os.Args[1:]) - if err != nil && errors.Is(err, ErrHelp) { + if err != nil && errors.Is(err, flag.ErrHelp) { fmt.Print(HelpMessage) os.Exit(0) } diff --git a/cmd/rpc/main.go b/cmd/cldebug/main.go similarity index 91% rename from cmd/rpc/main.go rename to cmd/cldebug/main.go index f1ce174..a91c2ff 100644 --- a/cmd/rpc/main.go +++ b/cmd/cldebug/main.go @@ -99,7 +99,6 @@ func main() { } }() - // Wait for the Lambda server to be ready if err := waitForServer(lambdaServerPort, 5*time.Second); err != nil { fmt.Fprintf(os.Stderr, "Lambda server failed to start: %v\n", err) os.Exit(1) @@ -107,7 +106,6 @@ func main() { runner.Debugf("Lambda server is ready on port %s", lambdaServerPort) - // Connect to the Lambda RPC server client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%s", lambdaServerPort)) if err != nil { fmt.Fprintf(os.Stderr, "Failed to connect to Lambda server: %v\n", err) @@ -117,7 +115,6 @@ func main() { runner.Debugf("Connected to Lambda RPC server") - // Prepare the invocation request argsEvent := wrapper.CobraLambdaEvent{Args: config.LambdaArgs} payload, err := json.Marshal(argsEvent) if err != nil { @@ -132,7 +129,6 @@ func main() { }, } - // Invoke the Lambda function runner.Debugf("Invoking Lambda function...") invokeResponse := &messages.InvokeResponse{} if err := client.Call("Function.Invoke", args, &invokeResponse); err != nil { @@ -146,26 +142,21 @@ func main() { os.Exit(1) } - // Parse the response output := &wrapper.CobraLambdaOutput{} if err := json.Unmarshal(invokeResponse.Payload, &output); err != nil { fmt.Fprintf(os.Stderr, "Failed to unmarshal response: %v\n", err) os.Exit(1) } - // Print the output fmt.Print(output.Stdout) - // Send SIGTERM to the Lambda process runner.Debugf("Sending SIGTERM to Lambda process...") if err := runner.KillProcessGroup(cmd, syscall.SIGTERM); err != nil { fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err) } - // Wait a moment for the signal to be processed - time.Sleep(500 * time.Millisecond) + time.Sleep(200 * time.Millisecond) - // Try to call Function.Ping after sending SIGTERM runner.Debugf("Attempting to call Function.Ping after SIGTERM...") pingResponse := &messages.PingResponse{} @@ -176,7 +167,6 @@ func main() { } } -// waitForServer polls the given port until it's available or timeout is reached func waitForServer(port string, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { From d3653f290ed99f1d7501ac4ee97ae70d74b3d4bf Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:52:54 +1300 Subject: [PATCH 6/7] update readme --- README.md | 102 ++++++++++++++++++++++++++++++++++++-------- cmd/cldebug/main.go | 4 +- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 85514e0..1bc8dbd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Perfect for running CLI tools, automation scripts, or administrative commands se Install the `cobra-lambda` CLI tool to invoke remote Cobra applications hosted in Lambda: ```bash -go install github.com/JayJamieson/cobra-lambda@latest +go install github.com/JayJamieson/cobra-lambda/cmd/clctl@latest ``` Or build from source: @@ -29,7 +29,7 @@ Or build from source: ```bash git clone https://github.com/JayJamieson/cobra-lambda.git cd cobra-lambda -go build -o cobra-lambda . +go build -o clctl cmd/clctl/main.go ``` ### Go Library (for wrapping Cobra apps in Lambda) @@ -106,13 +106,13 @@ Use the CLI client to invoke your Lambda-hosted Cobra app: ```bash # Basic invocation -cobra-lambda --name my-lambda-function +clctl --name my-lambda-function # With arguments and flags -cobra-lambda --name my-lambda-function --name Alice +clctl --name my-lambda-function --name Alice # Pass any Cobra CLI arguments -cobra-lambda --name my-lambda-function subcommand --flag value arg1 arg2 +clctl --name my-lambda-function subcommand --flag value arg1 arg2 ``` The CLI forwards all arguments after `--name [function-name]` to your Lambda function. @@ -187,26 +187,26 @@ For concurrent executions, create separate wrapper instances per goroutine, or r ## CLI Client Usage -The `cobra-lambda` CLI tool invokes remote Lambda functions: +The `clctl` CLI tool invokes remote Lambda functions: ```bash -cobra-lambda --name [cobra-args...] +clctl --name [cobra-args...] ``` ### Examples ```bash # Simple invocation -cobra-lambda --name my-cli-app +clctl --name my-cli-app # With flags -cobra-lambda --name my-cli-app --verbose --output json +clctl --name my-cli-app --verbose --output json # With subcommands -cobra-lambda --name my-cli-app deploy --environment prod +clctl --name my-cli-app deploy --environment prod # Help -cobra-lambda --help +clctl --help ``` ### AWS Configuration @@ -217,18 +217,86 @@ The CLI uses the AWS SDK for Go v2 and respects standard AWS configuration: - Region from `AWS_REGION` environment variable or AWS config - IAM permissions required: `lambda:InvokeFunction` -## Testing +## Local Development with cldebug -Run the wrapper package test suite: +The `cldebug` cli allows you to test and debug your Lambda Cobra CLI applications locally without deploying to AWS. It starts your Lambda function as an RPC server and invokes it locally, simulating the Lambda runtime environment. + +### Installing cldebug + +Install `cldebug` cli: + +```bash +go install github.com/JayJamieson/cobra-lambda/cmd/cldebug@latest +``` + +Or build from source: + +```bash +git clone https://github.com/JayJamieson/cobra-lambda.git +cd cobra-lambda +go build -o cldebug cmd/cldebug/main.go +``` + +### Usage Examples + +#### 1. Invoke a compiled Lambda binary + +First, build your Lambda function: + +```bash +go build -o bootstrap ./iac/go-demo/main.go +``` + +Then invoke it locally with `cldebug`: ```bash -cd wrapper -go test -v +cldebug ./bootstrap arg1 arg2 --flag value +``` + +#### 2. Invoke a Go file directly with go run + +For rapid development, you can run your Lambda source file directly without building: + +```bash +cldebug --go-run ./iac/go-demo/main.go arg1 arg2 --flag value +``` + +#### 3. Debug mode + +Enable debug logging to see what's happening under the hood: + +```bash +cldebug --debug ./bootstrap arg1 arg2 +``` + +This will show: +- Lambda process startup +- RPC connection details +- Invocation payload +- Process cleanup steps + +### Full Example + +Using the example from `iac/go-demo/`: + +```bash +# Build the Lambda function +cd iac/go-demo +go build -o bootstrap main.go + +# Test locally with cldebug +cldebug ./bootstrap hello world + +# Or use go run for faster iteration +cldebug --go-run main.go hello world + +# With debug output +cldebug --debug ./bootstrap hello world ``` ## How It Works -1. **Client Side**: The `cobra-lambda` CLI tool extracts the `--name` flag to identify the target Lambda function, then forwards all remaining arguments as a JSON array payload. +1. **Client Side**: The `clctl` CLI tool extracts the `--name` flag to identify the target Lambda function, then forwards all remaining arguments as a JSON array payload. 2. **Lambda Side**: The wrapper package: - Intercepts `os.Stdout` and `os.Stderr` using pipes @@ -239,7 +307,7 @@ go test -v 3. **Response**: The client displays the returned output, making the remote execution feel like a local CLI invocation. -## Examples in This Repository +## Examples See the `iac/` directory for complete examples: diff --git a/cmd/cldebug/main.go b/cmd/cldebug/main.go index a91c2ff..be49f5a 100644 --- a/cmd/cldebug/main.go +++ b/cmd/cldebug/main.go @@ -18,7 +18,7 @@ import ( const ( lambdaServerPort = "8001" helpMessage = `Usage: rpc [flags] [lambda-path] [args...] - rpc [flags] -- [lambda-path] [args...] + rpc [flags] [lambda-path] [args...] Runs a Go Lambda function locally over RPC. @@ -35,7 +35,7 @@ Examples: rpc ./lambda-binary arg1 arg2 # Run with go run - rpc --go-run -- cmd/lambda/main.go arg1 arg2 + rpc --go-run cmd/lambda/main.go arg1 arg2 # Debug mode rpc --debug ./lambda-binary From 99db049a9216f5e1177fbd4e0d2b9af1347f9971 Mon Sep 17 00:00:00 2001 From: jay <38236622+JayJamieson@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:59:33 +1300 Subject: [PATCH 7/7] fix linting --- cmd/cldebug/main.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/cldebug/main.go b/cmd/cldebug/main.go index be49f5a..8fbb204 100644 --- a/cmd/cldebug/main.go +++ b/cmd/cldebug/main.go @@ -111,7 +111,12 @@ func main() { fmt.Fprintf(os.Stderr, "Failed to connect to Lambda server: %v\n", err) os.Exit(1) } - defer client.Close() + + defer func() { + if client != nil { + _ = client.Close() + } + }() runner.Debugf("Connected to Lambda RPC server") @@ -172,7 +177,7 @@ func waitForServer(port string, timeout time.Duration) error { for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 100*time.Millisecond) if err == nil { - conn.Close() + _ = conn.Close() return nil } time.Sleep(50 * time.Millisecond)