├── .envrc ├── .projectile ├── logo.webp ├── examples ├── wasm │ ├── c │ │ ├── Makefile │ │ ├── wasm.c │ │ └── wasm.lua │ └── http │ │ ├── Makefile │ │ ├── wasm.lua │ │ └── main.go ├── podman │ └── hello-world.lua ├── hello_plugin.lua ├── fail.lua ├── hello.lua ├── nats_publish.lua ├── libs │ ├── nix-shell.lua │ ├── opentrashmail.lua │ └── web.lua ├── io.lua ├── website.lua └── pushover.lua ├── how_it_works.png ├── arion-pkgs.nix ├── .dir-locals.el ├── nats.go ├── cmd ├── cli │ ├── main.go │ ├── cmd_lib.go │ ├── cmd_rm.go │ ├── cmd.go │ ├── cmd_list.go │ ├── cmd_add.go │ ├── cmd_lib_add.go │ ├── cmd_dev.go │ └── cmd_devhttp.go └── server │ ├── reply.go │ ├── otel.go │ ├── http.go │ └── main.go ├── plugins ├── httpscrape │ └── main.go ├── db │ └── main.go ├── hello │ └── main.go ├── vault │ ├── test.lua │ └── main.go ├── gopher-lua-libs │ └── main.go └── plugin.go ├── store ├── etcd_test.go ├── store.go ├── dev.go ├── file.go └── etcd.go ├── .luarc.json ├── .gitignore ├── flake.lock ├── executor ├── nopodman_executor.go ├── nowasm_executor.go ├── executor.go ├── wasm_executor.go ├── lua_executor.go └── podman_executor.go ├── arion-compose.nix ├── nix ├── pkgs │ ├── cli.nix │ └── server.nix └── modules │ └── default.nix ├── lua ├── nats.go ├── etcd_test.go └── etcd.go ├── .github └── workflows │ ├── release.yaml │ └── docker-publish.yml ├── docker └── entrypoint.sh ├── script ├── script_test.go └── script.go ├── Dockerfile ├── flake.nix ├── .editorconfig ├── go.mod ├── LICENSE └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.projectile: -------------------------------------------------------------------------------- 1 | - /.direnv 2 | -------------------------------------------------------------------------------- /logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numkem/msgscript/HEAD/logo.webp -------------------------------------------------------------------------------- /examples/wasm/c/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | emcc wasm.c -o c.wasm -s STANDALONE_WASM=1 3 | -------------------------------------------------------------------------------- /how_it_works.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numkem/msgscript/HEAD/how_it_works.png -------------------------------------------------------------------------------- /examples/wasm/http/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | tinygo build -o http.wasm -target wasi ./main.go 3 | -------------------------------------------------------------------------------- /arion-pkgs.nix: -------------------------------------------------------------------------------- 1 | import (builtins.getFlake (toString ./.)).inputs.nixpkgs { system = "x86_64-linux"; } 2 | -------------------------------------------------------------------------------- /examples/wasm/c/wasm.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | int main() { 5 | printf("Hello from C!"); 6 | } 7 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((go-mode . ((eglot-workspace-configuration . 2 | (:gopls (:buildFlags ["-tags=podman,wasmtime"])))))) 3 | -------------------------------------------------------------------------------- /examples/podman/hello-world.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.hello 2 | --* name: podman 3 | --* executor: podman 4 | { 5 | "image": "hello-world" 6 | } 7 | -------------------------------------------------------------------------------- /nats.go: -------------------------------------------------------------------------------- 1 | package msgscript 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func NatsUrlByEnv() string { 8 | return os.Getenv("NATS_URL") 9 | } 10 | -------------------------------------------------------------------------------- /examples/wasm/c/wasm.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.wasm 2 | --* name: wasm 3 | --* html: true 4 | --* executor: wasm 5 | /home/numkem/src/msgscript/examples/wasm/c/c.wasm 6 | -------------------------------------------------------------------------------- /examples/wasm/http/wasm.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.wasm 2 | --* name: wasm 3 | --* html: true 4 | --* executor: wasm 5 | /home/numkem/src/msgscript/examples/wasm/http/http.wasm 6 | -------------------------------------------------------------------------------- /examples/hello_plugin.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.hello_plugin 2 | --* name: hello 3 | local hello = require("hello") 4 | 5 | function OnMessage(_, _) 6 | return hello.print() 7 | end 8 | -------------------------------------------------------------------------------- /examples/fail.lua: -------------------------------------------------------------------------------- 1 | -- This script is meant to fail as a example 2 | --* subject: funcs.fail 3 | --* name: fail 4 | 5 | function OnMessage(_, _) 6 | someUndefinedFunction("fail") 7 | end 8 | -------------------------------------------------------------------------------- /cmd/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | if err := rootCmd.Execute(); err != nil { 10 | fmt.Fprintln(os.Stderr, err) 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/hello.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.hello 2 | --* name: hello 3 | 4 | function OnMessage(subject, payload) 5 | local response = "Processed subject: " .. subject .. " with payload: " .. payload 6 | 7 | return response 8 | end 9 | -------------------------------------------------------------------------------- /cmd/cli/cmd_lib.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var libCmd = &cobra.Command{ 8 | Use: "lib", 9 | Short: "library related commands", 10 | } 11 | 12 | func init() { 13 | rootCmd.AddCommand(libCmd) 14 | } 15 | -------------------------------------------------------------------------------- /examples/nats_publish.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.nats_publish 2 | --* name: nats_publish 3 | local nats = require("nats") 4 | local json = require("json") 5 | 6 | function OnMessage(_, _) 7 | nats.publish("funcs.pushover", json.encode({ title = "booyah", message = "boo-YAH" })) 8 | end 9 | -------------------------------------------------------------------------------- /plugins/httpscrape/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/felipejfc/gluahttpscrape" 5 | "github.com/yuin/gopher-lua" 6 | ) 7 | 8 | func Preload(L *lua.LState, envs map[string]string) { 9 | L.PreloadModule("scrape", gluahttpscrape.NewHttpScrapeModule().Loader) 10 | } 11 | -------------------------------------------------------------------------------- /examples/libs/nix-shell.lua: -------------------------------------------------------------------------------- 1 | local cmd = require("cmd") 2 | 3 | function Nixshell(command, packages) 4 | local full_command = string.format("nix-shell -p %s --run '%s'", table.concat(packages, " "), command) 5 | local result, err = cmd.exec(full_command) 6 | assert(err == nil) 7 | 8 | return result.stdout, result.stderr 9 | end 10 | -------------------------------------------------------------------------------- /examples/io.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.localio 2 | --* name: write 3 | 4 | function OnMessage(_, payload) 5 | local filename = "lua_test.out" 6 | local file = io.open(filename, "a") 7 | assert(file ~= nil) 8 | 9 | file:write("testing 123 from nats") 10 | file:write("\n" .. payload) 11 | file:close() 12 | 13 | return "written to file " .. filename 14 | end 15 | -------------------------------------------------------------------------------- /plugins/db/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/tengattack/gluasql" 5 | mysql "github.com/tengattack/gluasql/mysql" 6 | sqlite3 "github.com/tengattack/gluasql/sqlite3" 7 | "github.com/yuin/gopher-lua" 8 | ) 9 | 10 | func Preload(L *lua.LState, envs map[string]string) { 11 | L.PreloadModule("mysql", mysql.Loader) 12 | L.PreloadModule("sqlite3", sqlite3.Loader) 13 | gluasql.Preload(L) 14 | } 15 | -------------------------------------------------------------------------------- /store/etcd_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestEtcdScriptStoreListSubjects(t *testing.T) { 11 | store, err := NewEtcdScriptStore("localhost:2379") 12 | assert.Nil(t, err) 13 | 14 | subjects, err := store.ListSubjects(context.Background()) 15 | assert.Nil(t, err) 16 | assert.NotEmpty(t, subjects) 17 | } 18 | -------------------------------------------------------------------------------- /plugins/hello/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/yuin/gopher-lua" 7 | ) 8 | 9 | func Preload(L *lua.LState, envs map[string]string) { 10 | L.PreloadModule("hello", func(L *lua.LState) int { 11 | mod := L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 12 | "print": print, 13 | }) 14 | 15 | L.Push(mod) 16 | return 1 17 | }) 18 | } 19 | 20 | func print(L *lua.LState) int { 21 | msg := "Hello from a Go plugin!" 22 | fmt.Println(msg) 23 | L.Push(lua.LString(msg)) 24 | return 1 25 | } 26 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "diagnostics": { 3 | "enable": true, 4 | "neededFileStatus": { 5 | "codestyle-check": "Any" 6 | } 7 | }, 8 | "runtime": { 9 | "version": "Lua 5.2.4" 10 | }, 11 | "workspace": { 12 | "library": ["runtime/lua"], 13 | "checkThirdParty": false, 14 | "maxPreload": 2000, 15 | "preloadFileSize": 1000 16 | }, 17 | "misc.parameters": ["--loglevel=trace"], 18 | "telemetry.enable": false, 19 | "Lua.format.defaultConfig": { 20 | "indent_style": "space", 21 | "indent_size": "4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/website.lua: -------------------------------------------------------------------------------- 1 | --* subject: http.website 2 | --* name: demo 3 | --* http: true 4 | local template = require("template") 5 | local mustache, _ = template.choose("mustache") 6 | 7 | local function renderPage(header, body) 8 | return string.format([[ 9 | 10 |
11 | %s 12 |
13 | 14 | %s 15 | 16 | 17 | ]], header, body) 18 | end 19 | 20 | local website = { 21 | ["GET"] = { 22 | ["/foobar"] = function (_, _) 23 | return renderPage("", "

foobar yo

") 24 | end 25 | } 26 | } 27 | 28 | function GET(url, body) 29 | return website["GET"][url](url, body) 30 | end 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | /.direnv/ 27 | /default.etcd/ 28 | result 29 | *.wasm 30 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1759036355, 6 | "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /executor/nopodman_executor.go: -------------------------------------------------------------------------------- 1 | //go:build !podman 2 | 3 | // This is so that we can build without podman's dependancies 4 | 5 | package executor 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/numkem/msgscript/script" 12 | msgstore "github.com/numkem/msgscript/store" 13 | ) 14 | 15 | type noPodmanExecutor struct{} 16 | 17 | func NewPodmanExecutor(c context.Context, store msgstore.ScriptStore) (Executor, error) { 18 | return &noPodmanExecutor{}, nil 19 | } 20 | 21 | func (e *noPodmanExecutor) HandleMessage(ctx context.Context, msg *Message, scr *script.Script) *ScriptResult { 22 | return ScriptResultWithError(fmt.Errorf("msgscript wasn't built with podman support")) 23 | } 24 | 25 | func (e *noPodmanExecutor) Stop() {} 26 | -------------------------------------------------------------------------------- /arion-compose.nix: -------------------------------------------------------------------------------- 1 | { pkgs, ... }: 2 | 3 | { 4 | project.name = "msgscript"; 5 | 6 | services = { 7 | etcd = { 8 | image.enableRecommendedContents = true; 9 | service.useHostStore = true; 10 | service.command = [ 11 | "${pkgs.etcd_3_5}/bin/etcd" 12 | "-advertise-client-urls" 13 | "http://127.1.1.1:2379" 14 | "-listen-client-urls" 15 | "http://0.0.0.0:2379" 16 | ]; 17 | service.ports = [ 18 | "2379:2379" 19 | ]; 20 | }; 21 | 22 | nats = { 23 | image.enableRecommendedContents = true; 24 | service.useHostStore = true; 25 | service.command = [ "${pkgs.nats-server}/bin/nats-server" ]; 26 | service.ports = [ "4222:4222" ]; 27 | }; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /executor/nowasm_executor.go: -------------------------------------------------------------------------------- 1 | //go:build !wasmtime 2 | 3 | // This is so that we can build without wasm's dependancies 4 | 5 | package executor 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/nats-io/nats.go" 12 | 13 | msgplugins "github.com/numkem/msgscript/plugins" 14 | "github.com/numkem/msgscript/script" 15 | msgstore "github.com/numkem/msgscript/store" 16 | ) 17 | 18 | type noWasmExecutor struct{} 19 | 20 | func NewWasmExecutor(c context.Context, store msgstore.ScriptStore, plugins []msgplugins.PreloadFunc, nc *nats.Conn) Executor { 21 | return &noWasmExecutor{} 22 | } 23 | 24 | func (e *noWasmExecutor) HandleMessage(ctx context.Context, msg *Message, scr *script.Script) *ScriptResult { 25 | return ScriptResultWithError(fmt.Errorf("msgscript wasn't built with wasm support")) 26 | } 27 | 28 | func (e *noWasmExecutor) Stop() {} 29 | -------------------------------------------------------------------------------- /examples/pushover.lua: -------------------------------------------------------------------------------- 1 | --* subject: funcs.pushover 2 | --* name: pushover 3 | local http = require("http") 4 | local json = require("json") 5 | 6 | function OnMessage(_, payload) 7 | local p, err = json.decode(payload) 8 | assert(err == nil) 9 | 10 | local headers = {} 11 | headers["Content-Type"] = "application/json" 12 | 13 | local response, err_message = http.request("POST", "https://api.pushover.net/1/messages.json", { 14 | headers = headers, 15 | body = json.encode({ 16 | token = "appToken", 17 | user = "userToken", 18 | message = p.message, 19 | title = p.title 20 | }) 21 | }) 22 | 23 | if (err_message ~= nil) then 24 | return "error: " .. err_message 25 | end 26 | 27 | return "HTTP " .. response.status_code .. ": " .. response.body 28 | end 29 | -------------------------------------------------------------------------------- /nix/pkgs/cli.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | buildGoModule, 4 | btrfs-progs, 5 | gpgme, 6 | pkg-config, 7 | wasmtime, 8 | vendorHash, 9 | version, 10 | withPodman ? false, 11 | withWasm ? false, 12 | }: 13 | 14 | buildGoModule { 15 | pname = "msgscript-cli"; 16 | inherit version vendorHash; 17 | 18 | src = ../..; 19 | 20 | subPackages = [ "cmd/cli" ]; 21 | 22 | nativeBuildInputs = [ ] ++ (lib.optionals withPodman [ pkg-config ]); 23 | 24 | buildInputs = 25 | [ ] 26 | ++ (lib.optionals withWasm [ wasmtime.dev ]) 27 | ++ (lib.optionals withPodman [ 28 | btrfs-progs 29 | gpgme 30 | ]); 31 | 32 | ldflags = [ 33 | "-X" 34 | "main.version=${version}" 35 | ]; 36 | 37 | tags = [ ] ++ (lib.optionals withWasm [ "wasmtime" ]) ++ (lib.optionals withPodman [ "podman" ]); 38 | 39 | postInstall = '' 40 | mv $out/bin/cli $out/bin/msgscriptcli 41 | ''; 42 | } 43 | -------------------------------------------------------------------------------- /examples/wasm/http/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type ScriptResult struct { 10 | Code int `json:"http_code"` 11 | Error string `json:"error"` 12 | Headers map[string]string `json:"http_headers"` 13 | IsHTML bool `json:"is_html"` 14 | Payload []byte `json:"payload"` 15 | } 16 | 17 | func main() { 18 | html := ` 19 | 20 | 21 | 22 | 23 | Wasm Hello World 24 | 25 | 26 |

Hello from Wasm!

27 | 28 | 29 | ` 30 | result := ScriptResult{ 31 | Code: 200, 32 | IsHTML: true, 33 | Payload: []byte(html), 34 | } 35 | 36 | err := json.NewEncoder(os.Stdout).Encode(result) 37 | if err != nil { 38 | fmt.Fprintf(os.Stderr, "failed to encode result: %w", err) 39 | } 40 | 41 | os.Exit(0) 42 | } 43 | -------------------------------------------------------------------------------- /nix/pkgs/server.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | buildGoModule, 4 | btrfs-progs, 5 | gpgme, 6 | pkg-config, 7 | wasmtime, 8 | vendorHash, 9 | version, 10 | withPodman ? false, 11 | withWasm ? false, 12 | }: 13 | 14 | buildGoModule { 15 | pname = "msgscript"; 16 | inherit version vendorHash; 17 | 18 | src = ../..; 19 | 20 | subPackages = [ "cmd/server" ]; 21 | 22 | nativeBuildInputs = [ ] ++ (lib.optionals withPodman [ pkg-config ]); 23 | 24 | buildInputs = 25 | [ ] 26 | ++ (lib.optionals withWasm [ wasmtime.dev ]) 27 | ++ (lib.optionals withPodman [ 28 | btrfs-progs 29 | gpgme 30 | ]); 31 | 32 | ldflags = [ 33 | "-X" 34 | "main.version=${version}" 35 | ]; 36 | 37 | tags = [ ] ++ (lib.optionals withWasm [ "wasmtime" ]) ++ (lib.optionals withPodman [ "podman" ]); 38 | 39 | doCheck = false; # Requires networking, will just timeout 40 | 41 | postInstall = '' 42 | mv $out/bin/server $out/bin/msgscript 43 | ''; 44 | } 45 | -------------------------------------------------------------------------------- /lua/nats.go: -------------------------------------------------------------------------------- 1 | package lua 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nats-io/nats.go" 7 | "github.com/yuin/gopher-lua" 8 | ) 9 | 10 | var nc *nats.Conn 11 | 12 | // Preload adds the NATS module to the given Lua state. 13 | func PreloadNats(L *lua.LState, conn *nats.Conn) { 14 | L.PreloadModule("nats", natsLoader) 15 | nc = conn 16 | } 17 | 18 | func natsLoader(L *lua.LState) int { 19 | mod := L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 20 | "publish": natsPublish, 21 | }) 22 | L.Push(mod) 23 | return 1 24 | } 25 | 26 | func natsPublish(L *lua.LState) int { 27 | if nc == nil { 28 | L.Push(lua.LBool(false)) 29 | L.Push(lua.LString("Not connected to NATS")) 30 | return 2 31 | } 32 | 33 | subject := L.ToString(1) 34 | message := L.ToString(2) 35 | 36 | err := nc.Publish(subject, []byte(message)) 37 | if err != nil { 38 | L.Push(lua.LBool(false)) 39 | L.Push(lua.LString(fmt.Sprintf("Failed to publish message: %v", err))) 40 | return 2 41 | } 42 | 43 | L.Push(lua.LBool(true)) 44 | L.Push(lua.LNil) 45 | return 2 46 | } 47 | -------------------------------------------------------------------------------- /plugins/vault/test.lua: -------------------------------------------------------------------------------- 1 | --* name: vaulttest 2 | --* subject: app.vaulttest 3 | --* html: true 4 | 5 | local vault = require("vault") 6 | 7 | function GET(_, _) 8 | local v, err = vault.new("", "", true) 9 | if err ~= nil then 10 | return "conn: " .. err 11 | end 12 | 13 | err = v:write("/foo", { foo = "bar" }, "secrets") 14 | if err ~= nil then 15 | return "write: " .. err 16 | end 17 | 18 | local val 19 | val, err = v:read("/foo", "secrets") 20 | if err ~= nil then 21 | return "read: " .. err 22 | end 23 | 24 | if val['foo'] ~= "bar" then 25 | return "wrong data returned" 26 | end 27 | 28 | local list 29 | list, err = v:list("/", "secrets") 30 | if err ~= nil then 31 | return "list: " .. err 32 | end 33 | 34 | if #list ~= 1 then 35 | return "list returned wrong number of keys, expected 1 got " .. #list 36 | end 37 | 38 | err = v:delete("/foo", "secrets") 39 | if err ~= nil then 40 | return "delete: " .. err 41 | end 42 | 43 | return "passed!" 44 | end 45 | -------------------------------------------------------------------------------- /plugins/gopher-lua-libs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/vadv/gopher-lua-libs/cmd" 5 | "github.com/vadv/gopher-lua-libs/crypto" 6 | "github.com/vadv/gopher-lua-libs/filepath" 7 | "github.com/vadv/gopher-lua-libs/inspect" 8 | "github.com/vadv/gopher-lua-libs/ioutil" 9 | "github.com/vadv/gopher-lua-libs/log" 10 | "github.com/vadv/gopher-lua-libs/runtime" 11 | "github.com/vadv/gopher-lua-libs/storage" 12 | "github.com/vadv/gopher-lua-libs/strings" 13 | "github.com/vadv/gopher-lua-libs/tac" 14 | "github.com/vadv/gopher-lua-libs/tcp" 15 | "github.com/vadv/gopher-lua-libs/template" 16 | "github.com/vadv/gopher-lua-libs/time" 17 | "github.com/vadv/gopher-lua-libs/yaml" 18 | "github.com/yuin/gopher-lua" 19 | ) 20 | 21 | func Preload(L *lua.LState, envs map[string]string) { 22 | cmd.Preload(L) 23 | crypto.Preload(L) 24 | filepath.Preload(L) 25 | inspect.Preload(L) 26 | ioutil.Preload(L) 27 | log.Preload(L) 28 | runtime.Preload(L) 29 | storage.Preload(L) 30 | strings.Preload(L) 31 | tac.Preload(L) 32 | tcp.Preload(L) 33 | template.Preload(L) 34 | time.Preload(L) 35 | yaml.Preload(L) 36 | } 37 | -------------------------------------------------------------------------------- /examples/libs/opentrashmail.lua: -------------------------------------------------------------------------------- 1 | --* name: opentrashmail 2 | local http = require("http") 3 | local json = require("json") 4 | 5 | OpenTrashMail = { 6 | hostname = "" 7 | } 8 | 9 | function OpenTrashMail:new(hostname) 10 | self.hostname = hostname 11 | end 12 | 13 | function OpenTrashMail:rawEmailById(address, id) 14 | local resp, err = http.get(string.format("%s/api/raw/%s/%s", self.hostname, address, id)) 15 | assert(err == nil) 16 | 17 | return resp.body 18 | end 19 | 20 | function OpenTrashMail:emailsForAddress(address) 21 | local resp, err = http.get(string.format("%s/json/%s", self.hostname, address)) 22 | assert(err == nil) 23 | 24 | return json.decode(resp.body) 25 | end 26 | 27 | function OpenTrashMail:bodyForEmail(address, id) 28 | local resp, err = http.get(string.format("%s/json/%s/%s", self.hostname, address, id)) 29 | assert(err == nil) 30 | 31 | json.decode(resp.body) 32 | end 33 | 34 | function OpenTrashMail:ListAccounts() 35 | local resp, err = http.get(string.format("%s/json/listaccounts", self.hostname)) 36 | assert(err == nil) 37 | 38 | json.decode(resp.body) 39 | end 40 | -------------------------------------------------------------------------------- /cmd/cli/cmd_rm.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | msgstore "github.com/numkem/msgscript/store" 7 | ) 8 | 9 | var rmCmd = &cobra.Command{ 10 | Use: "rm", 11 | Short: "Remove an existing script", 12 | Run: rmCmdRun, 13 | } 14 | 15 | func init() { 16 | rootCmd.AddCommand(rmCmd) 17 | 18 | rmCmd.PersistentFlags().StringP("subject", "s", "", "The NATS subject to respond to") 19 | rmCmd.PersistentFlags().StringP("name", "n", "", "The name of the script in the backend") 20 | rmCmd.MarkFlagRequired("subject") 21 | rmCmd.MarkFlagRequired("name") 22 | } 23 | 24 | func rmCmdRun(cmd *cobra.Command, args []string) { 25 | scriptStore, err := msgstore.StoreByName(cmd.Flag("backend").Value.String(), cmd.Flag("etcdurls").Value.String(), "", "") 26 | if err != nil { 27 | cmd.PrintErrf("failed to get script store: %v", err) 28 | return 29 | } 30 | subject := cmd.Flag("subject").Value.String() 31 | name := cmd.Flag("name").Value.String() 32 | 33 | err = scriptStore.DeleteScript(cmd.Context(), subject, name) 34 | if err != nil { 35 | cmd.PrintErrf("failed to remove script: %v", err) 36 | } 37 | 38 | cmd.Printf("Script removed\n") 39 | } 40 | -------------------------------------------------------------------------------- /cmd/cli/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/numkem/msgscript/executor" 11 | ) 12 | 13 | var rootCmd = &cobra.Command{ 14 | Use: "msgscript", 15 | Short: "msgscript CLI", 16 | Long: `msgscript is a command line interface for managing scripts for the msgscript-server`, 17 | } 18 | 19 | func init() { 20 | if os.Getenv("DEBUG") != "" { 21 | log.SetLevel(log.DebugLevel) 22 | } 23 | 24 | rootCmd.PersistentFlags().StringP("log", "L", "info", "set the logger to this log level") 25 | rootCmd.PersistentFlags().StringP("etcdurls", "e", "localhost:2379", "Endpoints to connect to etcd") 26 | rootCmd.PersistentFlags().StringP("natsurl", "u", "nats://localhost:4222", "NATS url to reach") 27 | rootCmd.PersistentFlags().StringP("backend", "b", "etcd", "The name of the backend to use to manipulate the scripts") 28 | rootCmd.PersistentFlags().StringP("executor", "x", executor.EXECUTOR_LUA_NAME, fmt.Sprintf("Which executor to use. Either %s or %s", executor.EXECUTOR_LUA_NAME, executor.EXECUTOR_WASM_NAME)) 29 | } 30 | 31 | func Execute() error { 32 | return rootCmd.Execute() 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: {} 3 | release: 4 | types: [created] 5 | 6 | permissions: 7 | contents: write 8 | packages: write 9 | 10 | jobs: 11 | releases-matrix: 12 | name: Release Go Binary 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # build and publish in parallel: linux/amd64, linux/arm64, windows/amd64, darwin/amd64, darwin/arm64 17 | goos: [linux, windows, darwin] 18 | goarch: [amd64, arm64] 19 | exclude: 20 | - goarch: arm64 21 | goos: windows 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: wangyoucao577/go-release-action@v1 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | goos: ${{ matrix.goos }} 28 | goarch: ${{ matrix.goarch }} 29 | goversion: "https://dl.google.com/go/go1.23.4.linux-amd64.tar.gz" 30 | project_path: "./cmd/server" 31 | binary_name: "msgscript" 32 | - uses: wangyoucao577/go-release-action@v1 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | goos: ${{ matrix.goos }} 36 | goarch: ${{ matrix.goarch }} 37 | goversion: "https://dl.google.com/go/go1.23.4.linux-amd64.tar.gz" 38 | project_path: "./cmd/cli" 39 | binary_name: "msgscriptcli" 40 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Function to display usage information 5 | usage() { 6 | echo "Usage: $0 [cli|server] [arguments...]" 7 | echo "" 8 | echo "Commands:" 9 | echo " cli - Run the CLI application at /app/bin/cli" 10 | echo " server - Run the server application at /app/bin/server" 11 | echo "" 12 | echo "Examples:" 13 | echo " $0 cli --help" 14 | echo " $0 server --port 8080" 15 | echo " $0 server" 16 | exit 1 17 | } 18 | 19 | # Check if at least one argument is provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: No command specified." 22 | usage 23 | fi 24 | 25 | # Get the command (first argument) 26 | COMMAND="$1" 27 | shift # Remove the command from arguments list 28 | 29 | # Execute based on the command 30 | case "$COMMAND" in 31 | "cli") 32 | if [ $# -gt 0 ] && ([ "$1" = "dev" ] || [ "$1" = "devhttp" ]); then 33 | CLI_COMMAND="$1" 34 | shift 35 | exec /app/bin/cli "$CLI_COMMAND" -plugin /app/share/plugins "$@" 36 | else 37 | exec /app/bin/cli 38 | fi 39 | ;; 40 | "server") 41 | exec /app/bin/server -plugin /app/share/plugins "$@" 42 | ;; 43 | "--help"|"-h"|"help") 44 | usage 45 | ;; 46 | *) 47 | echo "Error: Unknown command '$COMMAND'" 48 | usage 49 | ;; 50 | esac 51 | -------------------------------------------------------------------------------- /script/script_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestScriptReaderLuaRead(t *testing.T) { 10 | content := `local http = require("http") 11 | local json = require("json") 12 | 13 | function OnMessage(_, payload) 14 | end` 15 | 16 | headers := `--* subject: funcs.foobar 17 | --* name: foo 18 | --* html: true 19 | --* require: web 20 | ` 21 | s, err := ReadString(headers + content) 22 | assert.Nil(t, err) 23 | 24 | assert.Equal(t, "foo", s.Name) 25 | assert.Equal(t, "funcs.foobar", s.Subject) 26 | assert.Equal(t, content, string(s.Content)) 27 | assert.Equal(t, true, s.HTML) 28 | assert.Equal(t, 1, len(s.LibKeys)) 29 | assert.Equal(t, "web", s.LibKeys[0]) 30 | } 31 | 32 | func TestScriptReaderWasmRead(t *testing.T) { 33 | content := `--* subject: funcs.foobar 34 | --* name: foo 35 | --* html: true 36 | --* executor: wasm 37 | /some/path/to/wasm/module.wasm 38 | ` 39 | s, err := ReadString(content) 40 | assert.Nil(t, err) 41 | 42 | assert.Equal(t, "foo", s.Name) 43 | assert.Equal(t, "funcs.foobar", s.Subject) 44 | assert.Equal(t, "wasm", s.Executor) 45 | assert.Equal(t, "/some/path/to/wasm/module.wasm", string(s.Content)) 46 | } 47 | 48 | func TestScriptFileReaderWasmFileRead(t *testing.T) { 49 | scriptPath := "../examples/wasm/http/wasm.lua" 50 | s, err := ReadFile(scriptPath) 51 | assert.Nil(t, err) 52 | 53 | assert.Equal(t, "wasm", s.Name) 54 | assert.Equal(t, "funcs.wasm", s.Subject) 55 | assert.Contains(t, string(s.Content), "msgscript/examples/wasm/http/http.wasm") 56 | } 57 | -------------------------------------------------------------------------------- /cmd/server/reply.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/nats-io/nats.go" 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/numkem/msgscript/executor" 11 | ) 12 | 13 | type Reply struct { 14 | Results []*executor.ScriptResult `json:"script_result"` 15 | HTML bool `json:"is_html"` 16 | Error string `json:"error,omitempty"` 17 | } 18 | 19 | func replyMessage(nc *nats.Conn, msg *executor.Message, replySubject string, rep *Reply) error { 20 | fields := log.Fields{ 21 | "Subject": msg.Subject, 22 | "URL": msg.URL, 23 | "Method": msg.Method, 24 | } 25 | 26 | // Send a reply if the message has a reply subject 27 | if replySubject == "" { 28 | return nil 29 | } 30 | 31 | var payload []byte 32 | var err error 33 | payload, err = json.Marshal(rep) 34 | if err != nil { 35 | log.WithFields(fields).Errorf("failed to serialize script reply to JSON: %v", err) 36 | return fmt.Errorf("failed to serialize script reply to JSON: %v", err) 37 | } 38 | 39 | log.WithFields(fields).Debugf("sent reply: %s", string(payload)) 40 | err = nc.Publish(replySubject, payload) 41 | if err != nil { 42 | log.WithFields(fields).Errorf("failed to publish reply after running script: %v", err) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func replyWithError(nc *nats.Conn, resErr error, replySubject string) { 49 | payload, err := json.Marshal(&Reply{Error: resErr.Error()}) 50 | if err != nil { 51 | log.Errorf("failed to serialize script reply to JSON: %v", err) 52 | } 53 | 54 | err = nc.Publish(replySubject, payload) 55 | if err != nil { 56 | log.Errorf("failed to reply with error: %v", err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nixos/nix:latest AS builder 2 | 3 | # Copy our source and setup our working dir. 4 | COPY . /tmp/build 5 | WORKDIR /tmp/build 6 | 7 | # Build our Nix environment 8 | RUN nix \ 9 | --extra-experimental-features "nix-command flakes" \ 10 | --option filter-syscalls false \ 11 | --option max-jobs auto \ 12 | build .#server && \ 13 | mv /tmp/build/result /tmp/build/server 14 | 15 | RUN nix \ 16 | --extra-experimental-features "nix-command flakes" \ 17 | --option filter-syscalls false \ 18 | --option max-jobs auto \ 19 | build .#cli && \ 20 | mv /tmp/build/result /tmp/build/cli 21 | 22 | RUN nix \ 23 | --extra-experimental-features "nix-command flakes" \ 24 | --option filter-syscalls false \ 25 | --option max-jobs auto \ 26 | build .#allPlugins && \ 27 | mv /tmp/build/result /tmp/build/allPlugins 28 | 29 | # Copy the Nix store closure into a directory. The Nix store closure is the 30 | # entire set of Nix store values that we need for our build. 31 | RUN mkdir /tmp/nix-store-closure 32 | RUN cp -R $(nix-store -qR server/) /tmp/nix-store-closure 33 | RUN cp -R $(nix-store -qR cli/) /tmp/nix-store-closure 34 | RUN cp -R $(nix-store -qR allPlugins/) /tmp/nix-store-closure 35 | 36 | 37 | FROM alpine:latest 38 | 39 | WORKDIR /app 40 | 41 | COPY ./docker/entrypoint.sh /entrypoint.sh 42 | RUN mkdir -p /app/share/plugins && chmod +x /entrypoint.sh 43 | 44 | COPY --from=builder /tmp/nix-store-closure /nix/store 45 | COPY --from=builder /tmp/build/server /app 46 | COPY --from=builder /tmp/build/cli /app 47 | COPY --from=builder /tmp/build/allPlugins/*.so /app/share/plugins/ 48 | RUN mv /app/bin/msgscript /app/bin/server && mv /app/bin/msgscriptcli /app/bin/cli 49 | 50 | EXPOSE 7643 51 | 52 | ENTRYPOINT ["/entrypoint.sh"] 53 | -------------------------------------------------------------------------------- /plugins/plugin.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "plugin" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/yuin/gopher-lua" 12 | ) 13 | 14 | type PreloadFunc func(L *lua.LState, envs map[string]string) 15 | 16 | func ReadPluginDir(dirpath string) ([]PreloadFunc, error) { 17 | entries, err := os.ReadDir(dirpath) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to read plugin directory %s: %w", dirpath, err) 20 | } 21 | 22 | var readPlugins []PreloadFunc 23 | for _, entry := range entries { 24 | if entry.IsDir() { 25 | continue 26 | } 27 | 28 | if filepath.Ext(entry.Name()) != ".so" { 29 | continue 30 | } 31 | 32 | fullPath := filepath.Join(dirpath, entry.Name()) 33 | p, err := plugin.Open(fullPath) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to open plugin file %s: %w", fullPath, err) 36 | } 37 | 38 | symPreload, err := p.Lookup("Preload") 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to find Plugin symbol: %w", err) 41 | } 42 | 43 | mp, ok := symPreload.(func(*lua.LState, map[string]string)) 44 | if !ok { 45 | return nil, fmt.Errorf("invalid plugin: %s", fullPath) 46 | } 47 | 48 | log.WithField("plugin", fullPath).Debug("loaded plugin") 49 | 50 | readPlugins = append(readPlugins, mp) 51 | } 52 | 53 | return readPlugins, nil 54 | } 55 | 56 | func LoadPlugins(L *lua.LState, plugins []PreloadFunc) error { 57 | envs := make(map[string]string) 58 | for _, e := range os.Environ() { 59 | pair := strings.SplitN(e, "=", 2) 60 | envs[pair[0]] = pair[1] 61 | } 62 | 63 | for _, preload := range plugins { 64 | preload(L, envs) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func LoadPluginsFromDir(L *lua.LState, dirpath string) error { 71 | plugins, err := ReadPluginDir(dirpath) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | LoadPlugins(L, plugins) 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /cmd/cli/cmd_list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/jedib0t/go-pretty/v6/table" 7 | "github.com/spf13/cobra" 8 | 9 | msgstore "github.com/numkem/msgscript/store" 10 | ) 11 | 12 | var listCmd = &cobra.Command{ 13 | Use: "list", 14 | Aliases: []string{"ls"}, 15 | Short: "list all the scripts registered in the store", 16 | Run: listCmdRun, 17 | } 18 | 19 | func init() { 20 | rootCmd.AddCommand(listCmd) 21 | } 22 | 23 | func listCmdRun(cmd *cobra.Command, args []string) { 24 | scriptStore, err := msgstore.StoreByName(cmd.Flag("backend").Value.String(), cmd.Flag("etcdurls").Value.String(), "", "") 25 | if err != nil { 26 | cmd.PrintErrf("failed to get script store: %v", err) 27 | return 28 | } 29 | 30 | subjectScriptNames := make(map[string][]string) 31 | subjects, err := scriptStore.ListSubjects(cmd.Context()) 32 | if err != nil { 33 | cmd.PrintErrf("failed to get subjects from store: %v", err) 34 | return 35 | } 36 | 37 | if len(subjects) == 0 { 38 | cmd.Print("No script in store\n") 39 | return 40 | } 41 | 42 | for _, subject := range subjects { 43 | scripts, err := scriptStore.GetScripts(cmd.Context(), subject) 44 | if err != nil { 45 | cmd.PrintErrf("failed to get scripts for subject %s: %v", subject, err) 46 | return 47 | } 48 | 49 | for name := range scripts { 50 | _, found := subjectScriptNames[subject] 51 | if !found { 52 | subjectScriptNames[subject] = []string{} 53 | } 54 | subjectScriptNames[subject] = append(subjectScriptNames[subject], name) 55 | } 56 | } 57 | 58 | t := table.NewWriter() 59 | t.SetOutputMirror(cmd.OutOrStdout()) 60 | t.SetStyle(table.StyleLight) 61 | t.Style().Options.DrawBorder = false 62 | t.Style().Options.SeparateRows = false 63 | t.Style().Options.SeparateColumns = false 64 | t.Style().Options.SeparateHeader = false 65 | t.Style().Options.SeparateFooter = false 66 | 67 | t.AppendHeader(table.Row{"Subject", "Name"}) 68 | 69 | for subject, names := range subjectScriptNames { 70 | for _, name := range names { 71 | ss := strings.Split(name, "/") 72 | n := ss[len(ss)-1] 73 | t.AppendRow(table.Row{subject, n}) 74 | } 75 | } 76 | 77 | t.Render() 78 | } 79 | -------------------------------------------------------------------------------- /cmd/cli/cmd_add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | scriptLib "github.com/numkem/msgscript/script" 10 | msgstore "github.com/numkem/msgscript/store" 11 | ) 12 | 13 | func validateArgIsPath(cmd *cobra.Command, args []string) error { 14 | if len(args) != 1 { 15 | return fmt.Errorf("a single path to a lua file is required") 16 | } 17 | 18 | if _, err := os.Stat(args[0]); err != nil { 19 | return fmt.Errorf("invalid filename %s: %w", args[0], err) 20 | } 21 | 22 | return nil 23 | } 24 | 25 | var addCmd = &cobra.Command{ 26 | Use: "add", 27 | Args: validateArgIsPath, 28 | Short: "Add a script to the backend by reading the provided lua file", 29 | Run: addCmdRun, 30 | } 31 | 32 | func init() { 33 | rootCmd.AddCommand(addCmd) 34 | 35 | addCmd.PersistentFlags().StringP("subject", "s", "", "The NATS subject to respond to") 36 | addCmd.PersistentFlags().StringP("name", "n", "", "The name of the script in the backend") 37 | } 38 | 39 | func addCmdRun(cmd *cobra.Command, args []string) { 40 | scriptStore, err := msgstore.StoreByName(cmd.Flag("backend").Value.String(), cmd.Flag("etcdurls").Value.String(), "", "") 41 | if err != nil { 42 | cmd.PrintErrf("failed to get script store: %v", err) 43 | return 44 | } 45 | subject := cmd.Flag("subject").Value.String() 46 | name := cmd.Flag("name").Value.String() 47 | 48 | // Try to read the file to see if we can find headers 49 | scr, err := scriptLib.ReadFile(args[0]) 50 | if err != nil { 51 | cmd.PrintErrf("failed to read the script file %s: %v", args[0], err) 52 | return 53 | } 54 | if subject == "" { 55 | if scr.Subject == "" { 56 | cmd.PrintErrf("subject is required") 57 | return 58 | } 59 | 60 | subject = scr.Subject 61 | } 62 | if name == "" { 63 | if scr.Name == "" { 64 | cmd.PrintErrf("name is required") 65 | return 66 | } 67 | 68 | name = scr.Name 69 | } 70 | 71 | // Add the script to etcd under the given subject 72 | err = scriptStore.AddScript(cmd.Context(), subject, name, scr) 73 | if err != nil { 74 | cmd.PrintErrf("Failed to add script to etcd: %v", err) 75 | return 76 | } 77 | 78 | cmd.Printf("Script added successfully for subject %s named %s \n", subject, name) 79 | } 80 | -------------------------------------------------------------------------------- /executor/executor.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/nats-io/nats.go" 10 | 11 | "github.com/numkem/msgscript/plugins" 12 | "github.com/numkem/msgscript/script" 13 | "github.com/numkem/msgscript/store" 14 | ) 15 | 16 | const ( 17 | MAX_LUA_RUNNING_TIME = 2 * time.Minute 18 | EXECUTOR_LUA_NAME = "lua" 19 | EXECUTOR_WASM_NAME = "wasm" 20 | EXECUTOR_PODMAN_NAME = "podman" 21 | ) 22 | 23 | type Message struct { 24 | Async bool `json:"async"` 25 | Executor string `json:"executor"` 26 | Method string `json:"method"` 27 | Payload []byte `json:"payload"` 28 | Raw bool `json:"raw"` 29 | Subject string `json:"subject"` 30 | URL string `json:"url"` 31 | } 32 | 33 | type ScriptResult struct { 34 | Code int `json:"http_code"` 35 | Error string `json:"error"` 36 | Headers map[string]string `json:"http_headers"` 37 | IsHTML bool `json:"is_html"` 38 | Payload []byte `json:"payload"` 39 | } 40 | 41 | func ScriptResultWithError(err error) *ScriptResult { 42 | return &ScriptResult{Error: err.Error()} 43 | } 44 | 45 | type NoScriptFoundError struct{} 46 | 47 | func (e *NoScriptFoundError) Error() string { 48 | return "No script found for subject" 49 | } 50 | 51 | // Used by executors 52 | func createTempFile(pattern string) (*os.File, error) { 53 | tmpFile, err := os.CreateTemp(os.TempDir(), pattern) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to create temp file: %w", err) 56 | } 57 | 58 | return tmpFile, nil 59 | } 60 | 61 | type Executor interface { 62 | HandleMessage(context.Context, *Message, *script.Script) *ScriptResult 63 | Stop() 64 | } 65 | 66 | func StartAllExecutors(ctx context.Context, scriptStore store.ScriptStore, plugins []plugins.PreloadFunc, nc *nats.Conn) map[string]Executor { 67 | executors := make(map[string]Executor) 68 | 69 | executors[EXECUTOR_LUA_NAME] = NewLuaExecutor(ctx, scriptStore, plugins, nc) 70 | executors[EXECUTOR_WASM_NAME] = NewWasmExecutor(ctx, scriptStore, nil, nil) 71 | 72 | podmanExec, err := NewPodmanExecutor(ctx, scriptStore) 73 | if err != nil { 74 | podmanExec = nil 75 | } 76 | executors[EXECUTOR_PODMAN_NAME] = podmanExec 77 | 78 | return executors 79 | } 80 | 81 | func ExecutorByName(name string, executors map[string]Executor) (Executor, error) { 82 | // Handle the message by invoking the corresponding Lua script 83 | // Check if the executor exists 84 | exec, found := executors[name] 85 | if !found { 86 | exec = executors[EXECUTOR_LUA_NAME] 87 | } 88 | 89 | // Check if the executor is enabled 90 | if exec == nil { 91 | return nil, fmt.Errorf("executor %s isn't enabled", name) 92 | } 93 | 94 | return exec, nil 95 | } 96 | 97 | func StopAllExecutors(executors map[string]Executor) { 98 | for _, exec := range executors { 99 | if exec != nil { 100 | exec.Stop() 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lua/etcd_test.go: -------------------------------------------------------------------------------- 1 | package lua 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | glua "github.com/yuin/gopher-lua" 11 | clientv3 "go.etcd.io/etcd/client/v3" 12 | 13 | "github.com/numkem/msgscript/store" 14 | ) 15 | 16 | func testingEtcdClient() *clientv3.Client { 17 | client, err := store.EtcdClient("127.0.0.1:2379") 18 | if err != nil { 19 | log.Errorf("failed to connect to etcd: %v", err) 20 | } 21 | 22 | return client 23 | } 24 | 25 | func TestLuaEtcdPut(t *testing.T) { 26 | etcdKey := "msgscript/test/put" 27 | etcdValue := "foobar" 28 | 29 | luaScript := fmt.Sprintf(` 30 | etcd = require("etcd") 31 | 32 | local err = etcd.put("%s", "%s") 33 | assert(err == nil) 34 | `, etcdKey, etcdValue) 35 | 36 | L := glua.NewState() 37 | defer L.Close() 38 | 39 | PreloadEtcd(L) 40 | 41 | err := L.DoString(luaScript) 42 | assert.Nil(t, err) 43 | 44 | // Check that the key actually exists 45 | resp, err := testingEtcdClient().Get(context.Background(), etcdKey) 46 | assert.Nil(t, err) 47 | assert.Len(t, resp.Kvs, 1) 48 | assert.Equal(t, etcdValue, string(resp.Kvs[0].Value)) 49 | 50 | teardown() 51 | } 52 | 53 | func TestLuaEtcdGet(t *testing.T) { 54 | etcdKey := "msgscript/test/get" 55 | etcdValue := "foobar" 56 | 57 | // Set the key 58 | client := testingEtcdClient() 59 | _, err := client.Put(context.Background(), etcdKey, etcdValue) 60 | assert.Nil(t, err) 61 | 62 | luaScript := fmt.Sprintf(` 63 | etcd = require("etcd") 64 | 65 | local resp, err = etcd.get("%s", false) 66 | assert(err == nil) 67 | return resp[1]:getValue() 68 | `, etcdKey) 69 | 70 | L := glua.NewState() 71 | defer L.Close() 72 | 73 | PreloadEtcd(L) 74 | 75 | err = L.DoString(luaScript) 76 | assert.Nil(t, err) 77 | 78 | // Fetch the value from the lua script 79 | result := L.Get(-1) 80 | val, ok := result.(glua.LString) 81 | if !ok { 82 | assert.Fail(t, "script should return a string") 83 | } 84 | assert.Equal(t, etcdValue, val.String()) 85 | 86 | teardown() 87 | } 88 | 89 | func TestLuaEtcdDelete(t *testing.T) { 90 | etcdKey := "msgscript/test/delete" 91 | etcdValue := "foobar" 92 | 93 | // Set a key to be deleted 94 | testingEtcdClient().Put(context.Background(), etcdKey, etcdValue) 95 | 96 | luaScript := fmt.Sprintf(` 97 | etcd = require("etcd") 98 | 99 | local err = etcd.delete("%s") 100 | assert(err == nil) 101 | `, etcdKey) 102 | 103 | L := glua.NewState() 104 | defer L.Close() 105 | 106 | PreloadEtcd(L) 107 | 108 | err := L.DoString(luaScript) 109 | assert.Nil(t, err) 110 | 111 | // Make sure the key is really gone 112 | resp, err := testingEtcdClient().Get(context.Background(), etcdKey) 113 | assert.Nil(t, err) 114 | assert.Empty(t, resp.Kvs) 115 | 116 | teardown() 117 | } 118 | 119 | func teardown() { 120 | _, err := testingEtcdClient().Delete(context.Background(), "msgscript/test", clientv3.WithPrefix()) 121 | if err != nil { 122 | log.Fatalf("failed to delete etcd testing keys: %v", err) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/numkem/msgscript/script" 11 | ) 12 | 13 | // Available backend options 14 | const ( 15 | BACKEND_ETCD_NAME = "etcd" 16 | BACKEND_SQLITE_NAME = "sqlite" 17 | BACKEND_FILE_NAME = "file" 18 | ) 19 | 20 | type ScriptStore interface { 21 | AddScript(ctx context.Context, subject string, name string, scr *script.Script) error 22 | DeleteScript(ctx context.Context, subject, name string) error 23 | GetScripts(ctx context.Context, subject string) (map[string]*script.Script, error) 24 | ReleaseLock(ctx context.Context, path string) error 25 | TakeLock(ctx context.Context, path string) (bool, error) 26 | WatchScripts(ctx context.Context, subject string, onChange func(subject, path string, script []byte, deleted bool)) 27 | ListSubjects(ctx context.Context) ([]string, error) 28 | LoadLibrairies(ctx context.Context, libraryPaths []string) ([][]byte, error) 29 | AddLibrary(ctx context.Context, content []byte, path string) error 30 | RemoveLibrary(ctx context.Context, path string) error 31 | } 32 | 33 | func StoreByName(name, etcdEndpoints, scriptDir, libraryDir string) (ScriptStore, error) { 34 | switch name { 35 | case BACKEND_ETCD_NAME: 36 | scriptStore, err := NewEtcdScriptStore(etcdEndpoints) 37 | if err != nil { 38 | return nil, fmt.Errorf("Failed to initialize etcd store: %w", err) 39 | } 40 | 41 | return scriptStore, nil 42 | 43 | case BACKEND_FILE_NAME: 44 | // Validate that the script directory isn't empty at the very least 45 | if scriptDir == "" { 46 | return nil, fmt.Errorf("script directory cannot be empty") 47 | } 48 | // Convert it to an absolute, easier for debugging 49 | scriptDir, err := filepath.Abs(scriptDir) 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to convert script directory %s to an aboslute", scriptDir) 52 | } 53 | 54 | scriptStore, err := NewFileScriptStore(scriptDir, libraryDir) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to initialize file store: %w", err) 57 | } 58 | 59 | // Read all the scripts from the scripts directory and add them to the store 60 | allScripts, err := script.ReadScriptDirectory(scriptDir, false) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to read scripts: %w", err) 63 | } 64 | 65 | var nbScripts int 66 | for subject, namedScripts := range allScripts { 67 | for name, scr := range namedScripts { 68 | log.WithField("subject", subject).WithField("name", name).Debug("loading script") 69 | scriptStore.AddScript(context.Background(), subject, name, scr) 70 | nbScripts++ 71 | } 72 | } 73 | log.Infof("loaded %d scripts from %s", nbScripts, scriptDir) 74 | 75 | if libraryDir != "" { 76 | // Read libraries from the library directory 77 | allLibrairies, err := script.ReadLibraryDirectory(libraryDir) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to read library directory: %w", err) 80 | } 81 | 82 | var nbLibraries int 83 | for _, library := range allLibrairies { 84 | scriptStore.AddLibrary(context.Background(), library.Content, library.Name) 85 | nbLibraries++ 86 | } 87 | log.Infof("loaded %d libraries from %s", nbLibraries, libraryDir) 88 | } 89 | 90 | return scriptStore, nil 91 | } 92 | 93 | return nil, fmt.Errorf("Unknown backend: %s", name) 94 | } 95 | -------------------------------------------------------------------------------- /lua/etcd.go: -------------------------------------------------------------------------------- 1 | package lua 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/yuin/gopher-lua" 9 | clientv3 "go.etcd.io/etcd/client/v3" 10 | 11 | msgstore "github.com/numkem/msgscript/store" 12 | ) 13 | 14 | func PreloadEtcd(L *lua.LState) { 15 | L.PreloadModule("etcd", etcdLoader) 16 | } 17 | 18 | func etcdLoader(L *lua.LState) int { 19 | c, err := msgstore.EtcdClient("127.0.0.1:2379") // Set a default value 20 | if err != nil { 21 | log.WithField("endpoints", os.Getenv("ETCD_ENDPOINTS")).Errorf("failed to connect to etcd: %v", err) 22 | } 23 | l := luaEtcd{client: c} 24 | 25 | mod := L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 26 | "put": l.Put, 27 | "get": l.Get, 28 | "delete": l.Delete, 29 | }) 30 | 31 | mt := L.NewTypeMetatable("EtcdKV") 32 | L.SetGlobal("EtcdKV", mt) 33 | L.SetField(mt, "new", L.NewFunction(newEtcdKV)) 34 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 35 | "getKey": luaEtcdKvGetKey, 36 | "getValue": luaEtcdKvGetValue, 37 | })) 38 | 39 | L.Push(mod) 40 | return 1 41 | } 42 | 43 | func luaEtcdKvGetKey(L *lua.LState) int { 44 | L.Push(lua.LString(L.CheckUserData(1).Value.(*luaEtcdKVs).Key)) 45 | return 1 46 | } 47 | 48 | func luaEtcdKvGetValue(L *lua.LState) int { 49 | L.Push(lua.LString(L.CheckUserData(1).Value.(*luaEtcdKVs).Value)) 50 | return 1 51 | } 52 | 53 | func newEtcdKV(L *lua.LState) int { 54 | key := L.CheckString(1) 55 | value := L.CheckString(2) 56 | kv := &luaEtcdKVs{ 57 | Key: key, 58 | Value: value, 59 | } 60 | 61 | ud := L.NewUserData() 62 | ud.Value = kv 63 | L.SetMetatable(ud, L.GetTypeMetatable("EtcdKV")) 64 | L.Push(ud) 65 | return 1 66 | } 67 | 68 | type luaEtcd struct { 69 | client *clientv3.Client 70 | } 71 | 72 | type luaEtcdKVs struct { 73 | Key string 74 | Value string 75 | } 76 | 77 | func (l *luaEtcd) Get(L *lua.LState) int { 78 | key := L.CheckString(1) 79 | prefix := L.CheckBool(2) 80 | 81 | var resp *clientv3.GetResponse 82 | var err error 83 | if prefix { 84 | resp, err = l.client.Get(context.TODO(), key, clientv3.WithPrefix()) 85 | } else { 86 | resp, err = l.client.Get(context.TODO(), key) 87 | } 88 | if err != nil { 89 | L.Push(lua.LNil) 90 | L.Push(lua.LString(err.Error())) 91 | return 2 92 | } 93 | 94 | if resp.Count == 0 { 95 | L.Push(lua.LNil) 96 | L.Push(lua.LNil) 97 | return 2 98 | } 99 | 100 | kvTable := L.NewTable() 101 | for _, kv := range resp.Kvs { 102 | ud := L.NewUserData() 103 | ud.Value = &luaEtcdKVs{ 104 | Key: string(kv.Key), 105 | Value: string(kv.Value), 106 | } 107 | L.SetMetatable(ud, L.GetTypeMetatable("EtcdKV")) 108 | kvTable.Append(ud) 109 | } 110 | 111 | L.Push(kvTable) 112 | L.Push(lua.LNil) 113 | return 2 114 | } 115 | 116 | func (l *luaEtcd) Put(L *lua.LState) int { 117 | key := L.CheckString(1) 118 | value := L.CheckString(2) 119 | _, err := l.client.Put(context.TODO(), key, value) 120 | if err != nil { 121 | L.Push(lua.LString(err.Error())) 122 | return 1 123 | } 124 | 125 | L.Push(lua.LNil) 126 | return 1 127 | } 128 | 129 | func (l *luaEtcd) Delete(L *lua.LState) int { 130 | key := L.CheckString(1) 131 | _, err := l.client.Delete(context.TODO(), key) 132 | if err != nil { 133 | L.Push(lua.LString(err.Error())) 134 | return 2 135 | } 136 | 137 | L.Push(lua.LNil) 138 | return 1 139 | } 140 | -------------------------------------------------------------------------------- /cmd/cli/cmd_lib_add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | 12 | scriptLib "github.com/numkem/msgscript/script" 13 | msgstore "github.com/numkem/msgscript/store" 14 | ) 15 | 16 | var libAddCmd = &cobra.Command{ 17 | Use: "add", 18 | Short: "add libraries", 19 | Long: "add libraries either as a single file or as a directory. Only files ending in .lua will be added", 20 | Run: libAddRun, 21 | } 22 | 23 | func init() { 24 | libCmd.AddCommand(libAddCmd) 25 | 26 | libAddCmd.PersistentFlags().BoolP("recursive", "r", false, "Add files in path recursively") 27 | libAddCmd.PersistentFlags().String("name", "n", "Name of the library") 28 | 29 | libAddCmd.MarkFlagRequired("name") 30 | } 31 | 32 | func libAddRun(cmd *cobra.Command, args []string) { 33 | store, err := msgstore.StoreByName(cmd.Flag("backend").Value.String(), cmd.Flag("etcdurls").Value.String(), "", "") 34 | if err != nil { 35 | log.Errorf("failed to create store: %v", err) 36 | return 37 | } 38 | 39 | recursive, err := cmd.Flags().GetBool("recursive") 40 | if err != nil { 41 | cmd.PrintErrln(fmt.Errorf("failed parse recursive flag: %w", err)) 42 | return 43 | } 44 | 45 | libraries, err := parseDirsForLibraries(args, recursive) 46 | if err != nil { 47 | cmd.PrintErrln(fmt.Errorf("failed to parse directories for librairies: %w", err)) 48 | return 49 | } 50 | 51 | for _, lib := range libraries { 52 | err = store.AddLibrary(cmd.Context(), lib.Content, lib.Name) 53 | if err != nil { 54 | cmd.PrintErrln(fmt.Errorf("failed to add library %s to the store: %w", lib.Name, err)) 55 | return 56 | } 57 | } 58 | 59 | cmd.Printf("Added %d libraries\n", len(libraries)) 60 | } 61 | 62 | func parseDirsForLibraries(dirnames []string, recursive bool) ([]*scriptLib.Script, error) { 63 | var scripts []*scriptLib.Script 64 | for _, fname := range dirnames { 65 | stat, err := os.Stat(fname) 66 | if err != nil { 67 | return nil, fmt.Errorf("failed to stat %s: %w", fname, err) 68 | } 69 | 70 | if stat.IsDir() { 71 | if recursive { 72 | fsys := os.DirFS(fname) 73 | err = fs.WalkDir(fsys, ".", func(filename string, d os.DirEntry, err error) error { 74 | if err != nil { 75 | return err 76 | } 77 | 78 | if path.Ext(filename) == ".lua" { 79 | fullname := path.Join(fname, filename) 80 | s, err := scriptLib.ReadFile(fullname) 81 | if err != nil { 82 | return fmt.Errorf("failed to read script %s: %w", fullname, err) 83 | } 84 | 85 | scripts = append(scripts, s) 86 | } 87 | 88 | return nil 89 | }) 90 | } else { 91 | entries, err := os.ReadDir(fname) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to read directory: %w", err) 94 | } 95 | 96 | for _, e := range entries { 97 | if path.Ext(e.Name()) == ".lua" { 98 | fullname := path.Join(fname, e.Name()) 99 | 100 | s, err := scriptLib.ReadFile(fname) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to read script %s: %w", fullname, err) 103 | } 104 | 105 | scripts = append(scripts, s) 106 | } 107 | } 108 | 109 | } 110 | } else { 111 | s, err := scriptLib.ReadFile(fname) 112 | if err != nil { 113 | return nil, fmt.Errorf("failed to read file %s: %w", fname, err) 114 | } 115 | 116 | if s.Name == "" { 117 | return nil, fmt.Errorf("script at %s requires to have the 'name' header", fname) 118 | } 119 | 120 | scripts = append(scripts, s) 121 | } 122 | } 123 | 124 | return scripts, nil 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: ["master"] 11 | # Publish semver tags as releases. 12 | tags: ["v*.*.*"] 13 | pull_request: 14 | branches: ["master"] 15 | 16 | env: 17 | # Use docker.io for Docker Hub if empty 18 | REGISTRY: ghcr.io 19 | # github.repository as / 20 | IMAGE_NAME: ${{ github.repository }} 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | # This is used to complete the identity challenge 29 | # with sigstore/fulcio when running outside of PRs. 30 | id-token: write 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | 36 | # Install the cosign tool except on PR 37 | # https://github.com/sigstore/cosign-installer 38 | - name: Install cosign 39 | if: github.event_name != 'pull_request' 40 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 41 | with: 42 | cosign-release: "v2.2.4" 43 | 44 | # Set up BuildKit Docker container builder to be able to build 45 | # multi-platform images and export cache 46 | # https://github.com/docker/setup-buildx-action 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 49 | 50 | # Login against a Docker registry except on PR 51 | # https://github.com/docker/login-action 52 | - name: Log into registry ${{ env.REGISTRY }} 53 | if: github.event_name != 'pull_request' 54 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 55 | with: 56 | registry: ${{ env.REGISTRY }} 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | # Extract metadata (tags, labels) for Docker 61 | # https://github.com/docker/metadata-action 62 | - name: Extract Docker metadata 63 | id: meta 64 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 65 | with: 66 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 67 | 68 | # Build and push Docker image with Buildx (don't push on PR) 69 | # https://github.com/docker/build-push-action 70 | - name: Build and push Docker image 71 | id: build-and-push 72 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 73 | with: 74 | context: . 75 | push: ${{ github.event_name != 'pull_request' }} 76 | tags: ${{ steps.meta.outputs.tags }} 77 | labels: ${{ steps.meta.outputs.labels }} 78 | cache-from: type=gha 79 | cache-to: type=gha,mode=max 80 | 81 | # Sign the resulting Docker image digest except on PRs. 82 | # This will only write to the public Rekor transparency log when the Docker 83 | # repository is public to avoid leaking data. If you would like to publish 84 | # transparency data even for private images, pass --force to cosign below. 85 | # https://github.com/sigstore/cosign 86 | - name: Sign the published Docker image 87 | if: ${{ github.event_name != 'pull_request' }} 88 | env: 89 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 90 | TAGS: ${{ steps.meta.outputs.tags }} 91 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 92 | # This step uses the identity token to provision an ephemeral certificate 93 | # against the sigstore community Fulcio instance. 94 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 95 | -------------------------------------------------------------------------------- /store/dev.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/numkem/msgscript/script" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type DevStore struct { 16 | // Key pattern: 17 | // First: subject 18 | // Second: name 19 | // Value: content 20 | scripts map[string]map[string]*script.Script 21 | libraries map[string][]byte 22 | } 23 | 24 | func NewDevStore(libraryPath string) (ScriptStore, error) { 25 | store := &DevStore{ 26 | scripts: make(map[string]map[string]*script.Script), 27 | libraries: make(map[string][]byte), 28 | } 29 | 30 | if libraryPath != "" { 31 | stat, err := os.Stat(libraryPath) 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to read library path %s: %w", libraryPath, err) 34 | } 35 | if !stat.IsDir() { 36 | return nil, fmt.Errorf("given library path %s isn't a directory", libraryPath) 37 | } 38 | 39 | fsys := os.DirFS(libraryPath) 40 | err = fs.WalkDir(fsys, ".", func(filename string, d os.DirEntry, err error) error { 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if path.Ext(filename) == ".lua" { 46 | filepath := path.Join(libraryPath, filename) 47 | content, err := os.ReadFile(filepath) 48 | if err != nil { 49 | return fmt.Errorf("failed to read %s: %w", filename, err) 50 | } 51 | 52 | p := strings.Replace(strings.Replace(filename, libraryPath, "", 1), path.Ext(filename), "", 1) 53 | log.Debugf("loading library %s", p) 54 | store.AddLibrary(context.Background(), content, p) 55 | } 56 | 57 | return nil 58 | }) 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to walk through directory %s for library files: %w", libraryPath, err) 61 | } 62 | } 63 | 64 | return store, nil 65 | } 66 | 67 | func (s *DevStore) onChange(subject, name string, scr *script.Script, del bool) { 68 | if !del { 69 | if _, found := s.scripts[subject]; !found { 70 | s.scripts[subject] = make(map[string]*script.Script) 71 | } 72 | 73 | s.scripts[subject][name] = scr 74 | } else { 75 | delete(s.scripts[subject], name) 76 | } 77 | 78 | return 79 | } 80 | 81 | func (s *DevStore) WatchScripts(ctx context.Context, subject string, onChange func(subject, name string, script []byte, delete bool)) { 82 | onChange(subject, "", nil, false) 83 | } 84 | 85 | func (s *DevStore) AddScript(ctx context.Context, subject, name string, script *script.Script) error { 86 | s.onChange(subject, name, script, false) 87 | 88 | return nil 89 | } 90 | 91 | func (s *DevStore) DeleteScript(ctx context.Context, subject, name string) error { 92 | s.onChange(subject, name, nil, true) 93 | 94 | return nil 95 | } 96 | 97 | func (s *DevStore) GetScripts(ctx context.Context, subject string) (map[string]*script.Script, error) { 98 | return s.scripts[subject], nil 99 | } 100 | 101 | func (s *DevStore) TakeLock(ctx context.Context, path string) (bool, error) { 102 | return true, nil 103 | } 104 | 105 | func (s *DevStore) ReleaseLock(ctx context.Context, path string) error { 106 | return nil 107 | } 108 | 109 | func (s *DevStore) ListSubjects(ctx context.Context) ([]string, error) { 110 | var subjects []string 111 | 112 | for subject := range s.scripts { 113 | subjects = append(subjects, subject) 114 | } 115 | 116 | return subjects, nil 117 | } 118 | 119 | func (s *DevStore) LoadLibrairies(ctx context.Context, libraryPaths []string) ([][]byte, error) { 120 | var libraries [][]byte 121 | for _, path := range libraryPaths { 122 | if l, found := s.libraries[path]; found { 123 | libraries = append(libraries, l) 124 | } 125 | } 126 | 127 | return libraries, nil 128 | } 129 | 130 | func (s *DevStore) AddLibrary(ctx context.Context, content []byte, path string) error { 131 | s.libraries[path] = content 132 | 133 | return nil 134 | } 135 | 136 | func (s *DevStore) RemoveLibrary(ctx context.Context, path string) error { 137 | delete(s.libraries, path) 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /cmd/cli/cmd_dev.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/numkem/msgscript/executor" 10 | msgplugin "github.com/numkem/msgscript/plugins" 11 | scriptLib "github.com/numkem/msgscript/script" 12 | msgstore "github.com/numkem/msgscript/store" 13 | ) 14 | 15 | var devCmd = &cobra.Command{ 16 | Use: "dev", 17 | Args: validateArgIsPath, 18 | Short: "Executes the script locally like how the server would", 19 | Run: devCmdRun, 20 | } 21 | 22 | func init() { 23 | rootCmd.AddCommand(devCmd) 24 | 25 | devCmd.PersistentFlags().StringP("subject", "s", "", "The NATS subject to respond to") 26 | devCmd.PersistentFlags().StringP("name", "n", "", "The name of the script in the backend") 27 | devCmd.PersistentFlags().StringP("input", "i", "", "Path to or actual payload to send to the function") 28 | devCmd.PersistentFlags().StringP("library", "l", "", "Path to a folder containing libraries to load for the function") 29 | devCmd.PersistentFlags().StringP("pluginDir", "p", "", "Path to a folder with plugins") 30 | 31 | devCmd.MarkFlagRequired("subject") 32 | devCmd.MarkFlagRequired("name") 33 | devCmd.MarkFlagRequired("input") 34 | } 35 | 36 | func devCmdRun(cmd *cobra.Command, args []string) { 37 | store, err := msgstore.NewDevStore(cmd.Flag("library").Value.String()) 38 | if err != nil { 39 | cmd.PrintErrf("failed to create store: %v\n", err) 40 | return 41 | } 42 | 43 | var plugins []msgplugin.PreloadFunc 44 | if path := cmd.Flag("pluginDir").Value.String(); path != "" { 45 | plugins, err = msgplugin.ReadPluginDir(path) 46 | if err != nil { 47 | cmd.PrintErrf("failed to read plugins: %v\n", err) 48 | return 49 | } 50 | } 51 | 52 | subject := cmd.Flag("subject").Value.String() 53 | name := cmd.Flag("name").Value.String() 54 | 55 | // Try to read the file to see if we can find headers 56 | scr, err := scriptLib.ReadFile(args[0]) 57 | if err != nil { 58 | log.Errorf("failed to read the script file %s: %v\n", args[0], err) 59 | return 60 | } 61 | 62 | if subject == "" { 63 | if scr.Subject == "" { 64 | cmd.PrintErrf("subject is required\n") 65 | return 66 | } 67 | 68 | subject = scr.Subject 69 | } 70 | if name == "" { 71 | if scr.Name == "" { 72 | cmd.PrintErrf("name is required\n") 73 | return 74 | } 75 | 76 | name = scr.Name 77 | } 78 | 79 | // Add the given script to the store 80 | err = store.AddScript(cmd.Context(), subject, name, scr) 81 | if err != nil { 82 | cmd.PrintErrf("failed to add script to store: %v\n", err) 83 | return 84 | } 85 | 86 | payloadFlag := cmd.Flag("input").Value.String() 87 | var payload []byte 88 | // Check if the payload is a path to a file 89 | if _, err := os.Stat(payloadFlag); err == nil { 90 | content, err := os.ReadFile(payloadFlag) 91 | if err != nil { 92 | cmd.PrintErrf("failed to read payload file %s: %v\n", payloadFlag, err) 93 | return 94 | } 95 | 96 | payload = content 97 | } else { 98 | payload = []byte(payloadFlag) 99 | } 100 | log.Debug("loaded payload") 101 | 102 | fields := log.Fields{ 103 | "subject": subject, 104 | "payload": string(payload), 105 | "lua_filename": args[0], 106 | } 107 | 108 | log.WithFields(fields).Debug("running the function") 109 | 110 | m := &executor.Message{ 111 | Payload: payload, 112 | Subject: subject, 113 | Executor: cmd.Flag("executor").Value.String(), 114 | } 115 | 116 | executors := executor.StartAllExecutors(cmd.Context(), store, plugins, nil) 117 | exec, err := executor.ExecutorByName(m.Executor, executors) 118 | if err != nil { 119 | cmd.PrintErrf("failed to get executor for message: %v", err) 120 | return 121 | } 122 | 123 | res := exec.HandleMessage(cmd.Context(), m, scr) 124 | if res.Error != "" { 125 | cmd.PrintErrf("Error while running script: %v", err) 126 | return 127 | } 128 | executor.StopAllExecutors(executors) 129 | 130 | cmd.Printf("Result: %s\n", string(res.Payload)) 131 | } 132 | -------------------------------------------------------------------------------- /cmd/server/otel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/nats-io/nats.go" 11 | "go.opentelemetry.io/otel" 12 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 13 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 14 | "go.opentelemetry.io/otel/propagation" 15 | "go.opentelemetry.io/otel/sdk/resource" 16 | "go.opentelemetry.io/otel/sdk/trace" 17 | semconv "go.opentelemetry.io/otel/semconv/v1.37.0" 18 | "google.golang.org/grpc" 19 | "google.golang.org/grpc/credentials/insecure" 20 | ) 21 | 22 | // natsHeaderCarrier adapts NATS headers to OpenTelemetry propagation 23 | type natsHeaderCarrier nats.Header 24 | 25 | func (n natsHeaderCarrier) Get(key string) string { 26 | return nats.Header(n).Get(key) 27 | } 28 | 29 | func (n natsHeaderCarrier) Set(key string, value string) { 30 | nats.Header(n).Set(key, value) 31 | } 32 | 33 | func (n natsHeaderCarrier) Keys() []string { 34 | keys := make([]string, 0, len(n)) 35 | for k := range n { 36 | keys = append(keys, k) 37 | } 38 | return keys 39 | } 40 | 41 | // setupOTelSDK bootstraps the OpenTelemetry pipeline. 42 | // If it does not return an error, make sure to call shutdown for proper cleanup. 43 | func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) { 44 | var shutdownFuncs []func(context.Context) error 45 | var err error 46 | 47 | // shutdown calls cleanup functions registered via shutdownFuncs. 48 | // The errors from the calls are joined. 49 | // Each registered cleanup will be invoked once. 50 | shutdown := func(ctx context.Context) error { 51 | var err error 52 | for _, fn := range shutdownFuncs { 53 | err = errors.Join(err, fn(ctx)) 54 | } 55 | shutdownFuncs = nil 56 | return err 57 | } 58 | 59 | // handleErr calls shutdown for cleanup and makes sure that all errors are returned. 60 | handleErr := func(inErr error) { 61 | err = errors.Join(inErr, shutdown(ctx)) 62 | } 63 | 64 | // Set up propagator. 65 | prop := newPropagator() 66 | otel.SetTextMapPropagator(prop) 67 | 68 | // Set up trace provider. 69 | tracerProvider, err := newTracerProvider(ctx) 70 | if err != nil { 71 | handleErr(err) 72 | return shutdown, err 73 | } 74 | shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) 75 | otel.SetTracerProvider(tracerProvider) 76 | 77 | return shutdown, err 78 | } 79 | 80 | func newPropagator() propagation.TextMapPropagator { 81 | return propagation.NewCompositeTextMapPropagator( 82 | propagation.TraceContext{}, 83 | propagation.Baggage{}, 84 | ) 85 | } 86 | 87 | func tempoTraceExporter(ctx context.Context, tempoEndpoint string) (trace.SpanExporter, error) { 88 | conn, err := grpc.NewClient( 89 | tempoEndpoint, 90 | grpc.WithTransportCredentials(insecure.NewCredentials()), 91 | ) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | return otlptracegrpc.New(ctx, 97 | otlptracegrpc.WithGRPCConn(conn), 98 | ) 99 | } 100 | 101 | func newTracerProvider(ctx context.Context) (*trace.TracerProvider, error) { 102 | var traceExporter trace.SpanExporter 103 | var err error 104 | 105 | endpoint := os.Getenv("OTEL_ENDPOINT") 106 | if endpoint == "" { 107 | traceExporter, err = stdouttrace.New( 108 | stdouttrace.WithPrettyPrint()) 109 | if err != nil { 110 | return nil, fmt.Errorf("failed to create stdout stdout trace exporter") 111 | } 112 | } else { 113 | traceExporter, err = tempoTraceExporter(ctx, endpoint) 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to connect to tempo GRPC endpoint: %v", err) 116 | } 117 | } 118 | 119 | res, err := resource.Merge( 120 | resource.Default(), 121 | resource.NewWithAttributes( 122 | semconv.SchemaURL, 123 | semconv.ServiceName("msgscript"), 124 | semconv.ServiceVersion(version), 125 | ), 126 | ) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | // Create tracer provider 132 | tracerProvider := trace.NewTracerProvider( 133 | trace.WithBatcher(traceExporter, 134 | trace.WithBatchTimeout(time.Second*5), 135 | ), 136 | trace.WithResource(res), 137 | ) 138 | 139 | return tracerProvider, nil 140 | } 141 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Run Lua function from nats subjects"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | }; 7 | 8 | outputs = 9 | { self, nixpkgs }: 10 | let 11 | version = "0.8.2"; 12 | vendorHash = "sha256-IsLAIKYuKhlD71fad8FuayTFbdQJla4ifjs8TexXDYQ="; 13 | 14 | mkPlugin = 15 | pkgs: name: path: 16 | pkgs.buildGoModule { 17 | name = "msgscript-plugin-${name}"; 18 | 19 | inherit vendorHash; 20 | 21 | src = self; 22 | 23 | subPackages = [ path ]; 24 | 25 | doUnpack = false; 26 | doCheck = false; 27 | 28 | buildPhase = '' 29 | go build -buildmode=plugin -o ${name}.so ${path}/main.go 30 | ''; 31 | 32 | installPhase = '' 33 | mkdir $out 34 | cp ${name}.so $out/ 35 | ''; 36 | }; 37 | in 38 | { 39 | packages.x86_64-linux = 40 | let 41 | pkgs = import nixpkgs { system = "x86_64-linux"; }; 42 | lib = pkgs.lib; 43 | in 44 | rec { 45 | cli = pkgs.callPackage ./nix/pkgs/cli.nix { 46 | inherit version vendorHash; 47 | }; 48 | server = pkgs.callPackage ./nix/pkgs/server.nix { 49 | inherit version vendorHash; 50 | }; 51 | default = server; 52 | 53 | allPlugins = pkgs.symlinkJoin { 54 | name = "msgscript-all-plugins"; 55 | paths = lib.attrValues plugins; 56 | }; 57 | 58 | plugins = 59 | let 60 | pluginDirs = lib.remove "" ( 61 | lib.mapAttrsToList (name: kind: if kind == "directory" then name else "") ( 62 | builtins.readDir "${self}/plugins/" 63 | ) 64 | ); 65 | in 66 | lib.genAttrs pluginDirs (name: mkPlugin pkgs name "${self}/plugins/${name}"); 67 | }; 68 | packages.aarch64-linux = 69 | let 70 | pkgs = import nixpkgs { system = "aarch64-linux"; }; 71 | lib = pkgs.lib; 72 | in 73 | rec { 74 | cli = pkgs.callPackage ./nix/pkgs/cli.nix { 75 | inherit version vendorHash; 76 | }; 77 | server = pkgs.callPackage ./nix/pkgs/server.nix { 78 | inherit version vendorHash; 79 | }; 80 | default = server; 81 | 82 | allPlugins = pkgs.symlinkJoin { 83 | name = "msgscript-all-plugins"; 84 | paths = lib.attrValues plugins; 85 | }; 86 | 87 | plugins = 88 | let 89 | pluginDirs = lib.remove "" ( 90 | lib.mapAttrsToList (name: kind: if kind == "directory" then name else "") ( 91 | builtins.readDir "${self}/plugins/" 92 | ) 93 | ); 94 | in 95 | lib.genAttrs pluginDirs (name: mkPlugin pkgs name "${self}/plugins/${name}"); 96 | }; 97 | 98 | devShells.x86_64-linux.default = 99 | let 100 | pkgs = import nixpkgs { system = "x86_64-linux"; }; 101 | in 102 | pkgs.mkShell { 103 | buildInputs = with pkgs; [ 104 | go 105 | just 106 | etcd 107 | natscli 108 | nats-top 109 | pandoc 110 | 111 | # wasm 112 | tinygo 113 | wasmtime 114 | wasmtime.dev 115 | 116 | # Deps for podman 117 | pkg-config 118 | btrfs-progs 119 | gpgme 120 | 121 | # Server compose 122 | arion 123 | 124 | # LSPs 125 | gopls 126 | lua-language-server 127 | ]; 128 | 129 | shellHook = '' 130 | export GOOS=linux 131 | export GOARCH=amd64 132 | ''; 133 | }; 134 | 135 | overlays.default = final: prev: { 136 | msgscript-cli = self.packages.${final.system}.cli; 137 | msgscript-server = self.packages.${final.system}.server; 138 | }; 139 | 140 | nixosModules.default = import ./nix/modules/default.nix; 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /store/file.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "sync" 8 | 9 | "github.com/fsnotify/fsnotify" 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/numkem/msgscript/script" 13 | ) 14 | 15 | type FileScriptStore struct { 16 | filePath string 17 | scripts *sync.Map 18 | libs *sync.Map 19 | } 20 | 21 | type fileStoreMapValue map[string]*script.Script 22 | 23 | func NewFileScriptStore(scriptPath string, libraryPath string) (ScriptStore, error) { 24 | return &FileScriptStore{ 25 | scripts: new(sync.Map), 26 | libs: new(sync.Map), 27 | }, nil 28 | } 29 | 30 | func (f *FileScriptStore) GetScripts(ctx context.Context, subject string) (map[string]*script.Script, error) { 31 | res, ok := f.scripts.Load(subject) 32 | if !ok { 33 | return nil, fmt.Errorf("script not found for subject: %s", subject) 34 | } 35 | 36 | r, _ := res.(fileStoreMapValue) 37 | 38 | return r, nil 39 | } 40 | 41 | func (f *FileScriptStore) AddScript(ctx context.Context, subject, name string, scr *script.Script) error { 42 | sl, ok := f.scripts.Load(subject) 43 | if !ok { 44 | f.scripts.Store(subject, make(fileStoreMapValue)) 45 | sl, _ = f.scripts.Load(subject) 46 | } 47 | 48 | scrm := sl.(fileStoreMapValue) 49 | scrm[name] = scr 50 | f.scripts.Store(subject, scrm) 51 | 52 | return nil 53 | } 54 | 55 | func (f *FileScriptStore) DeleteScript(ctx context.Context, subject, name string) error { 56 | sl, ok := f.scripts.Load(subject) 57 | if !ok { 58 | return nil 59 | } 60 | 61 | scrm := sl.(fileStoreMapValue) 62 | delete(scrm, name) 63 | 64 | f.scripts.Store(subject, scrm) 65 | return nil 66 | } 67 | 68 | func (f *FileScriptStore) ReleaseLock(ctx context.Context, path string) error { 69 | return nil 70 | } 71 | 72 | func (f *FileScriptStore) TakeLock(ctx context.Context, path string) (bool, error) { 73 | return true, nil 74 | } 75 | 76 | // WatchScripts uses fsnotify to monitor the script file for changes 77 | func (f *FileScriptStore) WatchScripts(ctx context.Context, subject string, onChange func(subject, path string, script []byte, deleted bool)) { 78 | watcher, err := fsnotify.NewWatcher() 79 | if err != nil { 80 | log.Fatalf("failed to create watcher: %v", err) 81 | } 82 | defer watcher.Close() 83 | 84 | // Add the file to the watcher 85 | err = watcher.Add(f.filePath) 86 | if err != nil { 87 | log.Fatalf("failed to add file to watcher: %v", err) 88 | } 89 | 90 | log.Infof("Started watching file: %s", f.filePath) 91 | 92 | for { 93 | select { 94 | case event, ok := <-watcher.Events: 95 | if !ok { 96 | return 97 | } 98 | 99 | // We only care about write events, which means the file has been modified 100 | if event.Op&fsnotify.Write == fsnotify.Write { 101 | log.Infof("File modified: %s", f.filePath) 102 | 103 | scr, err := script.ReadFile(filepath.Join(f.filePath, event.Name)) 104 | if err != nil { 105 | log.Errorf("failed to read script file %s: %v", f.filePath, err) 106 | } 107 | 108 | // Trigger the onChange callback for each script 109 | onChange(scr.Subject, f.filePath, scr.Content, false) 110 | } 111 | 112 | case err, ok := <-watcher.Errors: 113 | if !ok { 114 | return 115 | } 116 | log.Errorf("Watcher error: %v", err) 117 | 118 | case <-ctx.Done(): 119 | log.Info("Stopping watcher") 120 | return 121 | } 122 | } 123 | } 124 | 125 | func (f *FileScriptStore) ListSubjects(ctx context.Context) ([]string, error) { 126 | var subjects []string 127 | 128 | f.scripts.Range(func(key, value any) bool { 129 | subject := key.(string) 130 | subjects = append(subjects, subject) 131 | 132 | return true 133 | }) 134 | 135 | return subjects, nil 136 | } 137 | 138 | func (f *FileScriptStore) LoadLibrairies(ctx context.Context, libraryPaths []string) ([][]byte, error) { 139 | var libraries [][]byte 140 | for _, path := range libraryPaths { 141 | v, ok := f.libs.Load(path) 142 | if ok { 143 | libraries = append(libraries, v.([]byte)) 144 | } 145 | } 146 | 147 | return libraries, nil 148 | } 149 | 150 | func (f *FileScriptStore) AddLibrary(ctx context.Context, content []byte, path string) error { 151 | f.libs.Store(path, []byte(content)) 152 | return nil 153 | } 154 | 155 | func (f *FileScriptStore) RemoveLibrary(ctx context.Context, path string) error { 156 | f.libs.Delete(path) 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # see https://github.com/CppCXY/EmmyLuaCodeStyle 3 | [*.lua] 4 | # [basic] 5 | 6 | # optional space/tab 7 | indent_style = space 8 | # if indent_style is space, this is valid 9 | indent_size = 4 10 | # if indent_style is tab, this is valid 11 | tab_width = 4 12 | # none/single/double 13 | quote_style = none 14 | 15 | continuation_indent = 4 16 | ## extend option 17 | # continuation_indent.before_block = 4 18 | # continuation_indent.in_expr = 4 19 | # continuation_indent.in_table = 4 20 | 21 | # this mean utf8 length , if this is 'unset' then the line width is no longer checked 22 | # this option decides when to chopdown the code 23 | max_line_length = 120 24 | 25 | # optional crlf/lf/cr/auto, if it is 'auto', in windows it is crlf other platforms are lf 26 | # in neovim the value 'auto' is not a valid option, please use 'unset' 27 | end_of_line = auto 28 | 29 | # none/ comma / semicolon / only_kv_colon 30 | table_separator_style = none 31 | 32 | #optional keep/never/always/smart 33 | trailing_table_separator = keep 34 | 35 | # keep/remove/remove_table_only/remove_string_only 36 | call_arg_parentheses = keep 37 | 38 | detect_end_of_line = false 39 | 40 | # this will check text end with new line 41 | insert_final_newline = true 42 | 43 | # [space] 44 | space_around_table_field_list = true 45 | 46 | space_before_attribute = true 47 | 48 | space_before_function_open_parenthesis = false 49 | 50 | space_before_function_call_open_parenthesis = false 51 | 52 | space_before_closure_open_parenthesis = true 53 | 54 | # optional always/only_string/only_table/none 55 | # or true/false 56 | space_before_function_call_single_arg = always 57 | ## extend option 58 | ## always/keep/none 59 | # space_before_function_call_single_arg.table = always 60 | ## always/keep/none 61 | # space_before_function_call_single_arg.string = always 62 | 63 | space_before_open_square_bracket = false 64 | 65 | space_inside_function_call_parentheses = false 66 | 67 | space_inside_function_param_list_parentheses = false 68 | 69 | space_inside_square_brackets = false 70 | 71 | # like t[#t+1] = 1 72 | space_around_table_append_operator = false 73 | 74 | ignore_spaces_inside_function_call = false 75 | 76 | # detail number or 'keep' 77 | space_before_inline_comment = 1 78 | 79 | # convert '---' to '--- ' or '--' to '-- ' 80 | space_after_comment_dash = false 81 | 82 | # [operator space] 83 | space_around_math_operator = true 84 | # space_around_math_operator.exponent = false 85 | 86 | space_after_comma = true 87 | 88 | space_after_comma_in_for_statement = true 89 | 90 | # true/false or none/always/no_space_asym 91 | space_around_concat_operator = true 92 | 93 | space_around_logical_operator = true 94 | 95 | # true/false or none/always/no_space_asym 96 | space_around_assign_operator = true 97 | 98 | # [align] 99 | 100 | align_call_args = false 101 | 102 | align_function_params = true 103 | 104 | align_continuous_assign_statement = true 105 | 106 | align_continuous_rect_table_field = true 107 | 108 | align_continuous_line_space = 2 109 | 110 | align_if_branch = false 111 | 112 | # option none / always / contain_curly/ 113 | align_array_table = true 114 | 115 | align_continuous_similar_call_args = false 116 | 117 | align_continuous_inline_comment = true 118 | # option none / always / only_call_stmt 119 | align_chain_expr = none 120 | 121 | # [indent] 122 | 123 | never_indent_before_if_condition = false 124 | 125 | never_indent_comment_on_if_branch = false 126 | 127 | keep_indents_on_empty_lines = false 128 | 129 | allow_non_indented_comments = false 130 | # [line space] 131 | 132 | # The following configuration supports four expressions 133 | # keep 134 | # fixed(n) 135 | # min(n) 136 | # max(n) 137 | # for eg. min(2) 138 | 139 | line_space_after_if_statement = keep 140 | 141 | line_space_after_do_statement = keep 142 | 143 | line_space_after_while_statement = keep 144 | 145 | line_space_after_repeat_statement = keep 146 | 147 | line_space_after_for_statement = keep 148 | 149 | line_space_after_local_or_assign_statement = keep 150 | 151 | line_space_after_function_statement = fixed(2) 152 | 153 | line_space_after_expression_statement = keep 154 | 155 | line_space_after_comment = keep 156 | 157 | line_space_around_block = fixed(1) 158 | # [line break] 159 | break_all_list_when_line_exceed = false 160 | 161 | auto_collapse_lines = false 162 | 163 | break_before_braces = false 164 | 165 | # [preference] 166 | ignore_space_after_colon = false 167 | 168 | remove_call_expression_list_finish_comma = false 169 | # keep / always / same_line / replace_with_newline / never 170 | end_statement_with_semicolon = keep 171 | -------------------------------------------------------------------------------- /nix/modules/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | lib, 4 | pkgs, 5 | ... 6 | }: 7 | 8 | with lib; 9 | let 10 | cfg = config.services.msgscript; 11 | 12 | pluginDir = pkgs.symlinkJoin { 13 | name = "msgscript-server-plugins"; 14 | paths = cfg.plugins; 15 | }; 16 | in 17 | { 18 | options.services.msgscript = { 19 | enable = mkEnableOption "Enable the msgscript service"; 20 | 21 | etcdEndpoints = mkOption { 22 | type = types.listOf types.str; 23 | default = [ "http://127.0.0.1:2379" ]; 24 | description = mdDoc "Etcd endpoints to connect to"; 25 | }; 26 | 27 | backend = mkOption { 28 | type = types.enum [ 29 | "etcd" 30 | "file" 31 | ]; 32 | default = "file"; 33 | description = "Backend to use to store/execute the functions from"; 34 | }; 35 | 36 | plugins = mkOption { 37 | type = types.listOf types.package; 38 | default = [ ]; 39 | description = "Plugins to add to the server"; 40 | }; 41 | 42 | natsUrl = mkOption { 43 | type = types.str; 44 | default = ""; 45 | description = mdDoc "Nats.io URL to connect to"; 46 | }; 47 | 48 | dataDir = mkOption { 49 | type = types.str; 50 | default = "/var/lib/msgscript"; 51 | description = mdDoc "Directory available to msgscript-server for io operation. The server owns this directory"; 52 | }; 53 | 54 | scriptDir = mkOption { 55 | type = types.str; 56 | default = "${cfg.dataDir}/scripts"; 57 | }; 58 | 59 | libraryDir = mkOption { 60 | type = types.str; 61 | default = "${cfg.dataDir}/libs"; 62 | }; 63 | 64 | user = mkOption { 65 | type = types.str; 66 | default = "msgscript"; 67 | description = "User account under which msgscript runs."; 68 | }; 69 | 70 | group = mkOption { 71 | type = types.str; 72 | default = "msgscript"; 73 | description = "Group under which msgscript runs."; 74 | }; 75 | 76 | enableTelemetry = mkOption { 77 | type = types.bool; 78 | default = false; 79 | description = "Whether to enable OpenTelemetry for msgscript."; 80 | }; 81 | 82 | otelEndpoint = mkOption { 83 | type = types.str; 84 | default = "localhost:4317"; 85 | description = "OpenTelemetry collector endpoint URL."; 86 | }; 87 | }; 88 | 89 | config = mkIf cfg.enable { 90 | systemd.services.msgscript = { 91 | description = "Run Lua function from nats subjects"; 92 | restartIfChanged = true; 93 | 94 | environment = 95 | { } 96 | // (optionalAttrs cfg.enableTelemetry { 97 | TELEMETRY_TRACES = "1"; 98 | OTEL_ENDPOINT = cfg.otelEndpoint; 99 | }); 100 | 101 | serviceConfig = { 102 | ExecStart = "${pkgs.msgscript-server}/bin/msgscript -backend ${cfg.backend} -etcdurl ${lib.concatStringsSep "," cfg.etcdEndpoints} -natsurl ${cfg.natsUrl} -plugin ${pluginDir} -script ${cfg.scriptDir} -library ${cfg.libraryDir}"; 103 | 104 | User = cfg.user; 105 | Group = cfg.group; 106 | WorkingDirectory = cfg.dataDir; 107 | RuntimeDirectory = cfg.dataDir; 108 | Restart = "on-failure"; 109 | TimeoutSec = 15; 110 | 111 | # Security options: 112 | NoNewPrivileges = true; 113 | SystemCallArchitectures = "native"; 114 | RestrictAddressFamilies = [ 115 | "AF_INET" 116 | "AF_INET6" 117 | ]; 118 | RestrictNamespaces = !config.boot.isContainer; 119 | RestrictRealtime = true; 120 | RestrictSUIDSGID = true; 121 | ProtectControlGroups = !config.boot.isContainer; 122 | ProtectHostname = true; 123 | ProtectKernelLogs = !config.boot.isContainer; 124 | ProtectKernelModules = !config.boot.isContainer; 125 | ProtectKernelTunables = !config.boot.isContainer; 126 | LockPersonality = true; 127 | PrivateTmp = !config.boot.isContainer; 128 | PrivateDevices = true; 129 | PrivateUsers = true; 130 | RemoveIPC = true; 131 | 132 | SystemCallFilter = [ 133 | "~@clock" 134 | "~@aio" 135 | "~@chown" 136 | "~@cpu-emulation" 137 | "~@debug" 138 | "~@keyring" 139 | "~@memlock" 140 | "~@module" 141 | "~@mount" 142 | "~@obsolete" 143 | "~@privileged" 144 | "~@raw-io" 145 | "~@reboot" 146 | "~@setuid" 147 | "~@swap" 148 | ]; 149 | SystemCallErrorNumber = "EPERM"; 150 | }; 151 | 152 | wantedBy = [ "multi-user.target" ]; 153 | after = [ "networking.target" ]; 154 | }; 155 | 156 | users.users = mkIf (cfg.user == "msgscript") { 157 | msgscript = { 158 | inherit (cfg) group; 159 | isSystemUser = true; 160 | home = cfg.dataDir; 161 | }; 162 | }; 163 | 164 | users.groups = mkIf (cfg.group == "msgscript") { 165 | msgscript = { }; 166 | }; 167 | }; 168 | } 169 | -------------------------------------------------------------------------------- /plugins/vault/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/openbao/openbao/api" 10 | vaultapi "github.com/openbao/openbao/api" // Use truly opensource fork 11 | log "github.com/sirupsen/logrus" 12 | lua "github.com/yuin/gopher-lua" 13 | ) 14 | 15 | const DEFAULT_VAULT_TIMEOUT = 5 * time.Second 16 | 17 | var ALL_ENVS map[string]string 18 | 19 | func Preload(L *lua.LState, envs map[string]string) { 20 | ALL_ENVS = envs 21 | 22 | L.PreloadModule("vault", func(L *lua.LState) int { 23 | mod := L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 24 | "new": new, 25 | }) 26 | L.Push(mod) 27 | 28 | mt := L.NewTypeMetatable("VaultKV") 29 | L.SetGlobal("VaultKV", mt) 30 | L.SetField(mt, "new", L.NewFunction(new)) 31 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{ 32 | "read": read, 33 | "write": write, 34 | "delete": delete, 35 | "list": list, 36 | })) 37 | 38 | return 1 39 | }) 40 | } 41 | 42 | func new(L *lua.LState) int { 43 | address, token, fromEnv := L.CheckString(1), L.CheckString(2), L.OptBool(3, false) 44 | v, err := newVaultKVLuaClient(address, token, fromEnv) 45 | if err != nil { 46 | L.Push(lua.LNil) 47 | L.Push(lua.LString(fmt.Sprintf("failed to create new vault client: %s", err))) 48 | return 2 49 | } 50 | 51 | ud := L.NewUserData() 52 | ud.Value = v 53 | L.SetMetatable(ud, L.GetTypeMetatable("VaultKV")) 54 | 55 | L.Push(ud) 56 | L.Push(lua.LNil) 57 | return 2 58 | } 59 | 60 | type vaultKVLuaClient struct { 61 | client *vaultapi.Client 62 | } 63 | 64 | func newVaultKVLuaClient(address, token string, fromEnv bool) (*vaultKVLuaClient, error) { 65 | config := api.DefaultConfig() 66 | 67 | // Configure TLS to skip verification 68 | tlsConfig := &tls.Config{ 69 | InsecureSkipVerify: true, 70 | } 71 | 72 | // Set the TLS config on the HTTP client 73 | config.HttpClient.Transport = &http.Transport{ 74 | TLSClientConfig: tlsConfig, 75 | } 76 | 77 | client, err := vaultapi.NewClient(config) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to connect to vault: %w", err) 80 | } 81 | 82 | if fromEnv { 83 | client.SetAddress(ALL_ENVS[vaultapi.EnvVaultAddress]) 84 | client.SetToken(ALL_ENVS[vaultapi.EnvVaultToken]) 85 | } else { 86 | client.SetToken(token) 87 | client.SetAddress(address) 88 | } 89 | 90 | return &vaultKVLuaClient{client: client}, nil 91 | } 92 | 93 | func write(L *lua.LState) int { 94 | c := L.CheckUserData(1).Value.(*vaultKVLuaClient) 95 | path := L.CheckString(2) 96 | ldata := L.CheckTable(3) 97 | mountPath := L.CheckString(4) 98 | 99 | data := make(map[string]interface{}) 100 | ldata.ForEach(func(k, v lua.LValue) { 101 | data[k.String()] = v.String() 102 | 103 | if k.Type().String() != "string" { 104 | log.WithField("plugin", "vault").Errorf("unsupported type in read data: %w", k.Type().String()) 105 | } 106 | }) 107 | 108 | _, err := c.client.Logical().Write(fmt.Sprintf("%s/data%s", mountPath, path), map[string]interface{}{"data": data}) 109 | if err != nil { 110 | L.Push(lua.LString(fmt.Sprintf("failed to write to %s: %w", path, err))) 111 | return 1 112 | } 113 | 114 | L.Push(lua.LNil) 115 | return 1 116 | } 117 | 118 | func read(L *lua.LState) int { 119 | c := L.CheckUserData(1).Value.(*vaultKVLuaClient) 120 | path := L.CheckString(2) 121 | mountPath := L.CheckString(3) 122 | 123 | s, err := c.client.Logical().Read(fmt.Sprintf("%s/data%s", mountPath, path)) 124 | if err != nil { 125 | L.Push(lua.LNil) 126 | L.Push(lua.LString(fmt.Sprintf("failed to read path %s: %w", path, err))) 127 | return 2 128 | } 129 | 130 | if s == nil { 131 | L.Push(lua.LNil) 132 | L.Push(lua.LString(fmt.Sprintf("path %s not found", path))) 133 | return 2 134 | } 135 | 136 | t := L.NewTable() 137 | for k, v := range s.Data["data"].(map[string]any) { 138 | t.RawSetString(k, lua.LString(v.(string))) 139 | } 140 | 141 | L.Push(t) 142 | L.Push(lua.LNil) 143 | return 2 144 | } 145 | 146 | func delete(L *lua.LState) int { 147 | c := L.CheckUserData(1).Value.(*vaultKVLuaClient) 148 | path := L.CheckString(2) 149 | mountPath := L.CheckString(3) 150 | 151 | _, err := c.client.Logical().Delete(fmt.Sprintf("%s/metadata%s", mountPath, path)) 152 | if err != nil { 153 | L.Push(lua.LString(fmt.Sprintf("failed to delete %s: %s", path, err))) 154 | return 1 155 | } 156 | 157 | L.Push(lua.LNil) 158 | return 1 159 | } 160 | 161 | func list(L *lua.LState) int { 162 | c := L.CheckUserData(1).Value.(*vaultKVLuaClient) 163 | path := L.CheckString(2) 164 | mountPath := L.CheckString(3) 165 | 166 | s, err := c.client.Logical().List(fmt.Sprintf("%s/metadata%s", mountPath, path)) 167 | if err != nil { 168 | L.Push(lua.LNil) 169 | L.Push(lua.LString(fmt.Sprintf("failed to list %s: %s", path, err))) 170 | return 2 171 | } 172 | 173 | l := L.NewTable() 174 | for _, k := range s.Data["keys"].([]any) { 175 | l.Append(lua.LString(k.(string))) 176 | } 177 | 178 | L.Push(l) 179 | L.Push(lua.LNil) 180 | return 2 181 | } 182 | -------------------------------------------------------------------------------- /script/script.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | HEADER_PATTERN = "--*" 16 | LIBRARY_FOLDER_NAME = "libs" 17 | ) 18 | 19 | type Script struct { 20 | Content []byte `json:"content"` 21 | Executor string `json:"executor"` 22 | HTML bool `json:"is_html"` 23 | LibKeys []string `json:"libraries"` 24 | Name string `json:"name"` 25 | Subject string `json:"subject"` 26 | } 27 | 28 | func ReadFile(filename string) (*Script, error) { 29 | f, err := os.Open(filename) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to open file %s: %w", filename, err) 32 | } 33 | 34 | s := new(Script) 35 | err = s.Read(f) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to read file %s: %w", filename, err) 38 | } 39 | 40 | return s, nil 41 | } 42 | 43 | func ReadString(content string) (*Script, error) { 44 | r := strings.NewReader(content) 45 | s := new(Script) 46 | 47 | err := s.Read(r) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to read string content: %w", err) 50 | } 51 | 52 | return s, nil 53 | } 54 | 55 | func getHeaderKey(line string) string { 56 | if strings.HasPrefix(line, HEADER_PATTERN) { 57 | ss := strings.Split(line, " ") 58 | if len(ss) >= 2 { 59 | return strings.TrimSuffix(ss[1], ":") 60 | } 61 | } 62 | 63 | return "" 64 | } 65 | 66 | func getHeaderValue(line string) string { 67 | ss := strings.Split(line, " ") 68 | if len(ss) >= 3 { 69 | return strings.Join(ss[2:], " ") 70 | } 71 | 72 | return "" 73 | } 74 | 75 | func (s *Script) Read(f io.Reader) error { 76 | scanner := bufio.NewScanner(f) 77 | var err error 78 | var b strings.Builder 79 | for scanner.Scan() { 80 | line := scanner.Text() 81 | 82 | k := getHeaderKey(line) 83 | v := getHeaderValue(line) 84 | switch k { 85 | case "subject": 86 | s.Subject = v 87 | case "name": 88 | s.Name = v 89 | case "require": 90 | s.LibKeys = append(s.LibKeys, v) 91 | case "html": 92 | s.HTML, err = strconv.ParseBool(v) 93 | if err != nil { 94 | s.HTML = false 95 | } 96 | case "executor": 97 | s.Executor = v 98 | default: 99 | _, err := b.WriteString(line + "\n") 100 | if err != nil { 101 | return fmt.Errorf("failed to write to builder: %w", err) 102 | } 103 | } 104 | } 105 | 106 | s.Content = []byte(strings.TrimSuffix(b.String(), "\n")) 107 | 108 | return nil 109 | } 110 | 111 | func ReadScriptDirectory(dirname string, recurse bool) (map[string]map[string]*Script, error) { 112 | scripts := make(map[string]map[string]*Script) 113 | if recurse { 114 | fsys := os.DirFS(dirname) 115 | err := fs.WalkDir(fsys, ".", func(filename string, d os.DirEntry, err error) error { 116 | if err != nil { 117 | return err 118 | } 119 | 120 | if path.Ext(filename) == ".lua" { 121 | fullname := path.Join(dirname, filename) 122 | 123 | // if the script is contained into a folder for libraries, ignore it 124 | // TODO: might have to revise this 125 | if strings.Contains(filename, fmt.Sprintf("%s/", LIBRARY_FOLDER_NAME)) { 126 | return nil 127 | } 128 | 129 | s, err := ReadFile(fullname) 130 | if err != nil { 131 | return fmt.Errorf("failed to read script %s: %w", fullname, err) 132 | } 133 | 134 | if s.Subject == "" { 135 | return fmt.Errorf("script required to have a 'subject' header") 136 | } 137 | if s.Name == "" { 138 | return fmt.Errorf("script required to have a 'name' header") 139 | } 140 | 141 | // Add the script to the map 142 | if _, e := scripts[s.Subject]; !e { 143 | scripts[s.Subject] = make(map[string]*Script) 144 | } 145 | 146 | scripts[s.Subject][s.Name] = s 147 | } 148 | 149 | return nil 150 | }) 151 | if err != nil { 152 | return nil, fmt.Errorf("failed to walk directory %s: %w", dirname, err) 153 | } 154 | } else { 155 | entries, err := os.ReadDir(dirname) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to read directory: %w", err) 158 | } 159 | 160 | for _, e := range entries { 161 | if path.Ext(e.Name()) == ".lua" { 162 | fullname := path.Join(dirname, e.Name()) 163 | 164 | s, err := ReadFile(fullname) 165 | if err != nil { 166 | return nil, fmt.Errorf("failed to read script %s: %w", fullname, err) 167 | } 168 | 169 | // Add the script to the map 170 | if _, e := scripts[s.Subject]; !e { 171 | scripts[s.Subject] = make(map[string]*Script) 172 | } 173 | 174 | scripts[s.Subject][s.Name] = s 175 | } 176 | } 177 | } 178 | 179 | return scripts, nil 180 | } 181 | 182 | func ReadLibraryDirectory(dirname string) ([]*Script, error) { 183 | var libraries []*Script 184 | entries, err := os.ReadDir(dirname) 185 | if err != nil { 186 | return nil, fmt.Errorf("failed to read directory: %w", err) 187 | } 188 | 189 | for _, e := range entries { 190 | if path.Ext(e.Name()) == ".lua" { 191 | fullname := path.Join(dirname, e.Name()) 192 | 193 | s, err := ReadFile(fullname) 194 | if err != nil { 195 | return nil, fmt.Errorf("failed to read script %s: %w", fullname, err) 196 | } 197 | 198 | // Add the script to the map 199 | libraries = append(libraries, s) 200 | } 201 | } 202 | 203 | return libraries, nil 204 | } 205 | -------------------------------------------------------------------------------- /cmd/cli/cmd_devhttp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "path/filepath" 11 | "strings" 12 | "syscall" 13 | 14 | log "github.com/sirupsen/logrus" 15 | "github.com/spf13/cobra" 16 | 17 | "github.com/numkem/msgscript/executor" 18 | msgplugin "github.com/numkem/msgscript/plugins" 19 | "github.com/numkem/msgscript/script" 20 | scriptLib "github.com/numkem/msgscript/script" 21 | "github.com/numkem/msgscript/store" 22 | ) 23 | 24 | const DEVHTTP_SERVER_PORT = 7634 25 | 26 | var devHttpCmd = &cobra.Command{ 27 | Use: "devhttp", 28 | Args: validateArgIsPath, 29 | Short: "Starts a webserver that will run only to receive request from this script", 30 | Run: devHttpCmdRun, 31 | } 32 | 33 | func init() { 34 | rootCmd.AddCommand(devHttpCmd) 35 | 36 | devHttpCmd.PersistentFlags().StringP("library", "l", "", "Path to a folder containing libraries to load for the function") 37 | devHttpCmd.PersistentFlags().StringP("pluginDir", "p", "", "Path to a folder with plugins") 38 | } 39 | 40 | func devHttpCmdRun(cmd *cobra.Command, args []string) { 41 | store, err := store.NewDevStore(cmd.Flag("library").Value.String()) 42 | if err != nil { 43 | cmd.PrintErrf("failed to create store: %v\n", err) 44 | return 45 | } 46 | 47 | var plugins []msgplugin.PreloadFunc 48 | if path := cmd.Flag("pluginDir").Value.String(); path != "" { 49 | plugins, err = msgplugin.ReadPluginDir(path) 50 | if err != nil { 51 | cmd.PrintErrf("failed to read plugins: %v\n", err) 52 | return 53 | } 54 | } 55 | 56 | executors := executor.StartAllExecutors(cmd.Context(), store, plugins, nil) 57 | exec, err := executor.ExecutorByName(cmd.Flag("executor").Value.String(), executors) 58 | if err != nil { 59 | cmd.PrintErrf("failed to get executor for message: %v", err) 60 | return 61 | } 62 | 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | defer cancel() 65 | 66 | fullpath, err := filepath.Abs(args[0]) 67 | if err != nil { 68 | cmd.PrintErrf("failed to get absolute path for file %s: %v", args[0], err) 69 | return 70 | } 71 | 72 | fullLibraryDir, err := filepath.Abs(cmd.Flag("library").Value.String()) 73 | if err != nil { 74 | cmd.PrintErrf("failed to get absolute path for library folder: %v", err) 75 | return 76 | } 77 | 78 | go func() { 79 | proxy := &devHttpProxy{ 80 | store: store, 81 | executor: exec, 82 | context: ctx, 83 | scriptFile: fullpath, 84 | libraryDir: fullLibraryDir, 85 | } 86 | 87 | log.Infof("Starting HTTP server on port %d", DEVHTTP_SERVER_PORT) 88 | cmd.PrintErrln(http.ListenAndServe(fmt.Sprintf(":%d", DEVHTTP_SERVER_PORT), proxy)) 89 | }() 90 | 91 | sigChan := make(chan os.Signal, 1) 92 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 93 | <-sigChan 94 | cancel() 95 | 96 | cmd.Println("Received shutdown signal, stopping server...") 97 | exec.Stop() 98 | } 99 | 100 | type devHttpProxy struct { 101 | store store.ScriptStore 102 | executor executor.Executor 103 | context context.Context 104 | scriptFile string 105 | libraryDir string 106 | } 107 | 108 | func (p *devHttpProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 109 | // URL should look like /funcs.foobar 110 | // Where funcs.foobar is the subject for NATS 111 | ss := strings.Split(r.URL.Path, "/") 112 | // Validate URL structure 113 | if len(ss) < 2 { 114 | w.WriteHeader(http.StatusBadRequest) 115 | w.Write([]byte("URL should be in the pattern of /")) 116 | return 117 | } 118 | subject := ss[1] 119 | 120 | fields := log.Fields{ 121 | "subject": subject, 122 | "client": r.RemoteAddr, 123 | "method": r.Method, 124 | } 125 | log.WithFields(fields).Info("Received HTTP request") 126 | 127 | payload, err := io.ReadAll(r.Body) 128 | if err != nil { 129 | w.WriteHeader(http.StatusInternalServerError) 130 | fmt.Fprintf(w, "failed to read request body: %v", err) 131 | return 132 | } 133 | 134 | // Load script from disk 135 | s, err := scriptLib.ReadFile(p.scriptFile) 136 | if err != nil { 137 | log.WithField("filename", p.scriptFile).Errorf("failed to read file: %v", err) 138 | return 139 | } 140 | 141 | // TODO: load/delete libraries 142 | libs, err := parseDirsForLibraries([]string{p.libraryDir}, true) 143 | if err != nil { 144 | e := fmt.Errorf("failed to read librairies: %w", err) 145 | log.Error(e.Error()) 146 | w.WriteHeader(http.StatusInternalServerError) 147 | w.Write([]byte(e.Error())) 148 | return 149 | } 150 | for _, lib := range libs { 151 | p.store.AddLibrary(r.Context(), lib.Content, lib.Name) 152 | } 153 | 154 | // Add only the currently worked on file 155 | scr, err := script.ReadFile(p.scriptFile) 156 | if err != nil { 157 | e := fmt.Errorf("failed to read script file %s: %w", p.scriptFile, err) 158 | log.Error(e.Error()) 159 | w.WriteHeader(http.StatusInternalServerError) 160 | w.Write([]byte(e.Error())) 161 | return 162 | } 163 | 164 | p.store.AddScript(p.context, s.Subject, s.Name, scr) 165 | 166 | // Create a new empty store at the end of each request 167 | defer emptyStore(p.store, p.libraryDir) 168 | 169 | url := strings.ReplaceAll(r.URL.String(), "/"+subject, "") 170 | if url == "" { 171 | url = "/" 172 | } 173 | log.Infof("URL: %s", url) 174 | 175 | msg := &executor.Message{ 176 | Payload: payload, 177 | Method: r.Method, 178 | Subject: subject, 179 | URL: url, 180 | } 181 | 182 | res := p.executor.HandleMessage(p.context, msg, scr) 183 | if res.Error != "" { 184 | if res.Error == (&executor.NoScriptFoundError{}).Error() { 185 | w.WriteHeader(http.StatusNotFound) 186 | } else { 187 | w.WriteHeader(http.StatusInternalServerError) 188 | } 189 | 190 | _, err = w.Write([]byte("Error: " + res.Error)) 191 | if err != nil { 192 | log.WithFields(fields).Errorf("failed to write error to HTTP response: %v", err) 193 | } 194 | 195 | return 196 | } 197 | 198 | if res.IsHTML { 199 | var hasContentType bool 200 | for k, v := range res.Headers { 201 | if k == "Content-Type" { 202 | hasContentType = true 203 | } 204 | w.Header().Add(k, v) 205 | } 206 | if !hasContentType { 207 | w.Header().Add("Content-Type", "text/html") 208 | } 209 | w.WriteHeader(res.Code) 210 | 211 | _, err = w.Write(res.Payload) 212 | if err != nil { 213 | log.WithFields(fields).Errorf("failed to write reply back to HTTP response: %v", err) 214 | } 215 | 216 | return 217 | } 218 | 219 | // Return the content of the script as if the browser was a console 220 | w.Header().Add("Content-Type", "text/plain") 221 | 222 | // Only print error if there is one 223 | if res.Error != "" { 224 | w.WriteHeader(http.StatusInternalServerError) 225 | w.Write([]byte(res.Error)) 226 | return 227 | } 228 | 229 | w.WriteHeader(http.StatusOK) 230 | w.Write(res.Payload) 231 | } 232 | 233 | func emptyStore(s store.ScriptStore, libraryDir string) { 234 | s, _ = store.NewDevStore(libraryDir) 235 | } 236 | -------------------------------------------------------------------------------- /examples/libs/web.lua: -------------------------------------------------------------------------------- 1 | --* name: web 2 | local template = require("template") 3 | local mustache, _ = template.choose("mustache") 4 | local json = require("json") 5 | local strings = require("strings") 6 | 7 | Router = {} 8 | Router.__index = Router 9 | 10 | -- Methods to add a handler to a route 11 | function Router:get(url, handler) 12 | self.routes["GET"][url] = handler 13 | end 14 | 15 | function Router:post(url, handler) 16 | self.routes["POST"][url] = handler 17 | end 18 | 19 | function Router:put(url, handler) 20 | self.routes["PUT"][url] = handler 21 | end 22 | 23 | function Router:patch(url, handler) 24 | self.routes["PATCH"][url] = handler 25 | end 26 | 27 | function Router:head(url, handler) 28 | self.routes["HEAD"][url] = handler 29 | end 30 | 31 | function Router:options(url, handler) 32 | self.routes["OPTIONS"][url] = handler 33 | end 34 | 35 | function Router:delete(url, handler) 36 | self.routes["DELETE"][url] = handler 37 | end 38 | 39 | local LIBWEB_HTTP_VERBS = { 40 | "GET", "POST", "PUT", "PATCH", "HEAD", "OPTIONS", "DELETE" 41 | } 42 | 43 | function Router.new(layout) 44 | if layout == nil or layout == "" then 45 | layout = [[ 46 | 47 |
48 | {{& header }} 49 |
50 | 51 | {{& body }} 52 | {{& footer }} 53 | 54 | 55 | ]] 56 | end 57 | 58 | local router = setmetatable({ 59 | layout = layout, 60 | routes = {}, 61 | }, Router) 62 | for _, m in pairs(LIBWEB_HTTP_VERBS) do 63 | router.routes[m] = {} 64 | end 65 | 66 | -- Setup handlers for each of the HTTP methods 67 | for _, m in pairs(LIBWEB_HTTP_VERBS) do 68 | _G[m] = function (url, body) 69 | return router:handle(m, url, body) 70 | end 71 | end 72 | 73 | return router 74 | end 75 | 76 | function Router:handle(method, url, payload) 77 | -- extract the path and query from the url 78 | local path, query = self:parseRequest(method, url) 79 | 80 | -- Parse the payload for key/value from a form 81 | local formkv = {} 82 | for _, field in pairs(strings.split(payload, "&")) do 83 | local kv = strings.split(field, "=") 84 | if (#kv == 2) then 85 | formkv[kv[1]] = kv[2] 86 | end 87 | end 88 | 89 | -- First, check for exact route match 90 | if self.routes[method][path] then 91 | local tmpl_ret, data, code, opts = self.routes[method][path](Request.new(query, nil, formkv), payload) 92 | return self:processResponse(tmpl_ret, data, code, opts) 93 | end 94 | 95 | -- If no exact match, check for pattern routes 96 | for pattern, handler in pairs(self.routes[method]) do 97 | local params = self:matchURLPattern(pattern, path) 98 | if next(params) ~= nil then 99 | -- Parse queries 100 | local queries = {} 101 | for k, v in pairs(query or {}) do 102 | queries[k] = v 103 | end 104 | 105 | -- Parse the paths from the URLs 106 | local paths = {} 107 | for k, v in pairs(params) do 108 | paths[k] = v 109 | end 110 | 111 | -- Execute the handler 112 | local tmpl_ret, data, code, opts = handler(Request.new(queries, paths, formkv), payload) 113 | return self:processResponse(tmpl_ret, data, code, opts) 114 | end 115 | end 116 | 117 | -- No route found 118 | return string.format("URL %s doesn't exist", url), 404, {} 119 | end 120 | 121 | function Router:processResponse(tmpl_ret, data, code, opts) 122 | if data == nil then data = {} end 123 | if code == nil then code = 200 end 124 | if opts == nil then opts = { headers = {}, no_template = false } end 125 | 126 | -- Process redirects 127 | if opts.redirect then 128 | return "", 302, { Location = opts.redirect } 129 | end 130 | 131 | -- If we have no template ("" or nil), we use the data as a JSON response 132 | if tmpl_ret == "" or tmpl_ret == nil then 133 | return json.encode(data), code, { ["Content-Type"] = "application/json" } 134 | end 135 | 136 | local handler_body = mustache:render(tmpl_ret, data) 137 | 138 | if not opts.no_template then 139 | return mustache:render(self.layout, { header = opts.header, body = handler_body, footer = opts.footer }), code, opts.headers 140 | else 141 | return handler_body, code, opts.headers 142 | end 143 | end 144 | 145 | function Router:matchURLPattern(pattern, url) 146 | local result = {} 147 | 148 | local patternParts = {} 149 | for part in pattern:gmatch("[^/]+") do 150 | table.insert(patternParts, part) 151 | end 152 | 153 | local urlParts = {} 154 | for part in url:gmatch("[^/]+") do 155 | table.insert(urlParts, part) 156 | end 157 | 158 | if #patternParts > #urlParts then 159 | return result 160 | end 161 | 162 | for i, patternPart in ipairs(patternParts) do 163 | if i > #urlParts then 164 | break 165 | end 166 | 167 | local urlPart = urlParts[i] 168 | 169 | if patternPart:match("^<.*>$") then 170 | local key = patternPart:sub(2, -2) 171 | result[key] = urlPart 172 | elseif patternPart ~= urlPart and not patternPart:match("^<.*>$") then 173 | return {} 174 | end 175 | end 176 | 177 | return result 178 | end 179 | 180 | function Router:parseRequest(method, url) 181 | local queryStr = "" 182 | local path = "" 183 | if method == "GET" then 184 | local ss = url:split("?") 185 | if #ss == 1 then 186 | if ss[1] == "" then 187 | table.insert(ss, 1, "/") 188 | end 189 | 190 | table.insert(ss, 2, "") 191 | end 192 | 193 | path = ss[1] 194 | queryStr = ss[2] 195 | else 196 | path = url 197 | end 198 | 199 | return path, self:parseQuery(queryStr) 200 | end 201 | 202 | function Router:parseQuery(queryStr) 203 | if string.find(queryStr, "&") ~= 0 then 204 | local queries = {} 205 | 206 | for _, q in ipairs(queryStr:split("&")) do 207 | local eq = q:split("=") 208 | queries[eq[1]] = eq[2] 209 | end 210 | 211 | return queries 212 | else 213 | return {} 214 | end 215 | end 216 | 217 | -- Returns a table splitting some string with a delimiter 218 | function string:split(delimiter) 219 | local result = {} 220 | local from = 1 221 | local delim_from, delim_to = string.find(self, delimiter, from, true) 222 | while delim_from do 223 | if (delim_from ~= 1) then 224 | table.insert(result, string.sub(self, from, delim_from - 1)) 225 | end 226 | from = delim_to + 1 227 | delim_from, delim_to = string.find(self, delimiter, from, true) 228 | end 229 | if (from <= #self) then table.insert(result, string.sub(self, from)) end 230 | return result 231 | end 232 | 233 | -- 234 | -- Request: Passed to a handler as first parameter 235 | -- 236 | Request = {} 237 | Request.__index = Request 238 | 239 | function Request.new(queries, paths, formkv) 240 | return setmetatable({ queries = queries, paths = paths, form = formkv }, Request) 241 | end 242 | 243 | function Request:query(name) 244 | if name == nil then 245 | return self.queries 246 | else 247 | return self.queries[name] 248 | end 249 | end 250 | 251 | function Request:path(name) 252 | if name == nil then 253 | return self.paths 254 | else 255 | return self.paths[name] 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /cmd/server/http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/nats-io/nats.go" 12 | log "github.com/sirupsen/logrus" 13 | "golang.org/x/net/context" 14 | 15 | "go.opentelemetry.io/otel" 16 | "go.opentelemetry.io/otel/attribute" 17 | "go.opentelemetry.io/otel/codes" 18 | "go.opentelemetry.io/otel/propagation" 19 | "go.opentelemetry.io/otel/trace" 20 | 21 | "github.com/numkem/msgscript" 22 | "github.com/numkem/msgscript/executor" 23 | ) 24 | 25 | const DEFAULT_HTTP_PORT = 7643 26 | const DEFAULT_HTTP_TIMEOUT = 5 * time.Second 27 | 28 | var tracer = otel.Tracer("http-nats-proxy") 29 | 30 | type httpNatsProxy struct { 31 | port string 32 | nc *nats.Conn 33 | } 34 | 35 | func NewHttpNatsProxy(port int, natsURL string) (*httpNatsProxy, error) { 36 | // Connect to NATS 37 | if natsURL == "" { 38 | natsURL = msgscript.NatsUrlByEnv() 39 | } 40 | nc, err := nats.Connect(natsURL) 41 | if err != nil { 42 | return nil, fmt.Errorf("Failed to connect to NATS: %w", err) 43 | } 44 | 45 | return &httpNatsProxy{ 46 | nc: nc, 47 | }, nil 48 | } 49 | 50 | func (p *httpNatsProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 51 | // Extract context from incoming request headers 52 | ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header)) 53 | 54 | // Start a new span for the HTTP request 55 | ctx, span := tracer.Start(ctx, "http.request", 56 | trace.WithSpanKind(trace.SpanKindServer), 57 | trace.WithAttributes( 58 | attribute.String("http.method", r.Method), 59 | attribute.String("http.url", r.URL.String()), 60 | attribute.String("http.remote_addr", r.RemoteAddr), 61 | ), 62 | ) 63 | defer span.End() 64 | 65 | defer r.Body.Close() 66 | 67 | // URL should look like /funcs.foobar 68 | // Where funcs.foobar is the subject for NATS 69 | ss := strings.Split(r.URL.Path, "/") 70 | // Validate URL structure 71 | if len(ss) < 2 { 72 | span.SetStatus(codes.Error, "Invalid URL structure") 73 | span.SetAttributes(attribute.Int("http.status_code", http.StatusBadRequest)) 74 | w.WriteHeader(http.StatusBadRequest) 75 | w.Write([]byte("URL should be in the pattern of /")) 76 | return 77 | } 78 | subject := ss[1] 79 | span.SetAttributes(attribute.String("nats.subject", subject)) 80 | 81 | fields := log.Fields{ 82 | "subject": subject, 83 | "client": r.RemoteAddr, 84 | } 85 | log.WithFields(fields).Info("Received HTTP request") 86 | 87 | // Read request body with tracing 88 | payload, err := io.ReadAll(r.Body) 89 | if err != nil { 90 | span.RecordError(err) 91 | span.SetStatus(codes.Error, "Failed to read request body") 92 | span.SetAttributes(attribute.Int("http.status_code", http.StatusInternalServerError)) 93 | w.WriteHeader(http.StatusInternalServerError) 94 | _, err = fmt.Fprintf(w, "failed to read request body: %s", err) 95 | if err != nil { 96 | log.WithFields(fields).Errorf("failed to write payload: %v", err) 97 | } 98 | return 99 | } 100 | span.SetAttributes(attribute.Int("http.request.body_size", len(payload))) 101 | 102 | // We can override the HTTP timeout by passing the `_timeout` query string 103 | timeout := DEFAULT_HTTP_TIMEOUT 104 | if r.URL.Query().Has("_timeout") { 105 | timeout, err = time.ParseDuration(r.URL.Query().Get("_timeout")) 106 | if err != nil { 107 | timeout = DEFAULT_HTTP_TIMEOUT 108 | } 109 | } 110 | span.SetAttributes(attribute.String("http.timeout", timeout.String())) 111 | 112 | ctx, cancel := context.WithTimeout(ctx, timeout) 113 | defer cancel() 114 | 115 | // Change the url passed to the fuction to remove the subject 116 | url := strings.ReplaceAll(r.URL.String(), "/"+subject, "") 117 | log.Debugf("URL: %s", url) 118 | body, err := json.Marshal(&executor.Message{ 119 | Payload: payload, 120 | Method: r.Method, 121 | Subject: subject, 122 | URL: url, 123 | }) 124 | if err != nil { 125 | span.RecordError(err) 126 | span.SetStatus(codes.Error, "Failed to encode message") 127 | log.WithFields(fields).Errorf("failed to encode message: %v", err) 128 | return 129 | } 130 | 131 | // Start a child span for the NATS request 132 | ctx, natsSpan := tracer.Start(ctx, "nats.request", 133 | trace.WithSpanKind(trace.SpanKindClient), 134 | trace.WithAttributes( 135 | attribute.String("nats.subject", subject), 136 | attribute.Int("nats.message_size", len(body)), 137 | ), 138 | ) 139 | 140 | // Inject trace context into NATS message headers 141 | msg := nats.NewMsg(subject) 142 | msg.Data = body 143 | otel.GetTextMapPropagator().Inject(ctx, natsHeaderCarrier(msg.Header)) 144 | 145 | // Send the message and wait for the response 146 | response, err := p.nc.RequestMsgWithContext(ctx, msg) 147 | if err != nil { 148 | natsSpan.RecordError(err) 149 | natsSpan.SetStatus(codes.Error, "NATS request failed") 150 | natsSpan.End() 151 | 152 | span.SetStatus(codes.Error, "Service unavailable") 153 | span.SetAttributes(attribute.Int("http.status_code", http.StatusServiceUnavailable)) 154 | 155 | w.WriteHeader(http.StatusServiceUnavailable) 156 | w.Write([]byte(err.Error())) 157 | return 158 | } 159 | natsSpan.SetAttributes(attribute.Int("nats.response_size", len(msg.Data))) 160 | natsSpan.SetStatus(codes.Ok, "") 161 | natsSpan.End() 162 | 163 | rep := new(Reply) 164 | err = json.Unmarshal(response.Data, rep) 165 | if err != nil { 166 | span.RecordError(err) 167 | span.SetStatus(codes.Error, "Failed to unmarshal response") 168 | span.SetAttributes(attribute.Int("http.status_code", http.StatusFailedDependency)) 169 | w.WriteHeader(http.StatusFailedDependency) 170 | fmt.Fprintf(w, "Error: %v", err) 171 | return 172 | } 173 | 174 | if rep.Error != "" { 175 | span.SetAttributes(attribute.String("executor.error", rep.Error)) 176 | if rep.Error == (&executor.NoScriptFoundError{}).Error() { 177 | span.SetStatus(codes.Error, "Script not found") 178 | span.SetAttributes(attribute.Int("http.status_code", http.StatusNotFound)) 179 | w.WriteHeader(http.StatusNotFound) 180 | } else { 181 | span.SetStatus(codes.Error, rep.Error) 182 | span.SetAttributes(attribute.Int("http.status_code", http.StatusInternalServerError)) 183 | w.WriteHeader(http.StatusInternalServerError) 184 | } 185 | 186 | _, err = w.Write([]byte("Error: " + rep.Error)) 187 | if err != nil { 188 | log.WithFields(fields).Errorf("failed to write error to HTTP response: %v", err) 189 | } 190 | 191 | return 192 | } 193 | 194 | // Go through all the scripts to see if one is HTML 195 | for _, scrRes := range rep.Results { 196 | if scrRes.IsHTML { 197 | 198 | span.SetAttributes(attribute.Bool("response.is_html", true)) 199 | var hasContentType bool 200 | for k, v := range scrRes.Headers { 201 | if k == "Content-Type" { 202 | hasContentType = true 203 | } 204 | w.Header().Add(k, v) 205 | } 206 | if !hasContentType { 207 | w.Header().Add("Content-Type", "text/html") 208 | } 209 | span.SetAttributes( 210 | attribute.Int("http.status_code", scrRes.Code), 211 | attribute.Int("http.response.body_size", len(scrRes.Payload)), 212 | ) 213 | w.WriteHeader(scrRes.Code) 214 | 215 | _, err = w.Write(scrRes.Payload) 216 | if err != nil { 217 | span.RecordError(err) 218 | log.WithFields(fields).Errorf("failed to write reply back to HTTP response: %v", err) 219 | } 220 | 221 | span.SetStatus(codes.Ok, "") 222 | // Since only the HTML page reply can "win" we ignore the rest 223 | return 224 | } 225 | } 226 | 227 | // Convert the results to bytes 228 | span.SetAttributes(attribute.Bool("response.is_html", false)) 229 | rr, err := json.Marshal(rep.Results) 230 | if err != nil { 231 | span.RecordError(err) 232 | log.WithFields(fields).Errorf("failed to serialize all results to JSON: %v", err) 233 | } 234 | 235 | span.SetAttributes( 236 | attribute.Int("http.status_code", http.StatusOK), 237 | attribute.Int("http.response.body_size", len(rr)), 238 | ) 239 | 240 | _, err = w.Write(rr) 241 | if err != nil { 242 | span.RecordError(err) 243 | log.WithFields(fields).Errorf("failed to write reply back to HTTP response: %v", err) 244 | } 245 | 246 | span.SetStatus(codes.Ok, "") 247 | } 248 | 249 | func hasHTMLResult(results map[string]*executor.ScriptResult) (bool, *executor.ScriptResult) { 250 | for _, sr := range results { 251 | if sr.IsHTML { 252 | return true, sr 253 | } 254 | } 255 | 256 | return false, nil 257 | } 258 | 259 | func runHTTP(port int, natsURL string) { 260 | proxy, err := NewHttpNatsProxy(port, natsURL) 261 | if err != nil { 262 | log.Fatalf("failed to create HTTP proxy: %v", err) 263 | } 264 | 265 | log.Infof("Starting HTTP server on port %d", port) 266 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), proxy)) 267 | } 268 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | natsserver "github.com/nats-io/nats-server/v2/server" 15 | "github.com/nats-io/nats.go" 16 | log "github.com/sirupsen/logrus" 17 | "golang.org/x/net/context" 18 | 19 | "go.opentelemetry.io/otel" 20 | "go.opentelemetry.io/otel/attribute" 21 | "go.opentelemetry.io/otel/codes" 22 | "go.opentelemetry.io/otel/trace" 23 | 24 | "github.com/numkem/msgscript" 25 | "github.com/numkem/msgscript/executor" 26 | msgplugin "github.com/numkem/msgscript/plugins" 27 | "github.com/numkem/msgscript/script" 28 | msgstore "github.com/numkem/msgscript/store" 29 | ) 30 | 31 | var version = "dev" 32 | var mainTracer = otel.Tracer("msgscript.main") 33 | 34 | func main() { 35 | // Parse command-line flags 36 | backendName := flag.String("backend", msgstore.BACKEND_FILE_NAME, "Storage backend to use (etcd, sqlite, flatfile)") 37 | etcdURL := flag.String("etcdurl", "localhost:2379", "URL of etcd server") 38 | natsURL := flag.String("natsurl", "", "URL of NATS server") 39 | logLevel := flag.String("log", "info", "Logging level (debug, info, warn, error)") 40 | httpPort := flag.Int("port", DEFAULT_HTTP_PORT, "HTTP port to bind to") 41 | pluginDir := flag.String("plugin", "", "Plugin directory") 42 | libraryDir := flag.String("library", "", "Library directory") 43 | scriptDir := flag.String("script", ".", "Script directory") 44 | flag.Parse() 45 | 46 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 47 | defer stop() 48 | 49 | // Set up logging 50 | level, err := log.ParseLevel(*logLevel) 51 | if err != nil { 52 | log.Fatalf("Invalid log level: %v", err) 53 | } 54 | log.SetLevel(level) 55 | 56 | if os.Getenv("DEBUG") != "" { 57 | log.SetLevel(log.DebugLevel) 58 | } 59 | 60 | if os.Getenv("TELEMETRY_TRACES") != "" { 61 | log.WithField("kind", "traces").Info("Starting telemetry") 62 | 63 | // Init traces 64 | otelShutdown, err := setupOTelSDK(ctx) 65 | if err != nil { 66 | log.Errorf("failed to initialize opentelemetry traces: %v", err) 67 | os.Exit(1) 68 | } 69 | defer func() { 70 | err = errors.Join(err, otelShutdown(context.Background())) 71 | }() 72 | } 73 | 74 | // Create the ScriptStore based on the selected backend 75 | scriptStore, err := msgstore.StoreByName(*backendName, *etcdURL, *scriptDir, *libraryDir) 76 | if err != nil { 77 | log.Fatalf("failed to initialize the script store: %v", err) 78 | } 79 | log.Infof("Starting %s backend", *backendName) 80 | 81 | if *natsURL == "" { 82 | *natsURL = msgscript.NatsUrlByEnv() 83 | 84 | if *natsURL == "" { 85 | // nats isn't provided, we can start an embeded one 86 | log.Info("Starting embeded NATS server... on 127.0.0.1:4222") 87 | ns, err := natsserver.NewServer(&natsserver.Options{ 88 | Host: "127.0.0.1", 89 | Port: 4222, 90 | }) 91 | if err != nil { 92 | log.Fatalf("failed to start embeded NATS server: %v", err) 93 | } 94 | 95 | go ns.Start() 96 | *natsURL = ns.ClientURL() 97 | 98 | for { 99 | if ns.ReadyForConnections(1 * time.Second) { 100 | log.Info("NATS server started") 101 | break 102 | } 103 | 104 | log.Info("Waiting for embeded NATS server to start...") 105 | time.Sleep(1 * time.Second) 106 | } 107 | } 108 | } 109 | 110 | nc, err := nats.Connect(*natsURL) 111 | if err != nil { 112 | log.Fatalf("Failed to connect to NATS: %v", err) 113 | } 114 | defer nc.Close() 115 | 116 | // Initialize ScriptExecutor 117 | var plugins []msgplugin.PreloadFunc 118 | if *pluginDir != "" { 119 | plugins, err = msgplugin.ReadPluginDir(*pluginDir) 120 | if err != nil { 121 | log.Fatalf("failed to read plugins: %v", err) 122 | } 123 | } 124 | 125 | ctx, cancel := context.WithCancel(context.Background()) 126 | defer cancel() 127 | 128 | executors := executor.StartAllExecutors(ctx, scriptStore, plugins, nc) 129 | 130 | log.Info("Starting message watch...") 131 | 132 | // Set up a message handler 133 | _, err = nc.Subscribe(">", func(msg *nats.Msg) { 134 | // Extract trace context from NATS message headers 135 | ctx := otel.GetTextMapPropagator().Extract( 136 | context.Background(), 137 | natsHeaderCarrier(msg.Header), 138 | ) 139 | 140 | // Start a span for the NATS message handling 141 | ctx, span := mainTracer.Start(ctx, "nats.handle_message", 142 | trace.WithSpanKind(trace.SpanKindServer), 143 | trace.WithAttributes( 144 | attribute.String("nats.subject", msg.Subject), 145 | attribute.Int("nats.message_size", len(msg.Data)), 146 | ), 147 | ) 148 | defer span.End() 149 | 150 | log.Debugf("Received message on subject: %s", msg.Subject) 151 | 152 | if strings.HasPrefix(msg.Subject, "_INBOX.") { 153 | span.SetAttributes(attribute.Bool("nats.is_inbox", true)) 154 | span.SetStatus(codes.Ok, "Ignored inbox subject") 155 | log.Debugf("Ignoring reply subject %s", msg.Subject) 156 | return 157 | } 158 | 159 | m := new(executor.Message) 160 | err := json.Unmarshal(msg.Data, m) 161 | // if the payload isn't a JSON Message, take it as a whole 162 | if err != nil { 163 | m.Subject = msg.Subject 164 | m.Payload = msg.Data 165 | m.Raw = true 166 | } 167 | 168 | fields := log.Fields{ 169 | "subject": m.Subject, 170 | "raw": m.Raw, 171 | "async": m.Async, 172 | } 173 | 174 | span.SetAttributes( 175 | attribute.Bool("message.raw", m.Raw), 176 | attribute.Bool("message.async", m.Async), 177 | ) 178 | 179 | // The above unmarshalling only applies to the structure of the JSON. 180 | // Even if you feed it another JSON where none of the keys matches, 181 | // it will just end up being an empty struct 182 | if m.Payload == nil { 183 | m = &executor.Message{ 184 | Subject: msg.Subject, 185 | Payload: msg.Data, 186 | } 187 | } 188 | 189 | if m.Async { 190 | span.SetAttributes(attribute.String("reply.mode", "async")) 191 | err = nc.Publish(msg.Reply, []byte("{}")) 192 | if err != nil { 193 | span.RecordError(err) 194 | span.SetStatus(codes.Error, "Failed to publish async reply") 195 | log.WithFields(fields).Errorf("failed to reply to message: %v", err) 196 | 197 | replyWithError(nc, fmt.Errorf("failed to reply to message: %v", err), msg.Reply) 198 | return 199 | } 200 | } else { 201 | span.SetAttributes(attribute.String("reply.mode", "sync")) 202 | } 203 | 204 | cctx, getScriptsSpan := mainTracer.Start(ctx, "nats.handle_message.get_scripts", trace.WithAttributes( 205 | attribute.String("script.Name", m.Subject), 206 | attribute.String("script.URL", m.URL), 207 | )) 208 | 209 | scripts, err := scriptStore.GetScripts(cctx, m.Subject) 210 | if err != nil { 211 | log.WithError(err).WithField("subject", m.Subject).Error("failed to get scripts for subject") 212 | span.RecordError(err) 213 | span.SetStatus(codes.Error, "Failed to get scripts") 214 | 215 | replyWithError(nc, fmt.Errorf("failed to get scripts for subject"), msg.Reply) 216 | return 217 | } 218 | getScriptsSpan.SetStatus(codes.Ok, fmt.Sprintf("found %d scripts", len(scripts))) 219 | getScriptsSpan.End() 220 | 221 | _, executeScriptsSpan := mainTracer.Start(ctx, "nats.handle_message.run_scripts") 222 | defer executeScriptsSpan.End() 223 | 224 | var wg sync.WaitGroup 225 | allResults := make(chan *executor.ScriptResult, len(scripts)) 226 | for _, scr := range scripts { 227 | wg.Add(1) 228 | 229 | go func(ctx context.Context, msg *executor.Message, script *script.Script) { 230 | defer wg.Done() 231 | 232 | // Pass the context with trace info to the executor 233 | exec, err := executor.ExecutorByName(scr.Executor, executors) 234 | if err != nil { 235 | executeScriptsSpan.RecordError(err) 236 | executeScriptsSpan.SetStatus(codes.Error, "Failed to get executor") 237 | log.WithError(err).Error("failed to get executor for script") 238 | 239 | allResults <- &executor.ScriptResult{Error: fmt.Sprintf("failed to get executor for script: %v", err)} 240 | return 241 | } 242 | 243 | allResults <- exec.HandleMessage(ctx, m, scr) 244 | }(ctx, m, scr) 245 | } 246 | wg.Wait() 247 | 248 | close(allResults) 249 | 250 | _, parseReplySpan := mainTracer.Start(ctx, "nats.handle_message.parse_replies") 251 | msgRep := new(Reply) 252 | for res := range allResults { 253 | if res.IsHTML { 254 | msgRep.HTML = true 255 | } 256 | 257 | msgRep.Results = append(msgRep.Results, res) 258 | } 259 | parseReplySpan.SetAttributes(attribute.Int("responses", len(msgRep.Results))) 260 | parseReplySpan.SetStatus(codes.Ok, "responses parsed") 261 | parseReplySpan.End() 262 | 263 | _, natsReplaySpan := mainTracer.Start(ctx, "nats.handle_message.send_reply") 264 | err = replyMessage(nc, m, msg.Reply, msgRep) 265 | if err != nil { 266 | natsReplaySpan.RecordError(err) 267 | natsReplaySpan.SetStatus(codes.Error, "Failed to send reply through NATS") 268 | 269 | log.WithError(err).Errorf("failed to send reply through NATS") 270 | return 271 | } 272 | 273 | log.WithField("subject", msg.Subject).Debugf("finished running %d scripts", len(scripts)) 274 | span.SetStatus(codes.Ok, "Message handled") 275 | }) 276 | if err != nil { 277 | log.Fatalf("Failed to subscribe to NATS subjects: %v", err) 278 | } 279 | 280 | defer func() { 281 | log.Info("Received shutdown signal, stopping server...") 282 | executor.StopAllExecutors(executors) 283 | }() 284 | 285 | // Start HTTP Server 286 | runHTTP(*httpPort, *natsURL) 287 | } 288 | -------------------------------------------------------------------------------- /executor/wasm_executor.go: -------------------------------------------------------------------------------- 1 | //go:build wasmtime 2 | 3 | package executor 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "os" 11 | "strings" 12 | 13 | "github.com/bytecodealliance/wasmtime-go/v37" 14 | "github.com/nats-io/nats.go" 15 | log "github.com/sirupsen/logrus" 16 | "go.opentelemetry.io/otel" 17 | "go.opentelemetry.io/otel/attribute" 18 | "go.opentelemetry.io/otel/codes" 19 | "go.opentelemetry.io/otel/trace" 20 | 21 | msgplugins "github.com/numkem/msgscript/plugins" 22 | "github.com/numkem/msgscript/script" 23 | msgstore "github.com/numkem/msgscript/store" 24 | ) 25 | 26 | var wasmTracer = otel.Tracer("msgscript.executor.wasm") 27 | 28 | type WasmExecutor struct { 29 | cancelFunc context.CancelFunc 30 | ctx context.Context 31 | store msgstore.ScriptStore 32 | } 33 | 34 | func NewWasmExecutor(c context.Context, store msgstore.ScriptStore, plugins []msgplugins.PreloadFunc, nc *nats.Conn) Executor { 35 | ctx, cancelFunc := context.WithCancel(c) 36 | 37 | log.Info("WASM executor initialized") 38 | 39 | return &WasmExecutor{ 40 | cancelFunc: cancelFunc, 41 | ctx: ctx, 42 | store: store, 43 | } 44 | } 45 | 46 | func (we *WasmExecutor) HandleMessage(ctx context.Context, msg *Message, scr *script.Script) *ScriptResult { 47 | ctx, span := wasmTracer.Start(ctx, "wasm.handle_message", trace.WithAttributes( 48 | attribute.String("subject", scr.Subject), 49 | attribute.String("script.name", scr.Name), 50 | attribute.String("method", msg.Method), 51 | attribute.Int("payload_size", len(msg.Payload)), 52 | )) 53 | defer span.End() 54 | 55 | res := new(ScriptResult) 56 | 57 | fields := log.Fields{ 58 | "subject": scr.Subject, 59 | "path": scr.Name, 60 | "executor": "wasm", 61 | } 62 | 63 | modulePath := strings.TrimSuffix(string(scr.Content), "\n") 64 | span.SetAttributes(attribute.String("wasm.module_path", modulePath)) 65 | 66 | // Script's content is the path to the wasm script 67 | _, readSpan := wasmTracer.Start(ctx, "wasm.read_module", trace.WithAttributes( 68 | attribute.String("module_path", modulePath), 69 | )) 70 | wasmBytes, err := os.ReadFile(modulePath) 71 | if err != nil { 72 | readSpan.RecordError(err) 73 | readSpan.SetStatus(codes.Error, "Failed to read WASM module") 74 | readSpan.End() 75 | span.RecordError(err) 76 | span.SetStatus(codes.Error, "Failed to read WASM module") 77 | res.Error = fmt.Sprintf("failed to read wasm module file %s: %w", scr.Content, err) 78 | return res 79 | } 80 | readSpan.SetAttributes(attribute.Int("wasm.module_size", len(wasmBytes))) 81 | readSpan.SetStatus(codes.Ok, "") 82 | readSpan.End() 83 | 84 | // Create temp files for stdout/stderr 85 | _, stdoutSpan := wasmTracer.Start(ctx, "wasm.create_stdout_file") 86 | stdoutFile, err := createTempFile("msgscript-wasm-stdout-*") 87 | if err != nil { 88 | stdoutSpan.RecordError(err) 89 | stdoutSpan.SetStatus(codes.Error, "Failed to create stdout file") 90 | stdoutSpan.End() 91 | span.RecordError(err) 92 | span.SetStatus(codes.Error, "Failed to create stdout file") 93 | return ScriptResultWithError(fmt.Errorf("failed to create stdout temp file: %w", err)) 94 | } 95 | stdoutSpan.SetAttributes(attribute.String("stdout_file", stdoutFile.Name())) 96 | stdoutSpan.SetStatus(codes.Ok, "") 97 | stdoutSpan.End() 98 | defer os.Remove(stdoutFile.Name()) 99 | defer stdoutFile.Close() 100 | 101 | _, stderrSpan := wasmTracer.Start(ctx, "wasm.create_stderr_file") 102 | stderrFile, err := createTempFile("msgscript-wasm-stderr-*") 103 | if err != nil { 104 | stderrSpan.RecordError(err) 105 | stderrSpan.SetStatus(codes.Error, "Failed to create stderr file") 106 | stderrSpan.End() 107 | span.RecordError(err) 108 | span.SetStatus(codes.Error, "Failed to create stderr file") 109 | return ScriptResultWithError(fmt.Errorf("failed to create stderr temp file: %w", err)) 110 | } 111 | stderrSpan.SetAttributes(attribute.String("stderr_file", stderrFile.Name())) 112 | stderrSpan.SetStatus(codes.Ok, "") 113 | stderrSpan.End() 114 | defer os.Remove(stderrFile.Name()) 115 | defer stderrFile.Close() 116 | 117 | // Initialize WASM runtime 118 | _, initSpan := wasmTracer.Start(ctx, "wasm.initialize_runtime") 119 | engine := wasmtime.NewEngine() 120 | module, err := wasmtime.NewModule(engine, wasmBytes) 121 | if err != nil { 122 | initSpan.RecordError(err) 123 | initSpan.SetStatus(codes.Error, "Failed to create WASM module") 124 | initSpan.End() 125 | span.RecordError(err) 126 | span.SetStatus(codes.Error, "Failed to create WASM module") 127 | return ScriptResultWithError(fmt.Errorf("failed to create module: %w", err)) 128 | } 129 | 130 | linker := wasmtime.NewLinker(engine) 131 | err = linker.DefineWasi() 132 | if err != nil { 133 | initSpan.RecordError(err) 134 | initSpan.SetStatus(codes.Error, "Failed to define WASI") 135 | initSpan.End() 136 | span.RecordError(err) 137 | span.SetStatus(codes.Error, "Failed to define WASI") 138 | return ScriptResultWithError(fmt.Errorf("failed to define WASI: %w", err)) 139 | } 140 | 141 | wasiConfig := wasmtime.NewWasiConfig() 142 | wasiConfig.SetStdoutFile(stdoutFile.Name()) 143 | wasiConfig.SetStderrFile(stderrFile.Name()) 144 | wasiConfig.SetEnv([]string{"SUBJECT", "PAYLOAD", "METHOD", "URL"}, []string{msg.Subject, string(msg.Payload), msg.Method, msg.URL}) 145 | span.SetAttributes( 146 | attribute.String("wasm.env.subject", msg.Subject), 147 | attribute.String("wasm.env.method", msg.Method), 148 | attribute.String("wasm.env.url", msg.URL), 149 | ) 150 | 151 | store := wasmtime.NewStore(engine) 152 | store.SetWasi(wasiConfig) 153 | 154 | instance, err := linker.Instantiate(store, module) 155 | if err != nil { 156 | initSpan.RecordError(err) 157 | initSpan.SetStatus(codes.Error, "Failed to instantiate WASM module") 158 | initSpan.End() 159 | span.RecordError(err) 160 | span.SetStatus(codes.Error, "Failed to instantiate WASM module") 161 | return ScriptResultWithError(fmt.Errorf("failed to instantiate: %w", err)) 162 | } 163 | initSpan.SetStatus(codes.Ok, "") 164 | initSpan.End() 165 | 166 | // Execute WASM module 167 | _, execSpan := wasmTracer.Start(ctx, "wasm.execute_module", trace.WithAttributes( 168 | attribute.String("wasm.function", "_start"), 169 | )) 170 | log.WithFields(fields).Debug("running wasm module") 171 | 172 | // Execute the main function of the WASM module 173 | wasmFunc := instance.GetFunc(store, "_start") 174 | if wasmFunc == nil { 175 | execSpan.SetStatus(codes.Error, "_start function not found") 176 | execSpan.End() 177 | span.SetStatus(codes.Error, "_start function not found") 178 | return ScriptResultWithError(fmt.Errorf("GET function not found")) 179 | } 180 | 181 | _, err = wasmFunc.Call(store) 182 | if err != nil { 183 | ec, ok := err.(*wasmtime.Error).ExitStatus() 184 | execSpan.SetAttributes(attribute.Int("wasm.exit_code", int(ec))) 185 | if ok && ec != 0 { 186 | execSpan.RecordError(err) 187 | execSpan.SetStatus(codes.Error, fmt.Sprintf("WASM exit code: %d", ec)) 188 | execSpan.End() 189 | span.RecordError(err) 190 | span.SetStatus(codes.Error, fmt.Sprintf("WASM exit code: %d", ec)) 191 | return ScriptResultWithError(fmt.Errorf("failed to call wasm module function: %w", err)) 192 | } 193 | } 194 | execSpan.SetStatus(codes.Ok, "") 195 | execSpan.End() 196 | 197 | // Read stdout 198 | _, stdoutReadSpan := wasmTracer.Start(ctx, "wasm.read_stdout") 199 | payload, err := readTempFile(stdoutFile) 200 | if err != nil { 201 | stdoutReadSpan.RecordError(err) 202 | stdoutReadSpan.SetStatus(codes.Error, "Failed to read stdout") 203 | stdoutReadSpan.End() 204 | span.RecordError(err) 205 | span.SetStatus(codes.Error, "Failed to read stdout") 206 | return ScriptResultWithError(fmt.Errorf("failed to read stdout temp file: %v", err)) 207 | } 208 | stdoutReadSpan.SetAttributes(attribute.Int("stdout_size", len(payload))) 209 | stdoutReadSpan.SetStatus(codes.Ok, "") 210 | stdoutReadSpan.End() 211 | 212 | // Parse result 213 | _, parseSpan := wasmTracer.Start(ctx, "wasm.parse_result") 214 | err = json.Unmarshal(payload, res) 215 | if err != nil { 216 | // If we can't find that struct in the result, we just take the return data as is 217 | parseSpan.SetAttributes(attribute.Bool("result.is_json", false)) 218 | res.Payload = payload 219 | } else { 220 | parseSpan.SetAttributes(attribute.Bool("result.is_json", true)) 221 | } 222 | parseSpan.SetStatus(codes.Ok, "") 223 | parseSpan.End() 224 | 225 | span.SetAttributes(attribute.Int("result.payload_size", len(res.Payload))) 226 | 227 | // Check stderr file 228 | _, stderrReadSpan := wasmTracer.Start(ctx, "wasm.read_stderr") 229 | errB, err := readTempFile(stderrFile) 230 | if err != nil { 231 | stderrReadSpan.RecordError(err) 232 | stderrReadSpan.SetStatus(codes.Error, "Failed to read stderr") 233 | stderrReadSpan.End() 234 | span.RecordError(err) 235 | span.SetStatus(codes.Error, "Failed to read stderr") 236 | return ScriptResultWithError(fmt.Errorf("failed to read stderr temp file: %v", err)) 237 | } 238 | 239 | if len(errB) > 0 { 240 | res.Error = string(errB) 241 | stderrReadSpan.SetAttributes( 242 | attribute.Int("stderr_size", len(errB)), 243 | attribute.Bool("has_error", true), 244 | ) 245 | span.SetAttributes(attribute.String("wasm.stderr", res.Error)) 246 | span.SetStatus(codes.Error, "WASM module wrote to stderr") 247 | } else { 248 | stderrReadSpan.SetAttributes(attribute.Bool("has_error", false)) 249 | span.SetStatus(codes.Ok, "WASM module executed successfully") 250 | } 251 | stderrReadSpan.SetStatus(codes.Ok, "") 252 | stderrReadSpan.End() 253 | 254 | return res 255 | } 256 | 257 | func readTempFile(f *os.File) ([]byte, error) { 258 | // Rewind the file 259 | _, err := f.Seek(0, 0) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | // Read all of it than assign 265 | b, err := io.ReadAll(f) 266 | if err != nil { 267 | return nil, err 268 | } 269 | 270 | return b, nil 271 | } 272 | 273 | func (we *WasmExecutor) Stop() { 274 | we.cancelFunc() 275 | log.Debug("WasmExecutor stopped") 276 | } 277 | -------------------------------------------------------------------------------- /store/etcd.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/numkem/msgscript/script" 13 | log "github.com/sirupsen/logrus" 14 | clientv3 "go.etcd.io/etcd/client/v3" 15 | "go.etcd.io/etcd/client/v3/concurrency" 16 | ) 17 | 18 | const ( 19 | ETCD_TIMEOUT = 3 * time.Second 20 | ETCD_SESSION_TTL = 3 // In seconds 21 | ETCD_SCRIPT_KEY_PREFIX = "msgscript/scripts" 22 | ETCD_LIBRARY_KEY_PREFIX = "msgscript/libs" 23 | ) 24 | 25 | // EtcdScriptStore stores Lua scripts in etcd, supporting multiple scripts per subject 26 | type EtcdScriptStore struct { 27 | client *clientv3.Client 28 | prefix string 29 | mutexes sync.Map 30 | } 31 | 32 | func EtcdClient(endpoints string) (*clientv3.Client, error) { 33 | if e := os.Getenv("ETCD_ENDPOINTS"); e != "" { 34 | endpoints = e 35 | } 36 | 37 | // HACK: instead of using a global or carry over the variable everywhere, 38 | // we set the environment variable if it's not defined 39 | if os.Getenv("ETCD_ENDPOINTS") == "" { 40 | err := os.Setenv("ETCD_ENDPOINTS", endpoints) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to set ETCD_ENDPOINTS environment variable") 43 | } 44 | } 45 | 46 | return clientv3.New(clientv3.Config{ 47 | Endpoints: etcdEndpoints(endpoints), 48 | DialTimeout: ETCD_TIMEOUT, 49 | }) 50 | } 51 | 52 | func etcdEndpoints(endpoints string) []string { 53 | return strings.Split(endpoints, ",") 54 | } 55 | 56 | // NewEtcdScriptStore creates a new instance of EtcdScriptStore 57 | func NewEtcdScriptStore(endpoints string) (ScriptStore, error) { 58 | log.Debugf("Attempting to connect to etcd @ %s", endpoints) 59 | 60 | client, err := EtcdClient(endpoints) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to connect to etcd: %w", err) 63 | } 64 | 65 | log.Debugf("Connected to etcd @ %s", strings.Join(client.Endpoints(), ",")) 66 | 67 | return &EtcdScriptStore{ 68 | client: client, 69 | prefix: ETCD_SCRIPT_KEY_PREFIX, 70 | mutexes: sync.Map{}, 71 | }, nil 72 | } 73 | 74 | func (e *EtcdScriptStore) getKey(subject, name string) string { 75 | return strings.Join([]string{e.prefix, subject, name}, "/") 76 | } 77 | 78 | // AddScript adds a new Lua script under the given subject with a unique ID 79 | func (e *EtcdScriptStore) AddScript(ctx context.Context, subject, name string, scr *script.Script) error { 80 | key := e.getKey(subject, name) 81 | 82 | // Store script in etcd 83 | val, err := encodeValue(scr) 84 | if err != nil { 85 | return fmt.Errorf("failed to encode script: %w", err) 86 | } 87 | 88 | _, err = e.client.Put(ctx, key, string(val)) 89 | if err != nil { 90 | return fmt.Errorf("failed to add script for subject '%s': %w", subject, err) 91 | } 92 | 93 | log.Debugf("Script added for subject %s named %s", subject, name) 94 | return nil 95 | } 96 | 97 | func decodeValue(b []byte) (*script.Script, error) { 98 | // Decode json 99 | scr := &script.Script{} 100 | err := json.Unmarshal(b, scr) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to unmarshal script: %v", err) 103 | } 104 | 105 | return scr, nil 106 | } 107 | 108 | func encodeValue(scr *script.Script) ([]byte, error) { 109 | b, err := json.Marshal(scr) 110 | if err != nil { 111 | return nil, fmt.Errorf("failed to marshal script: %v", err) 112 | } 113 | 114 | return b, nil 115 | } 116 | 117 | // GetScripts retrieves all scripts associated with a subject 118 | func (e *EtcdScriptStore) GetScripts(ctx context.Context, subject string) (map[string]*script.Script, error) { 119 | keyPrefix := strings.Join([]string{e.prefix, subject}, "/") 120 | 121 | // Fetch all scripts under the subject's prefix 122 | resp, err := e.client.Get(ctx, keyPrefix, clientv3.WithPrefix()) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to get scripts for subject '%s': %w", subject, err) 125 | } 126 | 127 | scripts := make(map[string]*script.Script) 128 | for _, kv := range resp.Kvs { 129 | scr, err := decodeValue(kv.Value) 130 | if err != nil { 131 | return nil, fmt.Errorf("failed to decode script: %w", err) 132 | } 133 | 134 | scripts[string(kv.Key)] = scr 135 | } 136 | 137 | log.Debugf("Retrieved %d scripts for subject %s", len(scripts), subject) 138 | return scripts, nil 139 | } 140 | 141 | // DeleteScript deletes a specific Lua script for a subject by its name 142 | func (e *EtcdScriptStore) DeleteScript(ctx context.Context, subject, name string) error { 143 | key := fmt.Sprintf("%s/%s/%s", e.prefix, subject, name) 144 | 145 | // Delete script from etcd 146 | _, err := e.client.Delete(ctx, key) 147 | if err != nil { 148 | return fmt.Errorf("failed to delete script for subject '%s' with ID '%s': %w", subject, name, err) 149 | } 150 | 151 | log.Debugf("Deleted script for subject %s with ID %s", subject, name) 152 | return nil 153 | } 154 | 155 | // WatchScripts watches for changes to scripts for a specific subject 156 | func (e *EtcdScriptStore) WatchScripts(ctx context.Context, subject string, onChange func(subject, name string, script []byte, deleted bool)) { 157 | keyPrefix := fmt.Sprintf("%s/%s/", e.prefix, subject) 158 | 159 | watchChan := e.client.Watch(ctx, keyPrefix, clientv3.WithPrefix()) 160 | 161 | for watchResp := range watchChan { 162 | for _, ev := range watchResp.Events { 163 | name := string(ev.Kv.Key[len(keyPrefix):]) 164 | switch ev.Type { 165 | case clientv3.EventTypePut: 166 | log.Debugf("Script added/updated for subject: %s, ID: %s", subject, name) 167 | onChange(subject, name, ev.Kv.Value, false) 168 | case clientv3.EventTypeDelete: 169 | log.Debugf("Script deleted for subject: %s, ID: %s", subject, name) 170 | onChange(subject, name, nil, true) 171 | } 172 | } 173 | } 174 | } 175 | 176 | func (e *EtcdScriptStore) acquireLock(ctx context.Context, lockKey string, ttl int) (*concurrency.Mutex, error) { 177 | // Create a lease 178 | sess, err := concurrency.NewSession(e.client, concurrency.WithTTL(ttl), concurrency.WithContext(ctx)) 179 | if err != nil { 180 | return nil, fmt.Errorf("failed to create session: %w", err) 181 | } 182 | fields := log.Fields{ 183 | "lockKey": lockKey, 184 | } 185 | log.WithFields(fields).Debugf("etcdStore: Acquiring lock") 186 | 187 | l := concurrency.NewMutex(sess, lockKey) 188 | err = l.TryLock(ctx) 189 | if err != nil { 190 | if err == context.Canceled { 191 | return nil, concurrency.ErrLocked 192 | } 193 | 194 | return nil, err 195 | } 196 | 197 | log.WithFields(fields).Debug("etcdStore: Acquired lock") 198 | 199 | return l, nil 200 | } 201 | 202 | func (e *EtcdScriptStore) ReleaseLock(ctx context.Context, path string) error { 203 | fields := log.Fields{ 204 | "path": path, 205 | } 206 | v, ok := e.mutexes.Load(path) 207 | if !ok { 208 | // We don't have a lock for that path 209 | log.WithFields(fields).Debug("etcdStore: failed to find a locking mutex for timer") 210 | return nil 211 | } 212 | 213 | l := v.(*lock) 214 | err := l.Mutex.Unlock(ctx) 215 | if err != nil { 216 | return fmt.Errorf("etcdStore: failed to release lock: %w", err) 217 | } 218 | log.WithFields(fields).Debug("etcdStore: Released the lock") 219 | 220 | // Stop the timer 221 | l.Timer.Stop() 222 | e.mutexes.Delete(path) 223 | 224 | return nil 225 | } 226 | 227 | type lock struct { 228 | Mutex *concurrency.Mutex 229 | Timer *time.Timer 230 | } 231 | 232 | func (e *EtcdScriptStore) TakeLock(ctx context.Context, path string) (bool, error) { 233 | lockKey := path + "_lock" 234 | mu, err := e.acquireLock(ctx, lockKey, ETCD_SESSION_TTL) 235 | if err != nil { 236 | if err == concurrency.ErrLocked { 237 | return false, fmt.Errorf("already locked") 238 | } 239 | 240 | return false, fmt.Errorf("failed to get lock on key %s: %w", lockKey, err) 241 | } 242 | 243 | // Remove the mutex from the map after 1 second more than the session's TTL in case it's never unlocked 244 | timer := time.AfterFunc((ETCD_SESSION_TTL+1)*time.Second, func() { 245 | log.WithField("path", path).Debug("Releasing lock on timeout") 246 | e.ReleaseLock(context.Background(), lockKey) 247 | }) 248 | 249 | e.mutexes.Store(path, &lock{ 250 | Mutex: mu, 251 | Timer: timer, 252 | }) 253 | 254 | return true, nil 255 | } 256 | 257 | func (e *EtcdScriptStore) ListSubjects(ctx context.Context) ([]string, error) { 258 | resp, err := e.client.KV.Get(ctx, ETCD_SCRIPT_KEY_PREFIX, clientv3.WithPrefix()) 259 | if err != nil { 260 | return nil, fmt.Errorf("failed to list keys: %w", err) 261 | } 262 | 263 | var subjects []string 264 | for _, kv := range resp.Kvs { 265 | ss := strings.Split(strings.Replace(string(kv.Key), ETCD_SCRIPT_KEY_PREFIX, "", 1), "/") 266 | subjects = append(subjects, ss[1]) 267 | } 268 | 269 | return subjects, nil 270 | } 271 | 272 | func (e *EtcdScriptStore) LoadLibrairies(ctx context.Context, libraryPaths []string) ([][]byte, error) { 273 | var libraries [][]byte 274 | for _, path := range libraryPaths { 275 | key := strings.Join([]string{ETCD_LIBRARY_KEY_PREFIX, path}, "/") 276 | 277 | resp, err := e.client.Get(ctx, key) 278 | if err != nil { 279 | return nil, fmt.Errorf("failed to read key %s: %w", key, err) 280 | } 281 | 282 | if len(resp.Kvs) != 1 { 283 | return nil, fmt.Errorf("key %s doesn't exists", key) 284 | } 285 | 286 | libraries = append(libraries, resp.Kvs[0].Value) 287 | } 288 | 289 | return libraries, nil 290 | } 291 | 292 | func (e *EtcdScriptStore) AddLibrary(ctx context.Context, content []byte, path string) error { 293 | key := strings.Join([]string{ETCD_LIBRARY_KEY_PREFIX, path}, "/") 294 | _, err := e.client.Put(ctx, key, string(content)) 295 | if err != nil { 296 | return fmt.Errorf("failed to store library key %s: %w", key, err) 297 | } 298 | 299 | return nil 300 | } 301 | 302 | func (e *EtcdScriptStore) RemoveLibrary(ctx context.Context, path string) error { 303 | key := strings.Join([]string{ETCD_LIBRARY_KEY_PREFIX, path}, "/") 304 | _, err := e.client.Delete(ctx, key) 305 | if err != nil { 306 | return fmt.Errorf("failed to delete library key %s: %w", key, err) 307 | } 308 | 309 | return nil 310 | } 311 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/numkem/msgscript 2 | 3 | go 1.24.4 4 | 5 | replace github.com/openbao/openbao/api v1.12.2 => github.com/hashicorp/vault/api v1.12.2 6 | 7 | require ( 8 | github.com/bytecodealliance/wasmtime-go/v37 v37.0.0 9 | github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 10 | github.com/containers/podman/v5 v5.5.2 11 | github.com/felipejfc/gluahttpscrape v0.0.0-20170525191632-10580c4a38f9 12 | github.com/fsnotify/fsnotify v1.9.0 13 | github.com/google/uuid v1.6.0 14 | github.com/jedib0t/go-pretty/v6 v6.5.9 15 | github.com/layeh/gopher-json v0.0.0-20201124131017-552bb3c4c3bf 16 | github.com/nats-io/nats-server/v2 v2.10.24 17 | github.com/nats-io/nats.go v1.37.0 18 | github.com/openbao/openbao/api v1.12.2 19 | github.com/opencontainers/runtime-spec v1.2.1 20 | github.com/sirupsen/logrus v1.9.3 21 | github.com/spf13/cobra v1.9.1 22 | github.com/stretchr/testify v1.11.1 23 | github.com/tengattack/gluasql v0.0.0-20240325124313-344b155b513c 24 | github.com/vadv/gopher-lua-libs v0.5.0 25 | github.com/yuin/gluare v0.0.0-20170607022532-d7c94f1a80ed 26 | github.com/yuin/gopher-lua v1.1.1 27 | go.etcd.io/etcd/client/v3 v3.5.15 28 | go.opentelemetry.io/otel v1.38.0 29 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 30 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 31 | go.opentelemetry.io/otel/sdk v1.38.0 32 | go.opentelemetry.io/otel/trace v1.38.0 33 | golang.org/x/net v0.39.0 34 | google.golang.org/grpc v1.71.0 35 | layeh.com/gopher-lfs v0.0.0-20201124131141-d5fb28581d14 36 | ) 37 | 38 | require ( 39 | dario.cat/mergo v1.0.1 // indirect 40 | filippo.io/edwards25519 v1.1.0 // indirect 41 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 42 | github.com/BurntSushi/toml v1.5.0 // indirect 43 | github.com/Microsoft/go-winio v0.6.2 // indirect 44 | github.com/Microsoft/hcsshim v0.12.9 // indirect 45 | github.com/VividCortex/ewma v1.2.0 // indirect 46 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 47 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 48 | github.com/blang/semver/v4 v4.0.0 // indirect 49 | github.com/cbroglie/mustache v1.0.1 // indirect 50 | github.com/cenkalti/backoff/v3 v3.2.2 // indirect 51 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 52 | github.com/chzyer/readline v1.5.1 // indirect 53 | github.com/containerd/cgroups/v3 v3.0.5 // indirect 54 | github.com/containerd/errdefs v1.0.0 // indirect 55 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 56 | github.com/containerd/log v0.1.0 // indirect 57 | github.com/containerd/platforms v1.0.0-rc.1 // indirect 58 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 59 | github.com/containerd/typeurl/v2 v2.2.3 // indirect 60 | github.com/containers/buildah v1.40.1 // indirect 61 | github.com/containers/common v0.63.1 // indirect 62 | github.com/containers/image/v5 v5.35.0 // indirect 63 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect 64 | github.com/containers/ocicrypt v1.2.1 // indirect 65 | github.com/containers/psgo v1.9.0 // indirect 66 | github.com/containers/storage v1.58.0 // indirect 67 | github.com/coreos/go-semver v0.3.0 // indirect 68 | github.com/coreos/go-systemd/v22 v22.5.1-0.20231103132048-7d375ecc2b09 // indirect 69 | github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect 70 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 71 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 72 | github.com/disiqueira/gotree/v3 v3.0.2 // indirect 73 | github.com/distribution/reference v0.6.0 // indirect 74 | github.com/docker/distribution v2.8.3+incompatible // indirect 75 | github.com/docker/docker v28.1.1+incompatible // indirect 76 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 77 | github.com/docker/go-connections v0.5.0 // indirect 78 | github.com/docker/go-units v0.5.0 // indirect 79 | github.com/felixge/httpsnoop v1.0.4 // indirect 80 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 81 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 82 | github.com/go-logr/logr v1.4.3 // indirect 83 | github.com/go-logr/stdr v1.2.2 // indirect 84 | github.com/go-openapi/analysis v0.23.0 // indirect 85 | github.com/go-openapi/errors v0.22.1 // indirect 86 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 87 | github.com/go-openapi/jsonreference v0.21.0 // indirect 88 | github.com/go-openapi/loads v0.22.0 // indirect 89 | github.com/go-openapi/runtime v0.28.0 // indirect 90 | github.com/go-openapi/spec v0.21.0 // indirect 91 | github.com/go-openapi/strfmt v0.23.0 // indirect 92 | github.com/go-openapi/swag v0.23.1 // indirect 93 | github.com/go-openapi/validate v0.24.0 // indirect 94 | github.com/go-sql-driver/mysql v1.9.1 // indirect 95 | github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 // indirect 96 | github.com/gogo/protobuf v1.3.2 // indirect 97 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 98 | github.com/golang/protobuf v1.5.4 // indirect 99 | github.com/google/go-containerregistry v0.20.3 // indirect 100 | github.com/google/go-intervals v0.0.2 // indirect 101 | github.com/gorilla/mux v1.8.1 // indirect 102 | github.com/gorilla/schema v1.4.1 // indirect 103 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect 104 | github.com/hashicorp/errwrap v1.1.0 // indirect 105 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 106 | github.com/hashicorp/go-multierror v1.1.1 // indirect 107 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 108 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 109 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect 110 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 111 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 112 | github.com/hashicorp/hcl v1.0.0 // indirect 113 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 114 | github.com/jinzhu/copier v0.4.0 // indirect 115 | github.com/josharian/intern v1.0.0 // indirect 116 | github.com/json-iterator/go v1.1.12 // indirect 117 | github.com/junhsieh/goexamples v0.0.0-20210908032526-acdd3160140b // indirect 118 | github.com/kevinburke/ssh_config v1.2.0 // indirect 119 | github.com/klauspost/compress v1.18.0 // indirect 120 | github.com/klauspost/pgzip v1.2.6 // indirect 121 | github.com/kr/fs v0.1.0 // indirect 122 | github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect 123 | github.com/mailru/easyjson v0.9.0 // indirect 124 | github.com/manifoldco/promptui v0.9.0 // indirect 125 | github.com/mattn/go-runewidth v0.0.16 // indirect 126 | github.com/mattn/go-sqlite3 v1.14.28 // indirect 127 | github.com/miekg/pkcs11 v1.1.1 // indirect 128 | github.com/minio/highwayhash v1.0.3 // indirect 129 | github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect 130 | github.com/mitchellh/go-homedir v1.1.0 // indirect 131 | github.com/mitchellh/mapstructure v1.5.0 // indirect 132 | github.com/moby/docker-image-spec v1.3.1 // indirect 133 | github.com/moby/sys/capability v0.4.0 // indirect 134 | github.com/moby/sys/mountinfo v0.7.2 // indirect 135 | github.com/moby/sys/user v0.4.0 // indirect 136 | github.com/moby/sys/userns v0.1.0 // indirect 137 | github.com/moby/term v0.5.2 // indirect 138 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 139 | github.com/modern-go/reflect2 v1.0.2 // indirect 140 | github.com/morikuni/aec v1.0.0 // indirect 141 | github.com/nats-io/jwt/v2 v2.7.3 // indirect 142 | github.com/nats-io/nkeys v0.4.9 // indirect 143 | github.com/nats-io/nuid v1.0.1 // indirect 144 | github.com/nxadm/tail v1.4.11 // indirect 145 | github.com/oklog/ulid v1.3.1 // indirect 146 | github.com/opencontainers/cgroups v0.0.1 // indirect 147 | github.com/opencontainers/go-digest v1.0.0 // indirect 148 | github.com/opencontainers/image-spec v1.1.1 // indirect 149 | github.com/opencontainers/runc v1.2.6 // indirect 150 | github.com/opencontainers/runtime-tools v0.9.1-0.20250303011046-260e151b8552 // indirect 151 | github.com/opencontainers/selinux v1.12.0 // indirect 152 | github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f // indirect 153 | github.com/pkg/errors v0.9.1 // indirect 154 | github.com/pkg/sftp v1.13.9 // indirect 155 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 156 | github.com/proglottis/gpgme v0.1.4 // indirect 157 | github.com/rivo/uniseg v0.4.7 // indirect 158 | github.com/ryanuber/go-glob v1.0.0 // indirect 159 | github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect 160 | github.com/sigstore/fulcio v1.6.6 // indirect 161 | github.com/sigstore/protobuf-specs v0.4.1 // indirect 162 | github.com/sigstore/rekor v1.3.10 // indirect 163 | github.com/sigstore/sigstore v1.9.3 // indirect 164 | github.com/skeema/knownhosts v1.3.1 // indirect 165 | github.com/smallstep/pkcs7 v0.1.1 // indirect 166 | github.com/spf13/pflag v1.0.6 // indirect 167 | github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect 168 | github.com/sylabs/sif/v2 v2.21.1 // indirect 169 | github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect 170 | github.com/tchap/go-patricia/v2 v2.3.2 // indirect 171 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect 172 | github.com/ulikunitz/xz v0.5.12 // indirect 173 | github.com/vbatts/tar-split v0.12.1 // indirect 174 | github.com/vbauerster/mpb/v8 v8.9.3 // indirect 175 | github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 // indirect 176 | github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect 177 | go.etcd.io/etcd/api/v3 v3.5.15 // indirect 178 | go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect 179 | go.mongodb.org/mongo-driver v1.14.0 // indirect 180 | go.opencensus.io v0.24.0 // indirect 181 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 182 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 183 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 184 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 185 | go.opentelemetry.io/proto/otlp v1.4.0 // indirect 186 | go.uber.org/multierr v1.11.0 // indirect 187 | go.uber.org/zap v1.27.0 // indirect 188 | golang.org/x/crypto v0.38.0 // indirect 189 | golang.org/x/sync v0.14.0 // indirect 190 | golang.org/x/sys v0.35.0 // indirect 191 | golang.org/x/term v0.32.0 // indirect 192 | golang.org/x/text v0.25.0 // indirect 193 | golang.org/x/time v0.11.0 // indirect 194 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 195 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect 196 | google.golang.org/protobuf v1.36.6 // indirect 197 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 198 | gopkg.in/yaml.v2 v2.4.0 // indirect 199 | gopkg.in/yaml.v3 v3.0.1 // indirect 200 | layeh.com/gopher-luar v1.0.11 // indirect 201 | sigs.k8s.io/yaml v1.4.0 // indirect 202 | tags.cncf.io/container-device-interface v1.0.1 // indirect 203 | ) 204 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /executor/lua_executor.go: -------------------------------------------------------------------------------- 1 | package executor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strings" 9 | 10 | "github.com/cjoudrey/gluahttp" 11 | luajson "github.com/layeh/gopher-json" 12 | "github.com/nats-io/nats.go" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/yuin/gluare" 15 | lua "github.com/yuin/gopher-lua" 16 | "go.opentelemetry.io/otel" 17 | "go.opentelemetry.io/otel/attribute" 18 | "go.opentelemetry.io/otel/codes" 19 | "go.opentelemetry.io/otel/trace" 20 | lfs "layeh.com/gopher-lfs" 21 | 22 | luamodules "github.com/numkem/msgscript/lua" 23 | msgplugins "github.com/numkem/msgscript/plugins" 24 | "github.com/numkem/msgscript/script" 25 | msgstore "github.com/numkem/msgscript/store" 26 | ) 27 | 28 | var luaTracer = otel.Tracer("msgscript.executor.lua") 29 | 30 | // LuaExecutor defines the structure responsible for managing Lua script execution 31 | type LuaExecutor struct { 32 | cancelFunc context.CancelFunc // Context cancellation function 33 | ctx context.Context // Context for cancellation 34 | nc *nats.Conn // Connection to NATS 35 | store msgstore.ScriptStore // Interface for the script storage backend 36 | plugins []msgplugins.PreloadFunc // Plugins to load before execution 37 | } 38 | 39 | // NewLuaExecutor creates a new ScriptExecutor using the provided ScriptStore 40 | func NewLuaExecutor(c context.Context, store msgstore.ScriptStore, plugins []msgplugins.PreloadFunc, nc *nats.Conn) Executor { 41 | ctx, cancelFunc := context.WithCancel(c) 42 | 43 | return &LuaExecutor{ 44 | cancelFunc: cancelFunc, 45 | ctx: ctx, 46 | nc: nc, 47 | store: store, 48 | plugins: plugins, 49 | } 50 | } 51 | 52 | // HandleMessage receives a message, matches it to a Lua script, and executes the script in a new goroutine 53 | // Run the Lua script in a separate goroutine to handle the message for each script 54 | func (le *LuaExecutor) HandleMessage(ctx context.Context, msg *Message, scr *script.Script) *ScriptResult { 55 | ctx, span := luaTracer.Start(ctx, "lua.handle_message", 56 | trace.WithAttributes( 57 | attribute.String("subject", msg.Subject), 58 | attribute.String("method", msg.Method), 59 | attribute.Int("payload_size", len(msg.Payload)), 60 | ), 61 | ) 62 | defer span.End() 63 | 64 | fields := log.Fields{ 65 | "subject": msg.Subject, 66 | "executor": "lua", 67 | } 68 | 69 | ss := strings.Split(scr.Name, "/") 70 | name := ss[len(ss)-1] 71 | fields["path"] = name 72 | 73 | // Create a child span for this script execution 74 | _, scriptSpan := luaTracer.Start(ctx, "lua.build_script", 75 | trace.WithAttributes( 76 | attribute.String("script.name", scr.Subject), 77 | attribute.String("script.path", scr.Name), 78 | attribute.Bool("script.is_html", scr.HTML), 79 | attribute.Int("script.lib_count", len(scr.LibKeys)), 80 | ), 81 | ) 82 | defer scriptSpan.End() 83 | 84 | res := new(ScriptResult) 85 | 86 | tmp, err := os.MkdirTemp(os.TempDir(), "msgscript-lua-*s") 87 | if err != nil { 88 | scriptSpan.RecordError(err) 89 | scriptSpan.SetStatus(codes.Error, "Failed to create temp directory") 90 | 91 | res.Error = fmt.Sprintf("failed to create temp directory: %w", err) 92 | return nil 93 | } 94 | defer os.RemoveAll(tmp) 95 | 96 | err = os.Chdir(tmp) 97 | if err != nil { 98 | scriptSpan.RecordError(err) 99 | scriptSpan.SetStatus(codes.Error, "Failed to change directory") 100 | 101 | res.Error = fmt.Sprintf("failed to change to temp directory %s: %w", tmp, err) 102 | return nil 103 | } 104 | scriptSpan.SetAttributes(attribute.String("temp_dir", tmp)) 105 | 106 | // Load libraries 107 | _, libSpan := luaTracer.Start(ctx, "lua.load_libraries", 108 | trace.WithAttributes( 109 | attribute.Int("library_count", len(scr.LibKeys)), 110 | ), 111 | ) 112 | defer libSpan.End() 113 | libs, err := le.store.LoadLibrairies(ctx, scr.LibKeys) 114 | if err != nil { 115 | libSpan.RecordError(err) 116 | libSpan.SetStatus(codes.Error, "Failed to load libraries") 117 | 118 | scriptSpan.RecordError(err) 119 | scriptSpan.SetStatus(codes.Error, "Failed to load libraries") 120 | 121 | res.Error = fmt.Errorf("failed to read librairies: %w", err).Error() 122 | return res 123 | } 124 | libSpan.SetStatus(codes.Ok, "") 125 | 126 | // Acquire lock 127 | _, lockSpan := luaTracer.Start(ctx, "lua.acquire_lock", 128 | trace.WithAttributes( 129 | attribute.String("lock.name", scr.Name), 130 | ), 131 | ) 132 | defer lockSpan.End() 133 | 134 | locked, err := le.store.TakeLock(ctx, scr.Name) 135 | if err != nil { 136 | lockSpan.RecordError(err) 137 | lockSpan.SetStatus(codes.Error, "Failed to acquire lock") 138 | 139 | scriptSpan.RecordError(err) 140 | scriptSpan.SetStatus(codes.Error, "Failed to load libraries") 141 | 142 | log.WithFields(fields).Debugf("failed to get lock: %s", err) 143 | res.Error = fmt.Sprintf("failed to get lock: %s", err) 144 | return res 145 | } 146 | 147 | if !locked { 148 | lockSpan.SetStatus(codes.Error, "Lock not acquired") 149 | 150 | scriptSpan.SetStatus(codes.Error, "Could not acquire lock") 151 | 152 | log.WithFields(fields).Debug("we don't have a lock, giving up") 153 | res.Error = "cannot get lock" 154 | return res 155 | } 156 | lockSpan.SetStatus(codes.Ok, "Lock acquired") 157 | 158 | defer le.store.ReleaseLock(ctx, scr.Name) 159 | 160 | log.WithFields(fields).WithField("isHTML", scr.HTML).Debug("executing script") 161 | 162 | // Initialize Lua state 163 | _, luaInitSpan := luaTracer.Start(ctx, "lua.initialize_state") 164 | L := lua.NewState() 165 | tctx, tcan := context.WithTimeout(le.ctx, MAX_LUA_RUNNING_TIME) 166 | defer tcan() 167 | L.SetContext(tctx) 168 | defer L.Close() 169 | 170 | // Set up the Lua state with the subject and payload 171 | L.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader) 172 | L.PreloadModule("re", gluare.Loader) 173 | lfs.Preload(L) 174 | luajson.Preload(L) 175 | luamodules.PreloadEtcd(L) 176 | luamodules.PreloadNats(L, le.nc) 177 | 178 | // Load plugins 179 | if le.plugins != nil { 180 | err = msgplugins.LoadPlugins(L, le.plugins) 181 | if err != nil { 182 | luaInitSpan.RecordError(err) 183 | luaInitSpan.SetStatus(codes.Error, "Failed to load plugins") 184 | 185 | scriptSpan.RecordError(err) 186 | scriptSpan.SetStatus(codes.Error, "Failed to load plugins") 187 | 188 | res.Error = fmt.Sprintf("failed to load plugin: %w", err) 189 | return res 190 | } 191 | } 192 | luaInitSpan.SetStatus(codes.Ok, "") 193 | luaInitSpan.End() 194 | 195 | // Build script content 196 | var sb strings.Builder 197 | for _, l := range libs { 198 | sb.Write(l) 199 | sb.WriteString("\n") 200 | } 201 | sb.Write(scr.Content) 202 | scriptContent := sb.String() 203 | scriptSpan.SetAttributes(attribute.Int("script.content_size", len(scriptContent))) 204 | log.WithFields(fields).Debugf("script:\n%+s\n\n", scriptContent) 205 | 206 | // Execute Lua script 207 | _, execSpan := luaTracer.Start(ctx, "lua.execute_script") 208 | if err := L.DoString(scriptContent); err != nil { 209 | scriptSpan.RecordError(err) 210 | scriptSpan.SetStatus(codes.Error, "Script execute error") 211 | 212 | msg := fmt.Sprintf("error executing Lua script: %s", err) 213 | log.WithFields(fields).Errorf(msg) 214 | res.Error = err.Error() 215 | return nil 216 | } 217 | execSpan.SetStatus(codes.Ok, "") 218 | execSpan.End() 219 | 220 | // Execute the appropriate message handler 221 | if scr.HTML { 222 | // If the message is set to return HTML, we pass 2 things to the fonction named after the HTTP 223 | // method received ex: POST(), GET()... 224 | // The 2 things are: 225 | // - The URL part after the function name 226 | // - The body of the HTTP call 227 | res = le.executeHTMLMessage(ctx, fields, L, msg, scr.Name) 228 | } else { 229 | // If we do not have an HTML based message, we call the function named 230 | // OnMessage() with 2 parameters: 231 | // - The subject 232 | // - The body of the message 233 | res = le.executeRawMessage(ctx, fields, L, msg, scr.Name) 234 | } 235 | 236 | scriptSpan.SetStatus(codes.Ok, "Script executed successfully") 237 | 238 | return res 239 | } 240 | 241 | func (*LuaExecutor) executeHTMLMessage(ctx context.Context, fields log.Fields, L *lua.LState, msg *Message, name string) *ScriptResult { 242 | _, span := luaTracer.Start(ctx, "lua.execute_html_message", 243 | trace.WithAttributes( 244 | attribute.String("script.name", name), 245 | attribute.String("http.method", msg.Method), 246 | ), 247 | ) 248 | defer span.End() 249 | 250 | res := &ScriptResult{ 251 | IsHTML: true, 252 | Headers: make(map[string]string), 253 | } 254 | 255 | log.WithFields(fields).Debug("Running HTML based script") 256 | gMethod := L.GetGlobal(msg.Method) 257 | if msg.Method != "" && gMethod.Type().String() != "nil" { 258 | if err := L.CallByParam(lua.P{ 259 | Fn: gMethod, 260 | NRet: 3, 261 | Protect: true, 262 | }, lua.LString(msg.URL), lua.LString(string(msg.Payload))); err != nil { 263 | span.RecordError(err) 264 | span.SetStatus(codes.Error, fmt.Sprintf("Failed to call %s function", msg.Method)) 265 | 266 | res.Error = fmt.Errorf("failed to call %s function: %w", msg.Method, err).Error() 267 | return res 268 | } 269 | } 270 | 271 | // Expected return value from lua would look like this (super minimal): 272 | // return "", 200, {} 273 | // Only the first parameter is really necessary, the others are optional. 274 | // If they are not defined, they will be set to default values: 275 | // Return code will be a 200 (HTTP OK) 276 | // Headers will be empty ({}) 277 | res.Payload = []byte(lua.LVAsString(L.Get(1))) 278 | res.Code = int(lua.LVAsNumber(L.Get(2))) 279 | 280 | if res.Code == 0 { 281 | res.Code = http.StatusOK 282 | } 283 | 284 | lheaders := L.Get(3) 285 | if ltable, ok := lheaders.(*lua.LTable); ok { 286 | if ltable != nil { 287 | ltable.ForEach(func(k, v lua.LValue) { 288 | res.Headers[lua.LVAsString(k)] = lua.LVAsString(v) 289 | }) 290 | } 291 | } 292 | 293 | span.SetAttributes( 294 | attribute.Int("http.status_code", res.Code), 295 | attribute.Int("response.size", len(res.Payload)), 296 | attribute.Int("response.header_count", len(res.Headers)), 297 | ) 298 | span.SetStatus(codes.Ok, "HTML message executed") 299 | 300 | return res 301 | } 302 | 303 | func (*LuaExecutor) executeRawMessage(ctx context.Context, fields log.Fields, L *lua.LState, msg *Message, name string) *ScriptResult { 304 | _, span := luaTracer.Start(ctx, "lua.execute_raw_message", 305 | trace.WithAttributes( 306 | attribute.String("script.name", name), 307 | attribute.String("subject", msg.Subject), 308 | ), 309 | ) 310 | defer span.End() 311 | 312 | res := new(ScriptResult) 313 | log.WithFields(fields).Debug("Running standard script") 314 | 315 | gOnMessage := L.GetGlobal("OnMessage") 316 | if gOnMessage.Type().String() == "nil" { 317 | span.SetStatus(codes.Error, "OnMessage function not found") 318 | res.Error = "failed to find global function named 'OnMessage'" 319 | return res 320 | } 321 | 322 | // Call the "OnMessage" function 323 | err := L.CallByParam(lua.P{ 324 | Fn: gOnMessage, 325 | NRet: 1, 326 | Protect: true, 327 | }, lua.LString(msg.Subject), lua.LString(string(msg.Payload))) 328 | if err != nil { 329 | span.RecordError(err) 330 | span.SetStatus(codes.Error, "Failed to call OnMessage") 331 | res.Error = fmt.Errorf("failed to call OnMessage function: %w", err).Error() 332 | return res 333 | } 334 | 335 | result := L.Get(-1) 336 | val, ok := result.(lua.LString) 337 | if ok { 338 | res.Payload = []byte(val.String()) 339 | span.SetAttributes(attribute.Int("response.size", len(res.Payload))) 340 | span.SetStatus(codes.Ok, "Raw message executed") 341 | 342 | log.WithFields(fields).Debugf("Script output: \n%s\n", string(res.Payload)) 343 | } else { 344 | span.SetStatus(codes.Error, "Script did not return a string") 345 | 346 | log.WithFields(fields).Debug("Script did not return a string") 347 | } 348 | span.SetAttributes(attribute.Int("response.size", len(res.Payload))) 349 | span.SetStatus(codes.Ok, "Raw message executed") 350 | 351 | return res 352 | } 353 | 354 | // Stop gracefully shuts down the ScriptExecutor and stops watching for messages 355 | func (se *LuaExecutor) Stop() { 356 | se.cancelFunc() 357 | log.Debug("LuaExecutor stopped") 358 | } 359 | -------------------------------------------------------------------------------- /executor/podman_executor.go: -------------------------------------------------------------------------------- 1 | //go:build podman 2 | 3 | package executor 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "os" 11 | "sync" 12 | 13 | "github.com/containers/podman/v5/pkg/bindings" 14 | "github.com/containers/podman/v5/pkg/bindings/containers" 15 | "github.com/containers/podman/v5/pkg/bindings/images" 16 | "github.com/containers/podman/v5/pkg/specgen" 17 | "github.com/google/uuid" 18 | spec "github.com/opencontainers/runtime-spec/specs-go" 19 | log "github.com/sirupsen/logrus" 20 | "go.opentelemetry.io/otel" 21 | "go.opentelemetry.io/otel/attribute" 22 | "go.opentelemetry.io/otel/codes" 23 | "go.opentelemetry.io/otel/trace" 24 | 25 | "github.com/numkem/msgscript/script" 26 | scriptLib "github.com/numkem/msgscript/script" 27 | msgstore "github.com/numkem/msgscript/store" 28 | ) 29 | 30 | var podmanTracer = otel.Tracer("msgscript.executor.podman") 31 | 32 | type PodmanExecutor struct { 33 | containers sync.Map 34 | store msgstore.ScriptStore 35 | ConnText context.Context 36 | } 37 | 38 | type containerConfiguration struct { 39 | Image string `json:"image"` 40 | Mounts []spec.Mount `json:"mounts"` 41 | Command []string `json:"command"` 42 | Privileged bool `json:"privileged"` 43 | User string `json:"user"` 44 | Groups []string `json:"groups"` 45 | } 46 | 47 | func NewPodmanExecutor(ctx context.Context, store msgstore.ScriptStore) (*PodmanExecutor, error) { 48 | // Get Podman socket location 49 | sock_dir := os.Getenv("XDG_RUNTIME_DIR") 50 | socket := "unix:" + sock_dir + "/podman/podman.sock" 51 | 52 | // Connect to Podman socket 53 | connText, err := bindings.NewConnection(ctx, socket) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to connect to the podman socket: %w", err) 56 | } 57 | 58 | log.Info("WASM executor initialized") 59 | 60 | return &PodmanExecutor{ 61 | ConnText: connText, 62 | store: store, 63 | }, nil 64 | } 65 | 66 | func (pe *PodmanExecutor) HandleMessage(ctx context.Context, msg *Message, scr *script.Script) *ScriptResult { 67 | ctx, span := podmanTracer.Start(ctx, "podman.handle_message", 68 | trace.WithAttributes( 69 | attribute.String("subject", msg.Subject), 70 | attribute.String("script.name", scr.Name), 71 | attribute.String("method", msg.Method), 72 | attribute.Int("payload_size", len(msg.Payload)), 73 | ), 74 | ) 75 | defer span.End() 76 | 77 | fields := log.Fields{ 78 | "subject": msg.Subject, 79 | "executor": "podman", 80 | } 81 | 82 | fields["name"] = scr.Name 83 | 84 | _, parseSpan := podmanTracer.Start(ctx, "podman.parse_script") 85 | scr, err := scriptLib.ReadString(string(scr.Content)) 86 | if err != nil { 87 | parseSpan.RecordError(err) 88 | parseSpan.SetStatus(codes.Error, "Failed to parse script") 89 | parseSpan.End() 90 | span.RecordError(err) 91 | span.SetStatus(codes.Error, "Failed to parse script") 92 | return ScriptResultWithError(fmt.Errorf("failed to read script: %w", err)) 93 | } 94 | parseSpan.SetStatus(codes.Ok, "") 95 | parseSpan.End() 96 | 97 | res, err := pe.executeInContainer(ctx, fields, scr, msg) 98 | if err != nil { 99 | span.RecordError(err) 100 | span.SetStatus(codes.Error, "Failed to execute container") 101 | return ScriptResultWithError(fmt.Errorf("failed to execute container: %w", err)) 102 | } 103 | 104 | span.SetAttributes( 105 | attribute.Int("result.exit_code", res.Code), 106 | attribute.Int("result.payload_size", len(res.Payload)), 107 | attribute.Bool("result.has_error", res.Error != ""), 108 | ) 109 | span.SetStatus(codes.Ok, "Container executed successfully") 110 | 111 | return res 112 | } 113 | 114 | func (pe *PodmanExecutor) executeInContainer(ctx context.Context, fields log.Fields, scr *scriptLib.Script, msg *Message) (*ScriptResult, error) { 115 | ctx, span := podmanTracer.Start(ctx, "podman.execute_container") 116 | defer span.End() 117 | 118 | // Parse container configuration 119 | _, cfgSpan := podmanTracer.Start(ctx, "podman.parse_config") 120 | cfg := new(containerConfiguration) 121 | err := json.Unmarshal(scr.Content, cfg) 122 | if err != nil { 123 | cfgSpan.RecordError(err) 124 | cfgSpan.SetStatus(codes.Error, "Failed to decode config") 125 | cfgSpan.End() 126 | span.RecordError(err) 127 | span.SetStatus(codes.Error, "Failed to decode config") 128 | return nil, fmt.Errorf("failed to decode container configuration: %w", err) 129 | } 130 | cfgSpan.SetAttributes( 131 | attribute.String("container.image", cfg.Image), 132 | attribute.StringSlice("container.command", cfg.Command), 133 | attribute.Bool("container.privileged", cfg.Privileged), 134 | attribute.String("container.user", cfg.User), 135 | attribute.Int("container.mount_count", len(cfg.Mounts)), 136 | ) 137 | cfgSpan.SetStatus(codes.Ok, "") 138 | cfgSpan.End() 139 | 140 | containerName := "msgscript-" + uuid.New().String()[:8] 141 | span.SetAttributes(attribute.String("container.name", containerName)) 142 | 143 | // Pull the requested image 144 | _, pullSpan := podmanTracer.Start(ctx, "podman.pull_image", 145 | trace.WithAttributes( 146 | attribute.String("container.image", cfg.Image), 147 | ), 148 | ) 149 | _, err = images.Pull(pe.ConnText, cfg.Image, nil) 150 | if err != nil { 151 | pullSpan.RecordError(err) 152 | pullSpan.SetStatus(codes.Error, "Failed to pull image") 153 | pullSpan.End() 154 | span.RecordError(err) 155 | span.SetStatus(codes.Error, "Failed to pull image") 156 | return nil, fmt.Errorf("failed to pull image %s: %w", cfg.Image, err) 157 | } 158 | pullSpan.SetStatus(codes.Ok, "") 159 | pullSpan.End() 160 | 161 | // Create container spec 162 | spec := specgen.NewSpecGenerator(cfg.Image, false) 163 | spec.Command = cfg.Command 164 | spec.Name = containerName 165 | spec.Env = map[string]string{"SUBJECT": msg.Subject, "URL": msg.URL, "PAYLOAD": string(msg.Payload), "METHOD": msg.Method} 166 | spec.Mounts = cfg.Mounts 167 | spec.User = cfg.User 168 | spec.Groups = cfg.Groups 169 | spec.Privileged = &cfg.Privileged 170 | spec.Stdin = boolPtr(true) 171 | spec.Terminal = boolPtr(true) 172 | spec.Remove = boolPtr(true) 173 | 174 | fields["ctnName"] = containerName 175 | 176 | // Create container 177 | _, createSpan := podmanTracer.Start(ctx, "podman.create_container", 178 | trace.WithAttributes( 179 | attribute.String("container.name", containerName), 180 | attribute.String("container.image", cfg.Image), 181 | ), 182 | ) 183 | log.WithFields(fields).Debugf("creating container from spec") 184 | container, err := containers.CreateWithSpec(pe.ConnText, spec, nil) 185 | if err != nil { 186 | createSpan.RecordError(err) 187 | createSpan.SetStatus(codes.Error, "Failed to create container") 188 | createSpan.End() 189 | span.RecordError(err) 190 | span.SetStatus(codes.Error, "Failed to create container") 191 | return nil, fmt.Errorf("failed to create container with spec: %w", err) 192 | } 193 | fields["ctnID"] = container.ID 194 | createSpan.SetAttributes(attribute.String("container.id", container.ID)) 195 | createSpan.SetStatus(codes.Ok, "") 196 | createSpan.End() 197 | 198 | span.SetAttributes(attribute.String("container.id", container.ID)) 199 | 200 | // Create temp files 201 | _, stdoutFileSpan := podmanTracer.Start(ctx, "podman.create_stdout_file") 202 | stdout, err := createTempFile("msgscript-ctn-stdout-*") 203 | if err != nil { 204 | stdoutFileSpan.RecordError(err) 205 | stdoutFileSpan.SetStatus(codes.Error, "Failed to create stdout file") 206 | stdoutFileSpan.End() 207 | span.RecordError(err) 208 | span.SetStatus(codes.Error, "Failed to create stdout file") 209 | return nil, fmt.Errorf("failed to create temp file: %w", err) 210 | } 211 | stdoutFileSpan.SetAttributes(attribute.String("stdout_file", stdout.Name())) 212 | stdoutFileSpan.SetStatus(codes.Ok, "") 213 | stdoutFileSpan.End() 214 | defer stdout.Close() 215 | defer os.Remove(stdout.Name()) 216 | log.WithFields(fields).WithField("filename", stdout.Name()).Debugf("created stdout file") 217 | 218 | _, stderrFileSpan := podmanTracer.Start(ctx, "podman.create_stderr_file") 219 | stderr, err := createTempFile("msgscript-ctn-stderr-*") 220 | if err != nil { 221 | stderrFileSpan.RecordError(err) 222 | stderrFileSpan.SetStatus(codes.Error, "Failed to create stderr file") 223 | stderrFileSpan.End() 224 | span.RecordError(err) 225 | span.SetStatus(codes.Error, "Failed to create stderr file") 226 | return nil, fmt.Errorf("failed to create temp file: %w", err) 227 | } 228 | stderrFileSpan.SetAttributes(attribute.String("stderr_file", stderr.Name())) 229 | stderrFileSpan.SetStatus(codes.Ok, "") 230 | stderrFileSpan.End() 231 | defer stderr.Close() 232 | defer os.Remove(stderr.Name()) 233 | log.WithFields(fields).WithField("filename", stderr.Name()).Debugf("created stderr file") 234 | 235 | // Setup stdin pipe 236 | stdin, stdinW := io.Pipe() 237 | go func() { 238 | defer stdinW.Close() 239 | _, err := stdinW.Write(msg.Payload) 240 | if err != nil { 241 | log.WithFields(fields).WithError(err).Error("failed to write to container stdin") 242 | } 243 | }() 244 | 245 | // Attach to container 246 | _, attachSpan := podmanTracer.Start(ctx, "podman.attach_container", 247 | trace.WithAttributes( 248 | attribute.String("container.id", container.ID), 249 | ), 250 | ) 251 | attachReady := make(chan bool) 252 | go func() { 253 | err := containers.Attach(pe.ConnText, container.ID, stdin, stdout, stderr, attachReady, nil) 254 | if err != nil { 255 | log.WithFields(fields).Errorf("failed to attach to container ID %s: %v", container.ID, err) 256 | } 257 | }() 258 | 259 | <-attachReady 260 | log.WithFields(fields).Debug("attached to container") 261 | attachSpan.SetStatus(codes.Ok, "") 262 | attachSpan.End() 263 | 264 | pe.containers.Store(containerName, container.ID) 265 | 266 | // Start container 267 | _, startSpan := podmanTracer.Start(ctx, "podman.start_container", 268 | trace.WithAttributes( 269 | attribute.String("container.id", container.ID), 270 | ), 271 | ) 272 | log.WithFields(fields).Debug("starting container") 273 | err = containers.Start(pe.ConnText, container.ID, nil) 274 | if err != nil { 275 | startSpan.RecordError(err) 276 | startSpan.SetStatus(codes.Error, "Failed to start container") 277 | startSpan.End() 278 | span.RecordError(err) 279 | span.SetStatus(codes.Error, "Failed to start container") 280 | return nil, fmt.Errorf("failed to start container: %w", err) 281 | } 282 | log.WithFields(fields).Debug("started container") 283 | startSpan.SetStatus(codes.Ok, "") 284 | startSpan.End() 285 | 286 | // Wait for container to finish 287 | _, waitSpan := podmanTracer.Start(ctx, "podman.wait_container", 288 | trace.WithAttributes( 289 | attribute.String("container.id", container.ID), 290 | ), 291 | ) 292 | exitCode, err := containers.Wait(pe.ConnText, container.ID, nil) 293 | if err != nil { 294 | waitSpan.RecordError(err) 295 | waitSpan.SetStatus(codes.Error, "Container wait failed") 296 | waitSpan.End() 297 | span.RecordError(err) 298 | span.SetStatus(codes.Error, "Container wait failed") 299 | return nil, fmt.Errorf("Container exited with error: %w", err) 300 | } 301 | log.WithField("containerName", containerName).Infof("container exited with code: %d", exitCode) 302 | waitSpan.SetAttributes(attribute.Int("container.exit_code", int(exitCode))) 303 | if exitCode != 0 { 304 | waitSpan.SetStatus(codes.Error, fmt.Sprintf("Container exited with code %d", exitCode)) 305 | } else { 306 | waitSpan.SetStatus(codes.Ok, "") 307 | } 308 | waitSpan.End() 309 | 310 | pe.containers.Delete(containerName) 311 | log.WithFields(fields).Debug("container removed") 312 | 313 | // Read stdout 314 | _, readStdoutSpan := podmanTracer.Start(ctx, "podman.read_stdout") 315 | resStdout, err := os.ReadFile(stdout.Name()) 316 | if err != nil { 317 | readStdoutSpan.RecordError(err) 318 | readStdoutSpan.SetStatus(codes.Error, "Failed to read stdout") 319 | readStdoutSpan.End() 320 | span.RecordError(err) 321 | span.SetStatus(codes.Error, "Failed to read stdout") 322 | return nil, fmt.Errorf("failed to read stdout file %s: %w", stdout.Name(), err) 323 | } 324 | readStdoutSpan.SetAttributes(attribute.Int("stdout_size", len(resStdout))) 325 | readStdoutSpan.SetStatus(codes.Ok, "") 326 | readStdoutSpan.End() 327 | 328 | // Read stderr 329 | _, readStderrSpan := podmanTracer.Start(ctx, "podman.read_stderr") 330 | resStderr, err := os.ReadFile(stderr.Name()) 331 | if err != nil { 332 | readStderrSpan.RecordError(err) 333 | readStderrSpan.SetStatus(codes.Error, "Failed to read stderr") 334 | readStderrSpan.End() 335 | span.RecordError(err) 336 | span.SetStatus(codes.Error, "Failed to read stderr") 337 | return nil, fmt.Errorf("failed to read stderr file %s: %w", stderr.Name(), err) 338 | } 339 | readStderrSpan.SetAttributes( 340 | attribute.Int("stderr_size", len(resStderr)), 341 | attribute.Bool("has_stderr", len(resStderr) > 0), 342 | ) 343 | readStderrSpan.SetStatus(codes.Ok, "") 344 | readStderrSpan.End() 345 | 346 | log.WithFields(fields).Debugf("stdout: \n%+v", string(resStdout)) 347 | log.WithFields(fields).Debugf("stderr: \n%+v", string(resStderr)) 348 | 349 | result := &ScriptResult{ 350 | Code: int(exitCode), 351 | Headers: make(map[string]string), 352 | Payload: resStdout, 353 | Error: string(resStderr), 354 | } 355 | 356 | span.SetAttributes( 357 | attribute.Int("result.exit_code", int(exitCode)), 358 | attribute.Int("result.stdout_size", len(resStdout)), 359 | attribute.Int("result.stderr_size", len(resStderr)), 360 | ) 361 | 362 | if exitCode == 0 && len(resStderr) == 0 { 363 | span.SetStatus(codes.Ok, "Container executed successfully") 364 | } else { 365 | span.SetStatus(codes.Error, fmt.Sprintf("Container exited with code %d", exitCode)) 366 | } 367 | 368 | return result, nil 369 | } 370 | 371 | func (pe *PodmanExecutor) Stop() { 372 | // Go through each running container and kill them 373 | pe.containers.Range(func(key, value any) bool { 374 | containers.Kill(pe.ConnText, value.(string), &containers.KillOptions{ 375 | Signal: stringPtr("SIGKILL"), 376 | }) 377 | return true 378 | }) 379 | } 380 | 381 | func stringPtr(s string) *string { 382 | return &s 383 | } 384 | 385 | func boolPtr(b bool) *bool { 386 | return &b 387 | } 388 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | **Table of Contents** 7 | 8 | - [Features](#features) 9 | - [How it works](#how-it-works) 10 | - [Headers](#headers) 11 | - [The Function](#the-function) 12 | - [In Normal mode](#in-normal-mode) 13 | - [In HTTP mode](#in-http-mode) 14 | - [In HTTP+HTML mode](#in-httphtml-mode) 15 | - [Return value](#return-value) 16 | - [HTTP handler](#http-handler) 17 | - [Installation](#installation) 18 | - [Docker container](#docker-container) 19 | - [NixOS service](#nixos-service) 20 | - [Building it](#building-it) 21 | - [Clustering](#clustering) 22 | - [Adding Scripts](#adding-scripts) 23 | - [Command line flags](#command-line-flags) 24 | - [Cli options](#cli-options) 25 | - [Commands](#commands) 26 | - [dev](#dev) 27 | - [devhttp](#devhttp) 28 | - [Command line options](#command-line-options) 29 | - [Server options](#server-options) 30 | - [Executors](#executors) 31 | - [Lua](#lua) 32 | - [Plugin system](#plugin-system) 33 | - [Libraries](#libraries) 34 | - [Web "framework" library](#web-framework-library) 35 | - [WASM](#wasm) 36 | - [Podman](#podman) 37 | - [Contributing](#contributing) 38 | - [Support](#support) 39 | 40 | 41 | 42 | TLDR: msgscript is what you could call a poor man's Lambda-like function/application server. It can run functions and even some small web based applications. 43 | 44 | ## Features 45 | 46 | - Single binary 47 | - Nearly no overheads 48 | - Good enough performances (RTT of around 10ms for the hello example) 49 | - Runs Lua functions, WASM binaries and Podman containers (no docker) 50 | - Can use reusable libraries in Lua 51 | - Can add Go based plugins in Lua 52 | - HTTP handler 53 | - Script storage either as flat files or in etcd 54 | - Can be scaled up with multiples instances through locking (requires etcd) 55 | 56 | ## How it works 57 | 58 | Let's start with an example script: 59 | ``` lua 60 | --* subject: funcs.hello 61 | --* name: hello 62 | 63 | function OnMessage(subject, payload) 64 | local response = "Processed subject: " .. subject .. " with payload: " .. payload 65 | 66 | return response 67 | end 68 | ``` 69 | 70 | For more technical details, a [flow chart](how_it_works.png) explains what happens when a message is sent and the server sees a match and than executes a script. 71 | 72 | ### Headers 73 | 74 | The headers are formed with the pattern of `--*
: `. There are multiple possible headers: 75 | - `subject`: The subject the script is associated with 76 | - `name`: The name of the script. Multiple scripts can be associated with the same subject 77 | - `http`: Used to return HTML responses 78 | - `require`: Used to load a library script. It comes from the library "repository" of scripts and is prepended to the script that will be executed. 79 | 80 | Each script is a Lua file that gets executed when the server receives a message that matches a pattern. The pattern is defined in the `subject` field. The files also contains a `name` field. Multiple scripts can be associated with the same subject. 81 | 82 | ### The Function 83 | 84 | #### In Normal mode 85 | 86 | Normal mode is the default mode. The server executes the script when the message matches the `subject` and `name` fields. The function called is named `OnMessage()`. That function is fed 2 arguments, which can be named whatever you want. The first is the subject and the second is the payload. The payload is what the originating NATS message contains. 87 | 88 | It's possible to call this mode both through NATS or with the HTTP handler. 89 | 90 | #### In HTTP mode 91 | 92 | Example, for a GET request: 93 | 94 | ``` lua 95 | --* subject: http.hello 96 | --* name: http_get 97 | --* http: true 98 | 99 | function GET(url, body) 100 | return "Hello, " .. body .. "!", 200, { ["Content-Type"] = "text/plain" } 101 | end 102 | ``` 103 | 104 | The function executed will have the same name as the HTTP verb of the originating HTTP request. 105 | 106 | This mode is only available with the HTTP handler. 107 | 108 | #### In HTTP+HTML mode 109 | 110 | If you want to use the HTTP handler and want to return HTML, you can do so by setting the `http` header to `true`. 111 | 112 | Just like in HTTP mode, the function executed will have the same name as the HTTP verb of the originating HTTP request. 113 | 114 | ### Return value 115 | 116 | The function is expected to return a string. If it does not, the server will log a warning: `Script returned no response`. 117 | 118 | In **NATS** mode: it will return the string as-is. 119 | 120 | In **HTTP** mode: it will return the following JSON document: 121 | ``` json 122 | {"calculate":{"http_code":0,"error":"","http_headers":{},"is_html":false,"payload":"SGkhIEknbSBlbmNvZGVkIQo="}} 123 | ``` 124 | Each keys at the root level is named after the `name` field of the script. The value is a table with the following keys: 125 | - `http_code`: The HTTP status code. It defaults to 200 126 | - `error`: If the script returns an error, it will be set here 127 | - `http_headers`: A map of HTTP headers 128 | - `is_html`: Whether the response is HTML 129 | - `payload`: The payload of the message. It is base64 encoded 130 | 131 | In **HTTP+HTML** mode: you can return 3 different values: 132 | - The HTML as a string 133 | - The HTTP code (200 is missing) 134 | - The HTTP headers (empty if missing) 135 | 136 | ## HTTP handler 137 | 138 | If you have a application that cannot reach nats by itself (say a webhook), it's possible to use the included HTTP handler. 139 | 140 | The server listens to port 7643 by default (it can be changed through the command line). You can push messages by doing a POST request to `http://serverIP:7643/` where the `` is any subjects that you have scripts registered to it. 141 | 142 | Example using curl (if you are running locally and for the subject of the example above): 143 | 144 | ``` 145 | curl -X POST -d 'John' http://127.0.0.1:7643/http.hello 146 | ``` 147 | 148 | ## Installation 149 | 150 | ### Docker container 151 | 152 | A container is avaible through the ghcr.io container registry. You can use it as so: 153 | 154 | ``` sh 155 | docker pull ghcr.io/numkem/msgscript:latest 156 | ``` 157 | 158 | It takes either `server` or `cli` as a first parameter. 159 | 160 | ### NixOS service 161 | 162 | You can enable it in your NixOS configuration using the provided module (once included from either the flake or importing the module using something like `niv` or manually): 163 | 164 | ```nix 165 | services.msgscript.enable = true; 166 | ``` 167 | 168 | The options are defined in the [nix/modules/default.nix](nix/modules/default.nix) file. 169 | 170 | ### Building it 171 | 172 | Being a standalone Go binary, you can build each of the binaries like so: 173 | ```sh 174 | # Clone the msgscript repository 175 | git clone https://github.com/numkem/msgscript.git 176 | cd msgscript 177 | go build ./cmd/server # Generates the server binary 178 | go build ./cmd/cli # Generates the CLI binary 179 | ``` 180 | 181 | It requires the btrfs headers (podman), gpgme (podman) and wastime (wasm) as dependancies. 182 | 183 | ## Clustering 184 | 185 | When msgscript is running in cluster mode, it's possible to use it with etcd. You can use the `etcd` backend to do that. 186 | 187 | The `cli` binary provides some additional commands to manage the scripts stored inside etcd. 188 | 189 | ### Adding Scripts 190 | 191 | You can add Lua scripts to etcd using the `msgscriptcli` command. Here's an example: 192 | 193 | ```sh 194 | msgscriptcli add -subject funcs.pushover -name pushover ./examples/pushover.lua 195 | ``` 196 | 197 | This command adds the `pushover.lua` script from the `examples` directory, associating it with the subject `funcs.pushover` and the name `pushover`. 198 | 199 | The `-subject` and `-name` flags are optional. If they are not provided, they will be read through the headers contained in the file. 200 | 201 | ## Command line flags 202 | 203 | ### Cli options 204 | 205 | The `cli` binary helps with managing the scripts in the message store and for helping with developing scripts. 206 | 207 | #### Commands 208 | 209 | add Add a script to the backend by reading the provided lua file 210 | completion Generate the autocompletion script for the specified shell 211 | dev Executes the script locally like how the server would 212 | devhttp Starts a webserver that will run only to receive request from this script 213 | help Help about any command 214 | lib library related commands 215 | list list all the scripts registered in the store 216 | rm Remove an existing script 217 | 218 | The commands that manages scripts (add, list, rm) are not really useful when using the file base store. 219 | 220 | #### dev 221 | 222 | Useful for developing scripts that aren't http based (webhooks). 223 | 224 | First argument is the script in question. It will return the script's output. 225 | 226 | #### devhttp 227 | 228 | Useful for developing scripts that are http based (webhooks). 229 | 230 | First argument is the script in question. You can then reach your script at `http://localhost:7634//`. The script is reloaded from the store on every HTTP request so you don't have to restart the command each time. 231 | 232 | #### Command line options 233 | 234 | Flags: 235 | -b, --backend string The name of the backend to use to manipulate the scripts (default "etcd") 236 | -e, --etcdurls string Endpoints to connect to etcd (default "localhost:2379") 237 | -x, --executor string Which executor to use. Either lua or wasm (default "lua") 238 | -h, --help help for msgscript 239 | -L, --log string set the logger to this log level (default "info") 240 | -u, --natsurl string NATS url to reach (default "nats://localhost:4222") 241 | 242 | ### Server options 243 | 244 | You can download the binary from the release page. There are 2 binaries available: `server` and `cli`. The server is what most people will want. The `cli` is only useful when paired with the `etcd` backend. 245 | 246 | The server has the following options: 247 | - `-backend`: The backend to use. Currently supports `etcd` or `file`. `file` is the default. 248 | - `-etcdurl`: The URL of the etcd server. It can be multiple through a comma separated list. 249 | - `-library`: The path to a library directory. It has no defaults. It can be an absolute path or a relative path. 250 | - `-log`: The log level to use. The options are: `debug`, `info`, `warn`, `error`. It defaults to `info`. 251 | - `-natsurl`: The URL of the NATS server. 252 | - `-plugin`: The path to the plugin directory. It has no defaults. It can be an absolute path or a relative path. 253 | - `-port`: The port to listen on. It defaults to 7643. 254 | - `-script`: The path to a script directory. It defaults to the current working directory. It can be an absolute path or a relative path. 255 | 256 | ## Executors 257 | 258 | Msgscript supports many different executors or more simply, a way to execute a script. The following shows how to use them. 259 | 260 | ### Lua 261 | 262 | This is the default executor. 263 | 264 | When writing Lua scripts for msgscript, you have access to additional built-in modules: 265 | 266 | - `etcd`: Read/Write/Update/Delete keys in etcd [source](lua/etcd.go) 267 | - `http`: For making HTTP requests [source](https://github.com/cjoudrey/gluahttp) 268 | - `json`: For JSON parsing and generation [source](https://github.com/layeh/gopher-json) 269 | - `lfs`: LuaFilesystem implementation [source](https://layeh.com/gopher-lfs) 270 | - `nats`: For publishing messages back to NATS [source](lua/nats.go) 271 | - `re`: Regular expression library [source](https://github.com/yuin/gluare) 272 | 273 | these can be included using the built-in `require()` Lua function. 274 | 275 | The following example shows how to deserialize a JSON payload: 276 | ``` lua 277 | --* subject: example.json 278 | --* name: json 279 | local json = require("json") 280 | 281 | -- Assuming the payload contains: 282 | -- {"name": "John"} 283 | function OnMessage(_, payload) 284 | local data = json.decode(payload) 285 | 286 | return "Hello, " .. data.name .. "!" 287 | end 288 | ``` 289 | 290 | Some examples scripts are provided in the `examples` folder. 291 | 292 | #### Plugin system 293 | 294 | While there is already a lot of modules added to the Lua execution environment, it is possible to add more using the included plugin system. 295 | 296 | An example [plugin](plugins/hello/main.go) is available. The plugins can be loaded using the `--plugin` flag for both the server and cli. 297 | 298 | Example using the hello plugin: 299 | 300 | ``` lua 301 | --* subject: example.plugins.hello 302 | --* name: hello 303 | local hello = require("hello") 304 | 305 | function OnMessage(_, _) 306 | return hello.print() 307 | end 308 | ``` 309 | 310 | Plugins currently included in this repository: 311 | 312 | * `scrape`: An http parser [source](https://github.com/felipejfc/gluahttpscrape) 313 | * `db`: SQL access to MySQL, SQLite and PostgreSQL [source](https://github.com/tengattack/gluasql) 314 | * `gopher-lua-libs`: Various modules from the [gopher-lua-libs](https://github.com/vadv/gopher-lua-libs): 315 | * `cmd` 316 | * `filepath` 317 | * `inspect` 318 | * `ioutil` 319 | * `runtime` 320 | * `strings` 321 | * `time` 322 | 323 | **NOTE:** The plugin file needs to have the `.so` extension. 324 | 325 | #### Libraries 326 | 327 | Libraries are Lua files that gets prepended to the script that needs to be run. These libraries can be used within other scripts using the `require` header like this: 328 | 329 | ``` lua 330 | --* require: foo 331 | ``` 332 | 333 | In this case, it will load the library named `foo` and prepend it to the running script. 334 | 335 | Some example libraries are available [here](examples/libs). 336 | 337 | #### Web "framework" library 338 | 339 | The [web.lua](examples/libs/web.lua) library contains a very simple web framework. It's used in the example below: 340 | 341 | ``` lua 342 | --* subject: example.libs.web 343 | --* name: web 344 | --* require: web 345 | local json = require("json") 346 | 347 | local router = Router.new() 348 | 349 | router:get("/plain", function(req, _) 350 | return "Hello, World!", {}, 200, { ["Content-Type"] = "text/plain" } 351 | end) 352 | 353 | router:get("/json", function(req, _) 354 | return nil, { name = "John" }, 200 355 | end) 356 | 357 | router:get("/path/", function(req, _) 358 | return [[ 359 |

{{ param }}

360 | ]], { param = req.params.foo }, 200 361 | end) 362 | 363 | router:post("/post", function(req, _) 364 | doc = json.decode(req.body) 365 | return [[ Hello {{ name }}! ]], { name = doc.name }, 200 366 | end) 367 | ``` 368 | 369 | While not extensive, these examples shows how to use the library. 370 | 371 | Namely: 372 | - The function handling the endpoint returns 4 values: the mustache template, the data for the template, the HTTP code and HTTP headers 373 | - If the template is either empty (`""`) or nil, it will be assumed that it's returning a JSON document. The `Content-Type` will be set as such. 374 | 375 | ### WASM 376 | 377 | Binaires compiled targetting WASM can be used. Some examples are provided in the `examples/wasm` directory. 378 | 379 | The format required in the store looks like this: 380 | 381 | ``` 382 | --* subject: funcs.wasm 383 | --* name: wasm 384 | --* executor: wasm 385 | /home/numkem/src/msgscript/examples/wasm/c/c.wasm 386 | ``` 387 | 388 | The import parts are `subject`, `name` which are common with all other executors. The `executor` key needs to be set to `wasm`. The content is the path to the WASM executable. 389 | 390 | ### Podman 391 | 392 | The format requires in the store looks like this: 393 | 394 | ``` 395 | --* subject: funcs.hello 396 | --* name: podman 397 | --* executor: podman 398 | { 399 | "image": "hello-world" 400 | } 401 | ``` 402 | 403 | Like for WASM, it takes the same important keys. The content of the file can be: 404 | 405 | | Key | Description | 406 | |:-------------|:---------------------------------------------------------------------------| 407 | | `image` | Container image name | 408 | | `mounts` | List of mounts in the same format you would write them on the command line | 409 | | `privileged` | true/false if the container should run with more permissions | 410 | 411 | ## Contributing 412 | 413 | Contributions are welcome! Please feel free to submit a Pull Request. 414 | 415 | ## Support 416 | 417 | If you encounter any problems or have any questions, please open an issue on the GitHub repository. 418 | --------------------------------------------------------------------------------