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 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
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
112 changes: 112 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# n8n-launcher

CLI utility to launch [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 `/etc/n8n-task-runners.json`.

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. You will need to pass that token as `N8N_RUNNERS_AUTH_TOKEN` to the n8n main instance and to the launcher. During the `launch` command, the launcher will exchange this auth token for a short-lived 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 javascript
```

## Development

1. Install Go >=1.23

2. Clone repo and create a [config file](#config)

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

3. Make changes to launcher.

4. 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
```

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 javascript
```
Empty file added bin/.gitkeep
Empty file.
29 changes: 29 additions & 0 deletions cmd/launcher/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"flag"
"os"

"n8n-launcher/internal/commands"
"n8n-launcher/internal/logs"
)

func main() {
flag.Usage = func() {
logs.Logger.Printf("Usage: %s [runner-type]", os.Args[0])
flag.PrintDefaults()
}

if len(os.Args) < 2 {
logs.Logger.Fatal("Missing runner-type argument")
flag.Usage()
os.Exit(1)
}

runnerType := os.Args[1]
cmd := &commands.LaunchCommand{RunnerType: runnerType}

if err := cmd.Execute(); err != nil {
logs.Logger.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
51 changes: 51 additions & 0 deletions internal/auth/token_exchange.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package auth

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

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

// FetchGrantToken exchanges the launcher's auth token for a less privileged
// 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, bytes.NewBuffer(payloadBytes))
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
}
102 changes: 102 additions & 0 deletions internal/commands/launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package commands

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

type LaunchCommand struct {
RunnerType string
}

func (l *LaunchCommand) Execute() error {
logs.Logger.Println("Started executing `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 {
logs.Logger.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 {
logs.Logger.Println("Loaded config file loaded with a single runner config")
} else {
logs.Logger.Printf("Loaded config file with %d runner configs", cfgNum)
}

// 2. change into working directory

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

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

// 3. filter environment variables

defaultEnvs := []string{"LANG", "PATH", "TZ", "TERM"}
allowedEnvs := append(defaultEnvs, runnerConfig.AllowedEnv...)
runnerEnv := env.AllowedOnly(allowedEnvs)

logs.Logger.Printf("Filtered environment variables")

// 4. authenticate with n8n main instance

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

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

logs.Logger.Println("Authenticated with n8n main instance")

// 5. launch runner

logs.Logger.Println("Launching runner...")
logs.Logger.Printf("Command: %s", runnerConfig.Command)
logs.Logger.Printf("Args: %v", runnerConfig.Args)
logs.Logger.Printf("Env vars: %v", env.Keys(runnerEnv))

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

err = cmd.Run()
if err != nil {
return fmt.Errorf("failed to launch task runner: %w", err)
}

return nil
}
49 changes: 49 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Package config provides functions to use the launcher configuration file.
package config

import (
"encoding/json"
"fmt"
"os"
)

const configPath = "/etc/n8n-task-runners.json"
Copy link

Choose a reason for hiding this comment

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

I think it would still be nice to test this locally without having to write to /etc/. We can do that later on tho


type TaskRunnerConfig struct {
// Type of task runner, currently only "javascript" supported
RunnerType string `json:"runner-type"`

// Path to directory containing launcher (Go binary)
WorkDir string `json:"workdir"`

// Command to execute to start task runner
Command string `json:"command"`

// Arguments for command to execute, currently path to task runner entrypoint
Args []string `json:"args"`

// Env vars allowed to be passed by launcher to task runner
AllowedEnv []string `json:"allowed-env"`
}

type LauncherConfig struct {
TaskRunners []TaskRunnerConfig `json:"task-runners"`
}

func ReadConfig() (*LauncherConfig, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err)
}

var config LauncherConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file at %s: %w", configPath, err)
}

if len(config.TaskRunners) == 0 {
return nil, fmt.Errorf("found no task runner configs inside launcher config")
}

return &config, nil
}
Loading