Carry a bundle's seat traffic under its own name and kind, and start a module as its own account (hq ADR 0259)
This commit is contained in:
@@ -40,6 +40,9 @@ func (b *subscribing) State(string, json.RawMessage) (json.RawMessage, error) {
|
||||
func (b *subscribing) Watch(json.RawMessage, func(json.RawMessage) error) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
func (b *subscribing) Seat(string, json.RawMessage, func(string, any) (json.RawMessage, error)) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (b *subscribing) Subscribe(d func(json.RawMessage) error) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
@@ -60,6 +60,10 @@ type Bus interface {
|
||||
Subscribe(deliver func(envelope json.RawMessage) error) error
|
||||
State(verb string, params json.RawMessage) (json.RawMessage, error)
|
||||
Watch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error)
|
||||
// Seat answers a bundle's seat traffic (novox/hq ADR 0259 §3): `publish`, `prove` and `record` at once;
|
||||
// `take` and `answer` bind once and hand each message or proof on through handOn, to whichever child
|
||||
// of the module asked last — a restarted child asks again as its code runs again.
|
||||
Seat(verb string, params json.RawMessage, handOn func(method string, params any) (json.RawMessage, error)) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// EventTimeout bounds how long a bundle has to handle one event before it is offered again.
|
||||
@@ -147,6 +151,17 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
// The child that last subscribed is the one events are handed to: a restarted child subscribes
|
||||
// again as its code is imported, and from then on the module's events are its.
|
||||
var subscriber *child
|
||||
// The child that last asked to take a seat's work or answer its proofs is the one they are handed to.
|
||||
var seatTaker *child
|
||||
handOn := func(method string, params any) (json.RawMessage, error) {
|
||||
mu.Lock()
|
||||
c := seatTaker
|
||||
mu.Unlock()
|
||||
if c == nil {
|
||||
return nil, errors.New(module + "'s bundle is not running to take it")
|
||||
}
|
||||
return c.ask(module, method, params, EventTimeout)
|
||||
}
|
||||
stopped := false
|
||||
deliver := func(envelope json.RawMessage) error {
|
||||
mu.Lock()
|
||||
@@ -166,6 +181,12 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
start := func() (*child, error) {
|
||||
cmd := exec.Command(entry)
|
||||
cmd.Env = env
|
||||
// **A module that names an account of its own runs as it** (novox/hq ADR 0259 §8): its secrets and
|
||||
// state are that account's, and the operator's account — which every agent runs as — reaches
|
||||
// neither. Never root, never the operator's.
|
||||
if err := runAs(cmd, env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -237,7 +258,21 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
subscriber = c
|
||||
mu.Unlock()
|
||||
err = mesh.Subscribe(deliver)
|
||||
case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys":
|
||||
case "mesh/seat.publish", "mesh/seat.prove", "mesh/seat.record":
|
||||
var answered json.RawMessage
|
||||
if answered, err = mesh.Seat(strings.TrimPrefix(m.Method, "mesh/seat."), m.Params, nil); err == nil {
|
||||
result = answered
|
||||
}
|
||||
case "mesh/seat.take", "mesh/seat.answer":
|
||||
mu.Lock()
|
||||
seatTaker = c
|
||||
mu.Unlock()
|
||||
var answered json.RawMessage
|
||||
if answered, err = mesh.Seat(strings.TrimPrefix(m.Method, "mesh/seat."), m.Params, handOn); err == nil {
|
||||
result = answered
|
||||
}
|
||||
case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys",
|
||||
"mesh/state.create", "mesh/state.update":
|
||||
var answered json.RawMessage
|
||||
if answered, err = mesh.State(strings.TrimPrefix(m.Method, "mesh/state."), m.Params); err == nil {
|
||||
result = answered
|
||||
@@ -329,6 +364,9 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
if subscriber == c {
|
||||
subscriber = nil
|
||||
}
|
||||
if seatTaker == c {
|
||||
seatTaker = nil
|
||||
}
|
||||
wasStopped := stopped
|
||||
// Started again at once if it ran a while; after a growing pause while it keeps exiting.
|
||||
wait := RestartFirst
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// RunAs is the word in a bundle's environment naming the account it runs as (novox/hq ADR 0259 §8), and
|
||||
// OperatorAccount the word naming the operator's account on this machine, which the runtime is given.
|
||||
const (
|
||||
RunAs = "MESH_RUN_AS"
|
||||
OperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||
)
|
||||
|
||||
// lookupUser is user.Lookup, a variable so a test can name accounts this machine does not have.
|
||||
var lookupUser = user.Lookup
|
||||
|
||||
func word(env []string, name string) string {
|
||||
for _, kv := range env {
|
||||
if k, v, ok := strings.Cut(kv, "="); ok && k == name {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runAs starts cmd as the account its environment names, with that account's home, or leaves it as the
|
||||
// runtime's own when none is named. It refuses root and the operator's account: a module asks for an
|
||||
// account of its own to keep what it holds from the agents, and every agent runs as the operator.
|
||||
func runAs(cmd *exec.Cmd, env []string) error {
|
||||
name := word(env, RunAs)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if operator := word(env, OperatorAccount); operator != "" && name == operator {
|
||||
return fmt.Errorf("%s names the operator's account %s; a module runs as an account of its own, never "+
|
||||
"the one every agent runs as (novox/hq ADR 0259)", RunAs, name)
|
||||
}
|
||||
u, err := lookupUser(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s names the account %s, which this machine does not have: the module makes it with "+
|
||||
"a user resource (novox/hq ADR 0259): %w", RunAs, name, err)
|
||||
}
|
||||
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the account %s has no usable uid %q", name, u.Uid)
|
||||
}
|
||||
gid, err := strconv.ParseUint(u.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the account %s has no usable gid %q", name, u.Gid)
|
||||
}
|
||||
if uid == 0 || name == "root" {
|
||||
return fmt.Errorf("%s names root; a module that asks for an account of its own is not given root", RunAs)
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}}
|
||||
var kept []string
|
||||
for _, kv := range cmd.Env {
|
||||
if !strings.HasPrefix(kv, "HOME=") && !strings.HasPrefix(kv, "USER=") && !strings.HasPrefix(kv, "LOGNAME=") {
|
||||
kept = append(kept, kv)
|
||||
}
|
||||
}
|
||||
cmd.Env = append(kept, "HOME="+u.HomeDir, "USER="+name, "LOGNAME="+name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §8: a module naming an account of its own runs as it, never as root or the operator.
|
||||
func TestABundleRunsAsTheAccountItNamesAndNeverRootOrTheOperator(t *testing.T) {
|
||||
was := lookupUser
|
||||
t.Cleanup(func() { lookupUser = was })
|
||||
lookupUser = func(name string) (*user.User, error) {
|
||||
switch name {
|
||||
case "telegram":
|
||||
return &user.User{Username: "telegram", Uid: "961", Gid: "961", HomeDir: "/var/lib/telegram"}, nil
|
||||
case "root":
|
||||
return &user.User{Username: "root", Uid: "0", Gid: "0", HomeDir: "/root"}, nil
|
||||
case "toor":
|
||||
return &user.User{Username: "toor", Uid: "0", Gid: "0", HomeDir: "/root"}, nil
|
||||
}
|
||||
return nil, errors.New("unknown user")
|
||||
}
|
||||
cmd := exec.Command("/bin/true")
|
||||
cmd.Env = []string{"HOME=/root", "PATH=/usr/bin"}
|
||||
if err := runAs(cmd, []string{RunAs + "=telegram", OperatorAccount + "=jo"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := cmd.SysProcAttr.Credential; c == nil || c.Uid != 961 || c.Gid != 961 {
|
||||
t.Fatalf("not started as telegram: %+v", cmd.SysProcAttr)
|
||||
}
|
||||
if env := strings.Join(cmd.Env, " "); !strings.Contains(env, "HOME=/var/lib/telegram") || strings.Contains(env, "HOME=/root") {
|
||||
t.Fatalf("the account's home is not its own: %s", env)
|
||||
}
|
||||
for name, want := range map[string]string{"jo": "operator's account", "root": "names root", "toor": "names root",
|
||||
"nobody-here": "does not have"} {
|
||||
err := runAs(exec.Command("/bin/true"), []string{RunAs + "=" + name, OperatorAccount + "=jo"})
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", name, err, want)
|
||||
}
|
||||
}
|
||||
plain := exec.Command("/bin/true")
|
||||
if err := runAs(plain, nil); err != nil || plain.SysProcAttr != nil {
|
||||
t.Error("a bundle naming no account was changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A bundle that asks to take a worker's work and answers each piece it is handed.
|
||||
const takingBundle = `#!/bin/sh
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
|
||||
*'"method":"tools/list"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id"
|
||||
printf '{"jsonrpc":"2.0","id":"s1","method":"mesh/seat.take","params":{"worker":"SEAT_CHANNEL_TELEGRAM_worker"}}\n' ;;
|
||||
*'"method":"mesh/work"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"taken":true}}\n' "$id" ;;
|
||||
esac
|
||||
done
|
||||
`
|
||||
|
||||
type seating struct {
|
||||
subscribing
|
||||
mu sync.Mutex
|
||||
verb string
|
||||
params string
|
||||
handOn func(string, any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (b *seating) Seat(verb string, params json.RawMessage, handOn func(string, any) (json.RawMessage, error)) (json.RawMessage, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.verb, b.params, b.handOn = verb, string(params), handOn
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §3: a bundle's seat traffic reaches the runtime's bus from its own channel, and the work
|
||||
// it takes is handed back to it.
|
||||
func TestABundleTakesASeatsWorkThroughItsOwnChannel(t *testing.T) {
|
||||
entry := filepath.Join(t.TempDir(), "bundle")
|
||||
if err := os.WriteFile(entry, []byte(takingBundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &seating{}
|
||||
l, err := Start("telegram", entry, os.Environ(), b, t.Logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(l.Stop)
|
||||
var handOn func(string, any) (json.RawMessage, error)
|
||||
for i := 0; handOn == nil; i++ {
|
||||
if i > 100 {
|
||||
t.Fatal("the bundle never asked to take its work")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
b.mu.Lock()
|
||||
handOn = b.handOn
|
||||
b.mu.Unlock()
|
||||
}
|
||||
if b.verb != "take" || b.params != `{"worker":"SEAT_CHANNEL_TELEGRAM_worker"}` {
|
||||
t.Fatalf("asked %s %s", b.verb, b.params)
|
||||
}
|
||||
got, err := handOn("mesh/work", map[string]any{"work": map[string]any{"subject": "mesh.seat.channel.accept.show.telegram"}})
|
||||
if err != nil || string(got) != `{"taken":true}` {
|
||||
t.Fatalf("the work was not handed to the bundle: %s %v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user