Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Feature parity with Rust launcher #2

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.DS_Store
bin/*
!**/.gitkeep
config.json
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# n8n-launcher

CLI utility to securely manage [n8n task runners](https://docs.n8n.io/PENDING).

```sh
./n8n-launcher launch -type javascript
2024/11/15 13:53:33 Starting to execute `launch` command...
2024/11/15 13:53:33 Loaded config file loaded with a single runner config
2024/11/15 13:53:33 Changed into working directory: /Users/ivov/Development/n8n-launcher/bin
2024/11/15 13:53:33 Filtered environment variables
2024/11/15 13:53:33 Authenticated with n8n main instance
2024/11/15 13:53:33 Launching runner...
2024/11/15 13:53:33 Command: /usr/local/bin/node
2024/11/15 13:53:33 Args: [/Users/ivov/Development/n8n/packages/@n8n/task-runner/dist/start.js]
2024/11/15 13:53:33 Env vars: [LANG PATH TERM N8N_RUNNERS_N8N_URI N8N_RUNNERS_GRANT_TOKEN]
```

## Setup

### Install

- Install Node.js >=18.17
ivov marked this conversation as resolved.
Show resolved Hide resolved
- Install n8n >= `<PENDING_VERSION>`
- Download launcher binary from [releases page](https://github.com/n8n-io/PENDING/releases)

### Config

Create a config file for the launcher:

- at `config.json` in the dir containing the launcher binary, or
- at `/etc/n8n-task-runners.json` if `SECURE_MODE=true`.

Sample config file:

```json
{
"task-runners": [
{
"runner-type": "javascript",
"workdir": "/usr/local/bin",
"command": "/usr/local/bin/node",
"args": [
"/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"
],
"allowed-env": [
"PATH",
"N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI",
"N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS"
]
}
]
}
```

Task runner config fields:

- `runner-type`: Type of task runner, currently only `javascript` supported
- `workdir`: Path to directory containing the task runner binary
- `command`: Command to execute to start task runner
- `args`: Args for command to execute, currently path to task runner entrypoint
- `allowed-env`: Env vars allowed to be passed to the task runner

### Auth

Generate a secret auth token (e.g. random string) for the launcher to authenticate with the n8n main instance. During the `launch` command, the launcher will exchange this auth token for a grant token from the n8n instance, and pass the grant token to the runner.

## Usage

Once setup is complete, start the launcher:

```sh
export N8N_RUNNERS_AUTH_TOKEN=...
export N8N_RUNNERS_N8N_URI=...
./n8n-launcher launch -type javascript
```

Or in secure mode:

```sh
export SECURE_MODE=true
export N8N_RUNNERS_AUTH_TOKEN=...
export N8N_RUNNERS_N8N_URI=...
./n8n-launcher launch -type javascript
```

## Development

1. Install Go >=1.23

2. Clone repo and create config file:

```sh
git clone https://github.com/n8n-io/PENDING-NAME
cd PENDING_NAME
touch config.json && echo '<json-config-content>' > config.json
```

3. Start n8n:

```sh
export N8N_RUNNERS_ENABLED=true
export N8N_RUNNERS_MODE=external
export N8N_RUNNERS_LAUNCHER_PATH=...
export N8N_RUNNERS_AUTH_TOKEN=...
pnpm start
```

4. Make changes to launcher.

5. Build and run launcher:

```sh
go build -o bin cmd/launcher/main.go

export N8N_RUNNERS_N8N_URI=...
export N8N_RUNNERS_AUTH_TOKEN=...
./bin/main launch -type javascript
```
Empty file added bin/.gitkeep
Empty file.
56 changes: 56 additions & 0 deletions cmd/launcher/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"flag"
"log"
"os"

"n8n-launcher/internal/commands"
)

func main() {
if len(os.Args) < 2 {
log.Fatal("Missing argument. Expected `launch` or `kill` subcommand")
os.Exit(1)
}

var cmd commands.Command

switch os.Args[1] {
case "launch":
ivov marked this conversation as resolved.
Show resolved Hide resolved
launchCmd := flag.NewFlagSet("launch", flag.ExitOnError)
runnerType := launchCmd.String("type", "", "Runner type to launch")
launchCmd.Parse(os.Args[2:])

if *runnerType == "" {
launchCmd.PrintDefaults()
os.Exit(1)
}

cmd = &commands.LaunchCommand{RunnerType: *runnerType}

case "kill":
killCmd := flag.NewFlagSet("kill", flag.ExitOnError)
runnerType := killCmd.String("type", "", "Runner type to kill")
pid := killCmd.Int("pid", 0, "Process ID to kill")
killCmd.Parse(os.Args[2:])

if *runnerType == "" || *pid == 0 {
killCmd.PrintDefaults()
os.Exit(1)
}

cmd = &commands.KillCommand{
RunnerType: *runnerType,
PID: *pid,
}

default:
log.Printf("Unknown command: %s\nExpected `launch` or `kill` subcommand", os.Args[1])
os.Exit(1)
}

if err := cmd.Execute(); err != nil {
log.Printf("Failed to execute command: %s", err)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module n8n-launcher

go 1.23.3
53 changes: 53 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Package auth provides functions for the launcher to authenticate with the
// n8n main instance.
package auth

import (
"encoding/json"
"fmt"
"net/http"
"strings"
)

type grantTokenResponse struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}

// FetchGrantToken exchanges the launcher's its auth token for a less privileged
// grant token `N8N_RUNNERS_GRANT_TOKEN` returned by the n8n main instance. The
// launcher will later pass this grant token to the task runner.
func FetchGrantToken(n8nUri, authToken string) (string, error) {
url := fmt.Sprintf("http://%s/runners/auth", n8nUri)

payload := map[string]string{"token": authToken}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", err
}

req, err := http.NewRequest("POST", url, strings.NewReader(string(payloadBytes)))
ivov marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("request to fetch grant token received status code %d", resp.StatusCode)
}

var tokenResp grantTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", err
}

