Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 85 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ 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:

```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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <function-name> [cobra-args...]
clctl --name <function-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
Expand All @@ -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
Expand All @@ -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:

Expand Down
18 changes: 2 additions & 16 deletions flag.go → cli/flag/flag.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package flag

import (
"errors"
Expand All @@ -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
}
Expand Down
127 changes: 127 additions & 0 deletions cli/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package cli

import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)

type RunMode int

const (
// ModeBinary runs a compiled Lambda binary
ModeBinary RunMode = iota
// ModeGoRun uses 'go run' to execute the Lambda function
ModeGoRun
)

type Runner struct {
Mode RunMode
Debug bool
ServerPort string
}

type CommandConfig struct {
LambdaPath string
LambdaArgs []string
}

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)

if len(args) < 1 {
return nil, fmt.Errorf("missing lambda path argument")
}

switch r.Mode {
case ModeBinary:

config.LambdaPath = args[0]
if len(args) > 1 {
config.LambdaArgs = args[1:]
}

case ModeGoRun:
isGoFile := strings.HasSuffix(args[0], ".go")

if !isGoFile {
return nil, fmt.Errorf("invalid go file: %s", args[0])
}

config.LambdaPath = args[0]
if len(args) > 1 {
config.LambdaArgs = args[1:]
}

default:
return nil, fmt.Errorf("unknown run mode: %d", r.Mode)
}

return config, nil
}

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:
cmd = exec.Command(config.LambdaPath, config.LambdaArgs...)

case ModeGoRun:
// Construct args: go run <lambda-path> [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)
}

// needed in order to properly kill lambda process when run with go run
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}

cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", r.ServerPort))

return cmd, nil
}

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)
}

if err := syscall.Kill(-pgid, signal); err != nil {
return fmt.Errorf("failed to send signal %v: %w", signal, err)
}

return nil
}

func (r *Runner) Debugf(format string, args ...any) {
if r.Debug {
fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...)
}
}
21 changes: 18 additions & 3 deletions main.go → cmd/clctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
}
Expand All @@ -37,7 +52,7 @@ func main() {
os.Exit(1)
}

output := &ExecutionOutput{}
output := &wrapper.CobraLambdaOutput{}

err = lambda.InvokeSync(ctx, client, &lambda.InvokeInput{
Name: funcName,
Expand Down
Loading