return tokenResp.Data.Token, nil
}
5 changes: 5 additions & 0 deletions internal/commands/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package commands

type Command interface {
Execute() error
}
42 changes: 42 additions & 0 deletions internal/commands/kill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package commands

import (
"fmt"
"n8n-launcher/internal/config"
"os"
"syscall"
)

type KillCommand struct {
// Type of runner to kill, currently only "javascript" supported
RunnerType string

// Process ID of runner to kill
PID int
}

func (k *KillCommand) Execute() error {
cfg, err := config.ReadConfig()
if err != nil {
return err
}

found := false
for _, r := range cfg.TaskRunners {
if r.RunnerType == k.RunnerType {
found = true
break
}
}

if !found {
return fmt.Errorf("failed to find requested runner type in config: %s", k.RunnerType)
}

process, err := os.FindProcess(k.PID)
if err != nil {
return fmt.Errorf("failed to find requested process ID: %w", err)
}

return process.Signal(syscall.SIGKILL)
}
105 changes: 105 additions & 0 deletions internal/commands/launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package commands

import (
"fmt"
"log"
"n8n-launcher/internal/auth"
"n8n-launcher/internal/config"
"n8n-launcher/internal/env"
"os"
"os/exec"
)

type LaunchCommand struct {
RunnerType string
}

func (l *LaunchCommand) Execute() error {
log.Println("Starting to execute `launch` command...")

token := os.Getenv("N8N_RUNNERS_AUTH_TOKEN")
n8nUri := os.Getenv("N8N_RUNNERS_N8N_URI")

if token == "" || n8nUri == "" {
return fmt.Errorf("both N8N_RUNNERS_AUTH_TOKEN and N8N_RUNNERS_N8N_URI are required")
}

// 1. read configuration
ivov marked this conversation as resolved.
Show resolved Hide resolved

cfg, err := config.ReadConfig()
if err != nil {
log.Printf("Error reading config: %v", err)
return err
}

var runnerConfig config.TaskRunnerConfig
found := false
for _, r := range cfg.TaskRunners {
if r.RunnerType == l.RunnerType {
runnerConfig = r
found = true
break
}
}

if !found {
return fmt.Errorf("config file does not contain requested runner type : %s", l.RunnerType)
}

cfgNum := len(cfg.TaskRunners)

if cfgNum == 1 {
log.Print("Loaded config file loaded with a single runner config")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logger writes to stderr by default, which is not what we would want here. Also could we enable these logs only if certain env var is set, similar to RUST_LOG in the rust launcher

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also could we enable these logs only if certain env var is set, similar to RUST_LOG in the rust launcher

Why do we want to allow hiding the launcher logs by default? n8n main, worker, webhook, etc. all show at least info logs by default. As a user I'd like to always have a few informative lines to know the runner is working as expected.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider most of these logs to be debug logs, like for example the loading of the config here. Sure we can keep one or two to know the runner is working, (tho it can also be interpreted from the log from n8n that runner has connected), but we don't need all of these. This would be in quite contrast compared to what we log in the n8n app itself, which is very minimal

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, let's do this division of info/debug logs as a follow up 👍🏻

} else {
log.Printf("Loaded config file with %d runner configs", cfgNum)
}

// 2. change into working directory

if err := os.Chdir(runnerConfig.WorkDir); err != nil {
log.Printf("Failed to change dir to %s: %v", runnerConfig.WorkDir, err)
return fmt.Errorf("failed to chdir into configured dir (%s): %w", runnerConfig.WorkDir, err)
}

log.Printf("Changed into working directory: %s", runnerConfig.WorkDir)

// 3. filter environment variables

defaultEnvs := []string{"LANG", "PATH", "TZ", "TERM"}
allowedEnvs := append(defaultEnvs, runnerConfig.AllowedEnv...)
_env := env.AllowedOnly(allowedEnvs)
ivov marked this conversation as resolved.
Show resolved Hide resolved

log.Printf("Filtered environment variables")

// 4. authenticate with n8n main instance

grantToken, err := auth.FetchGrantToken(n8nUri, token)
if err != nil {
log.Printf("Failed to fetch grant token from n8n main instance: %v", err)
return fmt.Errorf("failed to fetch grant token from n8n main instance: %w", err)
}

_env = append(_env, fmt.Sprintf("N8N_RUNNERS_GRANT_TOKEN=%s", grantToken))

log.Printf("Authenticated with n8n main instance")

// 5. launch runner

log.Printf("Launching runner...")
log.Printf("Command: %s", runnerConfig.Command)
log.Printf("Args: %v", runnerConfig.Args)
log.Printf("Env vars: %v", env.Keys(_env))

cmd := exec.Command(runnerConfig.Command, runnerConfig.Args...)
ivov marked this conversation as resolved.
Show resolved Hide resolved
cmd.Env = _env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

err = cmd.Run()
if err != nil {
log.Printf("Failed to launch task runner: %v", err)
return err
}

return nil
}
Loading