openvibers/openvibe.link Commits
Run linux-class jobs as declared commands in the worker sandbox, like code entries (#32)
Changes
@@@ -258,7 +258,7 @@ checked in this order:| `invalid inputs` | `bad_value` | an entry of `inputs` outside the contract's patterns, a repeated name, or more than 32 || `job inputs not supported` | `unsupported` | the job carries `inputs`: this Node cannot stage and check them yet, so it never runs it with them dropped || `missing or invalid args, ttl_ms or limits` | `bad_value` | `args` is not an object, or `ttl_ms` or a limit is missing or below 1 |--| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`; every class while the worker is off) |++| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`, `code` and `linux`; every class while the worker is off) || `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set: no job starts until it clears || `class not available` | `shutting_down` | the Node is stopping || `unknown artifact` | `unsupported` | `artifact` is not a function the owner declared, at that exact version |@@@ -303,7 +303,7 @@ worker caps in `status.capabilities.worker`:`capabilities` holds one `worker:<class>` name per entry in `runtime_classes` (the `platform.resource-offer@1`capabilities namespace OpenVibe.Run routes on; the names are reserved in OpenVibe.Contracts and no schema changewas needed), so only classes this Node actually runs are advertised: `worker:function` when a `function` entry is declared,--`worker:code` when a `code` entry is (`"runtime_classes":["function","code"]`, in that order, with both). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off or++`worker:code` when a `code` entry is, and `worker:linux` when a `linux` entry is (`"runtime_classes":["function","code","linux"]`, in that order, with all three). A Node's `linux` class runs one declared Linux command per job, single-shot like `code`: it is not the persistent environment that lives across commands which `platform.runtime-class@1` also lets `linux` mean, so a linux job without an `artifact` naming a declared entry is refused (`unknown artifact`). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off ordeclares no `functions` entry (a probe that passes with none advertises nothing, so Fabric places no job this Node wouldrefuse), and `worker` is reserved as a plugin name.@@@ -314,7 +314,7 @@ this mapping:| Node descriptor field | Fabric offer | Example || --- | --- | --- |--| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code |++| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code, worker:linux || capabilities.worker.max_jobs | runtime-offer.limits.concurrency | 1 || capabilities.worker.max_ttl_ms | runtime-offer.limits.duration_seconds | 600 || device node_id / cell | resource-offer.node_id / resource-offer.cell | node_01H… / wnam-1 |@@@ -1,9 +1,13 @@--# The job worker (`function` and `code` jobs)++# The job worker (`function`, `code` and `linux` jobs)--The Node can run **function and code jobs** (OpenVibe.Contracts `platform.job@1`, class `function` or `code`) sent over--the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact, and the Node runs it--only if the owner declared that artifact, at that exact version and for that class, in the local config. Nothing is downloaded and no path comes from--the server. The worker is **off by default**; off, the Node refuses every job `class not available`, as before.++The Node can run **function, code and linux jobs** (OpenVibe.Contracts `platform.job@1`, class `function`, `code` or++`linux`) sent over the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact,++and the Node runs it only if the owner declared that artifact, at that exact version and for that class, in the local++config. Nothing is downloaded and no path comes from the server. The worker is **off by default**; off, the Node refuses++every job `class not available`, as before. A `linux` entry is an arbitrary declared Linux command, not a language++runtime: it runs through the same isolate path as a `code` entry, with the same sandbox, limits and net policy.++It is single-shot like a `code` job: the Node does not offer the persistent Linux environment that lives across++commands, which `platform.runtime-class@1` also lets `linux` describe.## Config@@@ -46,10 +50,11 @@ the server. The worker is **off by default**; off, the Node refuses every job `c- `functions`: `name` and `version` match a job's `artifact` exactly (the contract's patterns; listed once per class);`command[0]` is an absolute path. `env` is added to the job's environment. `class` is the runtime class the entry-- implements: `function` (the default) or `code`; `browser`, `linux`, `desktop` and `gpu` are refused at load. A job-- runs only an entry of its own class: a `code` job naming a `function` entry is an `unknown artifact`, and the-- reverse. A `code` entry is a local declared runtime like a function (nothing is fetched); its job gets exactly the-- same sandbox, limits and checks as a function job, never weaker. `artifact_dir` (default: the directory++ implements: `function` (the default), `code` or `linux`; `browser`, `desktop` and `gpu` are refused at load. A job++ runs only an entry of its own class: a `code` or `linux` job naming a `function` entry is an `unknown artifact`, and++ the reverse. A `code` or `linux` entry is a local declared command like a function (nothing is fetched), and a `linux`++ one is an arbitrary Linux command, not a language runtime; its job gets exactly the same sandbox, limits and checks as++ a function job, never weaker. `artifact_dir` (default: the directoryof `command[0]`) is the one host directory a job gets besides the system paths, as a **copy** made as the jobstarts, read-only, at the same path: nothing the host adds to it or changes in it later reaches the job (a Unixsocket or a FIFO that appears there, say, through which a job could reach a host process). The Node opens the@@@ -120,8 +125,9 @@ the server. The worker is **off by default**; off, the Node refuses every job `cand no error; it is sent only to `endpoint`'s own host. `max_bytes` caps each input and each uploaded result.Off, a job naming `inputs` is refused and a result over 256 KiB ends the job `limit`, as before.--The classes `function` and `code` — advertised as the reserved Fabric capabilities `worker:function` and `worker:code` in--`status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run routes on) — are each offered only when `enabled` is true, the OS is Linux++The classes `function`, `code` and `linux` — advertised as the reserved Fabric capabilities `worker:function`,++`worker:code` and `worker:linux` in `status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run++routes on) — are each offered only when `enabled` is true, the OS is Linux(amd64 or arm64: the architectures with a seccomp allowlist), at least one `functions` entry of that class is declared and a **boot-time probe** passes: it starts `/bin/sh`the way a job is started and checks every control from the host: the sandbox was set up; the process has user,mount, network, PID, IPC, UTS and cgroup namespaces of its own, `NoNewPrivs` and a seccomp filter; nothing in its@@@ -292,7 +298,7 @@ Refused with `nack` (keyed by the job id; the first matching row wins, after the| `message` | `fault_code` | when ||---------------------------------------|----------------------------|---------------------------------------------------------|--| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function` or `code`) |++| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function`, `code` or `linux`) || `job inputs not supported` | `unsupported` | the job carries `inputs` and `media` is off: this Node never runs a job with them silently dropped || `net policy not supported` | `unsupported` | the job names a `net` this host cannot enforce (`public` or `openvibe-only` while `egress` is `none`) || `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set |@@@ -109,7 +109,7 @@ type Config struct {// Python is the interpreter for plugins without an explicit command (default: <state>/venv/bin/python, then python3).Python string `json:"python,omitempty"`LogLevel string `json:"log_level,omitempty"`-- // Worker runs `function` and `code` jobs from the control link (docs/worker.md). Off unless enabled.++ // Worker runs `function`, `code` and `linux` jobs from the control link (docs/worker.md). Off unless enabled.Worker WorkerConfig `json:"worker,omitzero"`}@@@ -209,8 +209,9 @@ const (type FunctionConfig struct {Name string `json:"name"`Version string `json:"version"`-- // Class is the runtime class this entry implements: function (the default) or code. A job runs it only when its-- // class matches; browser, linux, desktop and gpu are refused at load.++ // Class is the runtime class this entry implements: function (the default), code or linux. A job runs it only when++ // its class matches; browser, desktop and gpu are refused at load. A linux entry is an arbitrary declared Linux++ // command, not a language runtime.Class string `json:"class,omitempty"`Command []string `json:"command"` // Command[0] is an absolute pathEnv map[string]string `json:"env,omitempty"`@@@ -223,6 +224,7 @@ type FunctionConfig struct {const (ClassFunction = "function"ClassCode = "code"++ ClassLinux = "linux")// EffectiveClass is the entry's runtime class, function when none is set.@@@ -439,8 +441,8 @@ func (w WorkerConfig) validate() error {if !functionNameRe.MatchString(f.Name) || !functionVersionRe.MatchString(f.Version) {return fmt.Errorf("config: worker function %q version %q: name or version is not a valid artifact name or exact version", f.Name, f.Version)}-- if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode {-- return fmt.Errorf("config: worker function %s@%s has class %q: only function or code are implemented", f.Name, f.Version, f.Class)++ if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode && c != ClassLinux {++ return fmt.Errorf("config: worker function %s@%s has class %q: only function, code or linux are implemented", f.Name, f.Version, f.Class)}key := f.Name + "@" + f.Version + "@" + f.EffectiveClass()if seen[key] {@@@ -87,8 +87,8 @@ func TestWorkerNameReserved(t *testing.T) {}}--// TestWorkerEntryClass: an entry is a function unless it says code; the other runtime classes are refused at load,--// and one name@version may be declared once per class.++// TestWorkerEntryClass: an entry is a function unless it says code or linux; browser, desktop and gpu are refused at++// load, and one name@version may be declared once per class.func TestWorkerEntryClass(t *testing.T) {run := filepath.Join(t.TempDir(), "run") // absolute on every platform: Validate wants an absolute commandentry := func(class string) FunctionConfig {@@@ -97,14 +97,14 @@ func TestWorkerEntryClass(t *testing.T) {if c := entry("").EffectiveClass(); c != ClassFunction {t.Fatalf("no class is %q", c)}-- for _, class := range []string{"", "function", "code"} {++ for _, class := range []string{"", "function", "code", "linux"} {c := Default()c.Worker.Functions = []FunctionConfig{entry(class)}if err := c.Validate(); err != nil {t.Fatalf("class %q: %v", class, err)}}-- for _, class := range []string{"browser", "linux", "desktop", "gpu", "wasm", "Code"} {++ for _, class := range []string{"browser", "desktop", "gpu", "wasm", "Code"} {c := Default()c.Worker.Functions = []FunctionConfig{entry(class)}if err := c.Validate(); err == nil {@@@ -1220,6 +1220,22 @@ func TestShortLivedJob(t *testing.T) {}}++// TestRunLinuxClass: a declared linux entry runs an arbitrary command through the same isolate path as a code entry:++// the process starts once, its result reaches job_exit and its exit code is 0.++func TestRunLinuxClass(t *testing.T) {++ cfg := helperConfig(t, config.WorkerCaps{}, "quick")++ cfg.Functions[0].Class = config.ClassLinux++ w, s := newRunWorkerConfig(t, cfg)++ j := testJob(1, "quick")++ j.Class = protocol.ClassLinux++ run(t, w, j)++ ex := s.exit(t, j.ID)++ if ex.Reason != protocol.ExitExited || ex.Code == nil || *ex.Code != 0 || string(ex.Result) != `{"ok":true}` ||++ s.count(protocol.TypeJobStarted, j.ID) != 1 {++ t.Fatalf("%+v %s", ex, ex.Result)++ }++}++// TestForkBomb: pids.max and RLIMIT_NPROC stop a job that starts processes without end; the Node is untouched and the// job ends when its first process exits, taking the others with it.func TestForkBomb(t *testing.T) {@@@ -1,11 +1,11 @@--// Package worker runs `function` jobs (OpenVibe.Contracts platform.job@1) from the control link. It runs only the--// functions the owner declared in the local config, each job as one child process in a sandbox: its own process group,--// user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root holding only--// the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits, no_new_privs and a--// seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its limits, on--// job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox cannot be--// set up fails without running, and a Node whose boot-time Probe fails does not advertise the class. docs/worker.md--// has the model and its limits.++// Package worker runs `function`, `code` and `linux` jobs (OpenVibe.Contracts platform.job@1) from the control link. It++// runs only the functions the owner declared in the local config, each job as one child process in a sandbox: its own++// process group, user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root++// holding only the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits,++// no_new_privs and a seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its++// limits, on job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox++// cannot be set up fails without running, and a Node whose boot-time Probe fails does not advertise the class.++// docs/worker.md has the model and its limits.package workerimport (@@@ -136,7 +136,7 @@ func (w *Worker) Admit(j protocol.Job) (fault, reason string) {if w.closed {return protocol.FaultShuttingDown, protocol.JobNotAvailable}-- if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode {++ if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode && j.Class != protocol.ClassLinux {return protocol.FaultUnsupported, protocol.JobNotAvailable}// inputs are fetched from worker.media and checked against their digests before the job starts (platform.job@1):@@@ -212,6 +212,36 @@ func TestAdmitByClass(t *testing.T) {w.Close()}++// TestAdmitLinux: a declared linux entry is a declared-command class like code — its job is admitted and matched by++// name, version and class — while browser, desktop and gpu jobs stay refused class not available.++func TestAdmitLinux(t *testing.T) {++ cfg := helperConfig(t, config.WorkerCaps{MaxJobs: 8}, "sleep")++ tool := cfg.Functions[0]++ tool.Name, tool.Class = "tool", config.ClassLinux++ cfg.Functions = append([]config.FunctionConfig{tool}, cfg.Functions...)++ w := New(cfg, (&sink{}).send, quiet())++ if c := w.Classes(); !slices.Equal(c, []string{protocol.ClassFunction, protocol.ClassLinux}) {++ t.Fatalf("classes %v", c)++ }++ job := func(n int, class, name string) protocol.Job {++ j := testJob(n, name)++ j.Class = class++ return j++ }++ if f, r := w.Admit(job(1, protocol.ClassLinux, "tool")); f != "" {++ t.Fatalf("a declared linux entry: %s %s", f, r)++ }++ if f, r := w.Admit(job(2, protocol.ClassLinux, "sleep")); f != protocol.FaultUnsupported || r != protocol.JobUnknownArtifact {++ t.Fatalf("a linux job naming a function entry: %q %q", f, r)++ }++ for i, class := range []string{protocol.ClassBrowser, protocol.ClassDesktop, protocol.ClassGPU} {++ if f, r := w.Admit(job(3+i, class, "tool")); f != protocol.FaultUnsupported || r != protocol.JobNotAvailable {++ t.Errorf("%s: %q %q, want %q %q", class, f, r, protocol.FaultUnsupported, protocol.JobNotAvailable)++ }++ }++ w.Close()++}++// TestIsolationUnavailableRefused: when the namespaces cannot be created the probe fails (the class is not// advertised), and a job whose isolation fails ends `failed` without its process ever starting.func TestIsolationUnavailableRefused(t *testing.T) {
@@ -258,7 +258,7 @@ checked in this order:
@@ -258,7 +258,7 @@ checked in this order:
| `invalid inputs` | `bad_value` | an entry of `inputs` outside the contract's patterns, a repeated name, or more than 32 |
| `invalid inputs` | `bad_value` | an entry of `inputs` outside the contract's patterns, a repeated name, or more than 32 |
| `job inputs not supported` | `unsupported` | the job carries `inputs`: this Node cannot stage and check them yet, so it never runs it with them dropped |
| `job inputs not supported` | `unsupported` | the job carries `inputs`: this Node cannot stage and check them yet, so it never runs it with them dropped |
| `missing or invalid args, ttl_ms or limits` | `bad_value` | `args` is not an object, or `ttl_ms` or a limit is missing or below 1 |
| `missing or invalid args, ttl_ms or limits` | `bad_value` | `args` is not an object, or `ttl_ms` or a limit is missing or below 1 |
-| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`; every class while the worker is off) |
+| `class not available` | `unsupported` | the class is valid but this Node does not run it (every class but `function`, `code` and `linux`; every class while the worker is off) |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set: no job starts until it clears |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set: no job starts until it clears |
| `class not available` | `shutting_down` | the Node is stopping |
| `class not available` | `shutting_down` | the Node is stopping |
| `unknown artifact` | `unsupported` | `artifact` is not a function the owner declared, at that exact version |
| `unknown artifact` | `unsupported` | `artifact` is not a function the owner declared, at that exact version |
@@ -303,7 +303,7 @@ worker caps in `status.capabilities.worker`:
@@ -303,7 +303,7 @@ worker caps in `status.capabilities.worker`:
`capabilities` holds one `worker:<class>` name per entry in `runtime_classes` (the `platform.resource-offer@1`
`capabilities` holds one `worker:<class>` name per entry in `runtime_classes` (the `platform.resource-offer@1`
capabilities namespace OpenVibe.Run routes on; the names are reserved in OpenVibe.Contracts and no schema change
capabilities namespace OpenVibe.Run routes on; the names are reserved in OpenVibe.Contracts and no schema change
was needed), so only classes this Node actually runs are advertised: `worker:function` when a `function` entry is declared,
was needed), so only classes this Node actually runs are advertised: `worker:function` when a `function` entry is declared,
-`worker:code` when a `code` entry is (`"runtime_classes":["function","code"]`, in that order, with both). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off or
+`worker:code` when a `code` entry is, and `worker:linux` when a `linux` entry is (`"runtime_classes":["function","code","linux"]`, in that order, with all three). A Node's `linux` class runs one declared Linux command per job, single-shot like `code`: it is not the persistent environment that lives across commands which `platform.runtime-class@1` also lets `linux` mean, so a linux job without an `artifact` naming a declared entry is refused (`unknown artifact`). The limits are `worker.caps` with defaults applied; the key is absent while the worker is off or
declares no `functions` entry (a probe that passes with none advertises nothing, so Fabric places no job this Node would
declares no `functions` entry (a probe that passes with none advertises nothing, so Fabric places no job this Node would
refuse), and `worker` is reserved as a plugin name.
refuse), and `worker` is reserved as a plugin name.
@@ -314,7 +314,7 @@ this mapping:
@@ -314,7 +314,7 @@ this mapping:
| Node descriptor field | Fabric offer | Example |
| Node descriptor field | Fabric offer | Example |
| --- | --- | --- |
| --- | --- | --- |
-| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code |
+| capabilities.worker.runtime_classes | resource-offer.capabilities item `worker:<class>` | worker:function, worker:code, worker:linux |
| capabilities.worker.max_jobs | runtime-offer.limits.concurrency | 1 |
| capabilities.worker.max_jobs | runtime-offer.limits.concurrency | 1 |
| capabilities.worker.max_ttl_ms | runtime-offer.limits.duration_seconds | 600 |
| capabilities.worker.max_ttl_ms | runtime-offer.limits.duration_seconds | 600 |
| device node_id / cell | resource-offer.node_id / resource-offer.cell | node_01H… / wnam-1 |
| device node_id / cell | resource-offer.node_id / resource-offer.cell | node_01H… / wnam-1 |
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
-# The job worker (`function` and `code` jobs)
+# The job worker (`function`, `code` and `linux` jobs)
-The Node can run **function and code jobs** (OpenVibe.Contracts `platform.job@1`, class `function` or `code`) sent over
-the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact, and the Node runs it
-only if the owner declared that artifact, at that exact version and for that class, in the local config. Nothing is downloaded and no path comes from
-the server. The worker is **off by default**; off, the Node refuses every job `class not available`, as before.
+The Node can run **function, code and linux jobs** (OpenVibe.Contracts `platform.job@1`, class `function`, `code` or
+`linux`) sent over the control link ([protocol.md](protocol.md#jobs)). A job is untrusted input: it names an artifact,
+and the Node runs it only if the owner declared that artifact, at that exact version and for that class, in the local
+config. Nothing is downloaded and no path comes from the server. The worker is **off by default**; off, the Node refuses
+every job `class not available`, as before. A `linux` entry is an arbitrary declared Linux command, not a language
+runtime: it runs through the same isolate path as a `code` entry, with the same sandbox, limits and net policy.
+It is single-shot like a `code` job: the Node does not offer the persistent Linux environment that lives across
+commands, which `platform.runtime-class@1` also lets `linux` describe.
## Config
## Config
@@ -46,10 +50,11 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
@@ -46,10 +50,11 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
- `functions`: `name` and `version` match a job's `artifact` exactly (the contract's patterns; listed once per class);
- `functions`: `name` and `version` match a job's `artifact` exactly (the contract's patterns; listed once per class);
`command[0]` is an absolute path. `env` is added to the job's environment. `class` is the runtime class the entry
`command[0]` is an absolute path. `env` is added to the job's environment. `class` is the runtime class the entry
- implements: `function` (the default) or `code`; `browser`, `linux`, `desktop` and `gpu` are refused at load. A job
- runs only an entry of its own class: a `code` job naming a `function` entry is an `unknown artifact`, and the
- reverse. A `code` entry is a local declared runtime like a function (nothing is fetched); its job gets exactly the
- same sandbox, limits and checks as a function job, never weaker. `artifact_dir` (default: the directory
+ implements: `function` (the default), `code` or `linux`; `browser`, `desktop` and `gpu` are refused at load. A job
+ runs only an entry of its own class: a `code` or `linux` job naming a `function` entry is an `unknown artifact`, and
+ the reverse. A `code` or `linux` entry is a local declared command like a function (nothing is fetched), and a `linux`
+ one is an arbitrary Linux command, not a language runtime; its job gets exactly the same sandbox, limits and checks as
+ a function job, never weaker. `artifact_dir` (default: the directory
of `command[0]`) is the one host directory a job gets besides the system paths, as a **copy** made as the job
of `command[0]`) is the one host directory a job gets besides the system paths, as a **copy** made as the job
starts, read-only, at the same path: nothing the host adds to it or changes in it later reaches the job (a Unix
starts, read-only, at the same path: nothing the host adds to it or changes in it later reaches the job (a Unix
socket or a FIFO that appears there, say, through which a job could reach a host process). The Node opens the
socket or a FIFO that appears there, say, through which a job could reach a host process). The Node opens the
@@ -120,8 +125,9 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
@@ -120,8 +125,9 @@ the server. The worker is **off by default**; off, the Node refuses every job `c
and no error; it is sent only to `endpoint`'s own host. `max_bytes` caps each input and each uploaded result.
and no error; it is sent only to `endpoint`'s own host. `max_bytes` caps each input and each uploaded result.
Off, a job naming `inputs` is refused and a result over 256 KiB ends the job `limit`, as before.
Off, a job naming `inputs` is refused and a result over 256 KiB ends the job `limit`, as before.
-The classes `function` and `code` — advertised as the reserved Fabric capabilities `worker:function` and `worker:code` in
-`status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run routes on) — are each offered only when `enabled` is true, the OS is Linux
+The classes `function`, `code` and `linux` — advertised as the reserved Fabric capabilities `worker:function`,
+`worker:code` and `worker:linux` in `status.capabilities.worker` (the `platform.resource-offer@1` namespace OpenVibe.Run
+routes on) — are each offered only when `enabled` is true, the OS is Linux
(amd64 or arm64: the architectures with a seccomp allowlist), at least one `functions` entry of that class is declared and a **boot-time probe** passes: it starts `/bin/sh`
(amd64 or arm64: the architectures with a seccomp allowlist), at least one `functions` entry of that class is declared and a **boot-time probe** passes: it starts `/bin/sh`
the way a job is started and checks every control from the host: the sandbox was set up; the process has user,
the way a job is started and checks every control from the host: the sandbox was set up; the process has user,
mount, network, PID, IPC, UTS and cgroup namespaces of its own, `NoNewPrivs` and a seccomp filter; nothing in its
mount, network, PID, IPC, UTS and cgroup namespaces of its own, `NoNewPrivs` and a seccomp filter; nothing in its
@@ -292,7 +298,7 @@ Refused with `nack` (keyed by the job id; the first matching row wins, after the
@@ -292,7 +298,7 @@ Refused with `nack` (keyed by the job id; the first matching row wins, after the
| `message` | `fault_code` | when |
| `message` | `fault_code` | when |
|---------------------------------------|----------------------------|---------------------------------------------------------|
|---------------------------------------|----------------------------|---------------------------------------------------------|
-| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function` or `code`) |
+| `class not available` | `unsupported` | the worker is off (disabled, not Linux, probe failed), or no declared entry has the job's class (`function`, `code` or `linux`) |
| `job inputs not supported` | `unsupported` | the job carries `inputs` and `media` is off: this Node never runs a job with them silently dropped |
| `job inputs not supported` | `unsupported` | the job carries `inputs` and `media` is off: this Node never runs a job with them silently dropped |
| `net policy not supported` | `unsupported` | the job names a `net` this host cannot enforce (`public` or `openvibe-only` while `egress` is `none`) |
| `net policy not supported` | `unsupported` | the job names a `net` this host cannot enforce (`public` or `openvibe-only` while `egress` is `none`) |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set |
| `the e-stop or local stop is latched` | `estopped` or `local_stop` | the stop latch is set |
@@ -109,7 +109,7 @@ type Config struct {
@@ -109,7 +109,7 @@ type Config struct {
// Python is the interpreter for plugins without an explicit command (default: <state>/venv/bin/python, then python3).
// Python is the interpreter for plugins without an explicit command (default: <state>/venv/bin/python, then python3).
Python string `json:"python,omitempty"`
Python string `json:"python,omitempty"`
LogLevel string `json:"log_level,omitempty"`
LogLevel string `json:"log_level,omitempty"`
- // Worker runs `function` and `code` jobs from the control link (docs/worker.md). Off unless enabled.
+ // Worker runs `function`, `code` and `linux` jobs from the control link (docs/worker.md). Off unless enabled.
Worker WorkerConfig `json:"worker,omitzero"`
Worker WorkerConfig `json:"worker,omitzero"`
}
}
@@ -209,8 +209,9 @@ const (
@@ -209,8 +209,9 @@ const (
type FunctionConfig struct {
type FunctionConfig struct {
Name string `json:"name"`
Name string `json:"name"`
Version string `json:"version"`
Version string `json:"version"`
- // Class is the runtime class this entry implements: function (the default) or code. A job runs it only when its
- // class matches; browser, linux, desktop and gpu are refused at load.
+ // Class is the runtime class this entry implements: function (the default), code or linux. A job runs it only when
+ // its class matches; browser, desktop and gpu are refused at load. A linux entry is an arbitrary declared Linux
+ // command, not a language runtime.
Class string `json:"class,omitempty"`
Class string `json:"class,omitempty"`
Command []string `json:"command"` // Command[0] is an absolute path
Command []string `json:"command"` // Command[0] is an absolute path
Env map[string]string `json:"env,omitempty"`
Env map[string]string `json:"env,omitempty"`
@@ -223,6 +224,7 @@ type FunctionConfig struct {
@@ -223,6 +224,7 @@ type FunctionConfig struct {
const (
const (
ClassFunction = "function"
ClassFunction = "function"
ClassCode = "code"
ClassCode = "code"
+ ClassLinux = "linux"
)
)
// EffectiveClass is the entry's runtime class, function when none is set.
// EffectiveClass is the entry's runtime class, function when none is set.
@@ -439,8 +441,8 @@ func (w WorkerConfig) validate() error {
@@ -439,8 +441,8 @@ func (w WorkerConfig) validate() error {
if !functionNameRe.MatchString(f.Name) || !functionVersionRe.MatchString(f.Version) {
if !functionNameRe.MatchString(f.Name) || !functionVersionRe.MatchString(f.Version) {
return fmt.Errorf("config: worker function %q version %q: name or version is not a valid artifact name or exact version", f.Name, f.Version)
return fmt.Errorf("config: worker function %q version %q: name or version is not a valid artifact name or exact version", f.Name, f.Version)
}
}
- if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode {
- return fmt.Errorf("config: worker function %s@%s has class %q: only function or code are implemented", f.Name, f.Version, f.Class)
+ if c := f.EffectiveClass(); c != ClassFunction && c != ClassCode && c != ClassLinux {
+ return fmt.Errorf("config: worker function %s@%s has class %q: only function, code or linux are implemented", f.Name, f.Version, f.Class)
}
}
key := f.Name + "@" + f.Version + "@" + f.EffectiveClass()
key := f.Name + "@" + f.Version + "@" + f.EffectiveClass()
if seen[key] {
if seen[key] {
@@ -87,8 +87,8 @@ func TestWorkerNameReserved(t *testing.T) {
@@ -87,8 +87,8 @@ func TestWorkerNameReserved(t *testing.T) {
}
}
}
}
-// TestWorkerEntryClass: an entry is a function unless it says code; the other runtime classes are refused at load,
-// and one name@version may be declared once per class.
+// TestWorkerEntryClass: an entry is a function unless it says code or linux; browser, desktop and gpu are refused at
+// load, and one name@version may be declared once per class.
func TestWorkerEntryClass(t *testing.T) {
func TestWorkerEntryClass(t *testing.T) {
run := filepath.Join(t.TempDir(), "run") // absolute on every platform: Validate wants an absolute command
run := filepath.Join(t.TempDir(), "run") // absolute on every platform: Validate wants an absolute command
entry := func(class string) FunctionConfig {
entry := func(class string) FunctionConfig {
@@ -97,14 +97,14 @@ func TestWorkerEntryClass(t *testing.T) {
@@ -97,14 +97,14 @@ func TestWorkerEntryClass(t *testing.T) {
if c := entry("").EffectiveClass(); c != ClassFunction {
if c := entry("").EffectiveClass(); c != ClassFunction {
t.Fatalf("no class is %q", c)
t.Fatalf("no class is %q", c)
}
}
- for _, class := range []string{"", "function", "code"} {
+ for _, class := range []string{"", "function", "code", "linux"} {
c := Default()
c := Default()
c.Worker.Functions = []FunctionConfig{entry(class)}
c.Worker.Functions = []FunctionConfig{entry(class)}
if err := c.Validate(); err != nil {
if err := c.Validate(); err != nil {
t.Fatalf("class %q: %v", class, err)
t.Fatalf("class %q: %v", class, err)
}
}
}
}
- for _, class := range []string{"browser", "linux", "desktop", "gpu", "wasm", "Code"} {
+ for _, class := range []string{"browser", "desktop", "gpu", "wasm", "Code"} {
c := Default()
c := Default()
c.Worker.Functions = []FunctionConfig{entry(class)}
c.Worker.Functions = []FunctionConfig{entry(class)}
if err := c.Validate(); err == nil {
if err := c.Validate(); err == nil {
@@ -1220,6 +1220,22 @@ func TestShortLivedJob(t *testing.T) {
@@ -1220,6 +1220,22 @@ func TestShortLivedJob(t *testing.T) {
}
}
}
}
+// TestRunLinuxClass: a declared linux entry runs an arbitrary command through the same isolate path as a code entry:
+// the process starts once, its result reaches job_exit and its exit code is 0.
+func TestRunLinuxClass(t *testing.T) {
+ cfg := helperConfig(t, config.WorkerCaps{}, "quick")
+ cfg.Functions[0].Class = config.ClassLinux
+ w, s := newRunWorkerConfig(t, cfg)
+ j := testJob(1, "quick")
+ j.Class = protocol.ClassLinux
+ run(t, w, j)
+ ex := s.exit(t, j.ID)
+ if ex.Reason != protocol.ExitExited || ex.Code == nil || *ex.Code != 0 || string(ex.Result) != `{"ok":true}` ||
+ s.count(protocol.TypeJobStarted, j.ID) != 1 {
+ t.Fatalf("%+v %s", ex, ex.Result)
+ }
+}
+
// TestForkBomb: pids.max and RLIMIT_NPROC stop a job that starts processes without end; the Node is untouched and the
// TestForkBomb: pids.max and RLIMIT_NPROC stop a job that starts processes without end; the Node is untouched and the
// job ends when its first process exits, taking the others with it.
// job ends when its first process exits, taking the others with it.
func TestForkBomb(t *testing.T) {
func TestForkBomb(t *testing.T) {
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
-// Package worker runs `function` jobs (OpenVibe.Contracts platform.job@1) from the control link. It runs only the
-// functions the owner declared in the local config, each job as one child process in a sandbox: its own process group,
-// user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root holding only
-// the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits, no_new_privs and a
-// seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its limits, on
-// job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox cannot be
-// set up fails without running, and a Node whose boot-time Probe fails does not advertise the class. docs/worker.md
-// has the model and its limits.
+// Package worker runs `function`, `code` and `linux` jobs (OpenVibe.Contracts platform.job@1) from the control link. It
+// runs only the functions the owner declared in the local config, each job as one child process in a sandbox: its own
+// process group, user, mount, network (no network at all), PID, IPC, UTS and cgroup namespaces, a private read-only root
+// holding only the system paths and the function's artifact, its own cgroup v2 (cpu, memory, pids, io), rlimits,
+// no_new_privs and a seccomp allowlist, with a scrubbed environment and a fresh /tmp. It kills a job at its ttl, at its
+// limits, on job_cancel and while the stop latch is set. It never runs a job with less isolation: a job whose sandbox
+// cannot be set up fails without running, and a Node whose boot-time Probe fails does not advertise the class.
+// docs/worker.md has the model and its limits.
package worker
package worker
import (
import (
@@ -136,7 +136,7 @@ func (w *Worker) Admit(j protocol.Job) (fault, reason string) {
@@ -136,7 +136,7 @@ func (w *Worker) Admit(j protocol.Job) (fault, reason string) {
if w.closed {
if w.closed {
return protocol.FaultShuttingDown, protocol.JobNotAvailable
return protocol.FaultShuttingDown, protocol.JobNotAvailable
}
}
- if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode {
+ if j.Class != protocol.ClassFunction && j.Class != protocol.ClassCode && j.Class != protocol.ClassLinux {
return protocol.FaultUnsupported, protocol.JobNotAvailable
return protocol.FaultUnsupported, protocol.JobNotAvailable
}
}
// inputs are fetched from worker.media and checked against their digests before the job starts (platform.job@1):
// inputs are fetched from worker.media and checked against their digests before the job starts (platform.job@1):
@@ -212,6 +212,36 @@ func TestAdmitByClass(t *testing.T) {
@@ -212,6 +212,36 @@ func TestAdmitByClass(t *testing.T) {
w.Close()
w.Close()
}
}
+// TestAdmitLinux: a declared linux entry is a declared-command class like code — its job is admitted and matched by
+// name, version and class — while browser, desktop and gpu jobs stay refused class not available.
+func TestAdmitLinux(t *testing.T) {
+ cfg := helperConfig(t, config.WorkerCaps{MaxJobs: 8}, "sleep")
+ tool := cfg.Functions[0]
+ tool.Name, tool.Class = "tool", config.ClassLinux
+ cfg.Functions = append([]config.FunctionConfig{tool}, cfg.Functions...)
+ w := New(cfg, (&sink{}).send, quiet())
+ if c := w.Classes(); !slices.Equal(c, []string{protocol.ClassFunction, protocol.ClassLinux}) {
+ t.Fatalf("classes %v", c)
+ }
+ job := func(n int, class, name string) protocol.Job {
+ j := testJob(n, name)
+ j.Class = class
+ return j
+ }
+ if f, r := w.Admit(job(1, protocol.ClassLinux, "tool")); f != "" {
+ t.Fatalf("a declared linux entry: %s %s", f, r)
+ }
+ if f, r := w.Admit(job(2, protocol.ClassLinux, "sleep")); f != protocol.FaultUnsupported || r != protocol.JobUnknownArtifact {
+ t.Fatalf("a linux job naming a function entry: %q %q", f, r)
+ }
+ for i, class := range []string{protocol.ClassBrowser, protocol.ClassDesktop, protocol.ClassGPU} {
+ if f, r := w.Admit(job(3+i, class, "tool")); f != protocol.FaultUnsupported || r != protocol.JobNotAvailable {
+ t.Errorf("%s: %q %q, want %q %q", class, f, r, protocol.FaultUnsupported, protocol.JobNotAvailable)
+ }
+ }
+ w.Close()
+}
+
// TestIsolationUnavailableRefused: when the namespaces cannot be created the probe fails (the class is not
// TestIsolationUnavailableRefused: when the namespaces cannot be created the probe fails (the class is not
// advertised), and a job whose isolation fails ends `failed` without its process ever starting.
// advertised), and a job whose isolation fails ends `failed` without its process ever starting.
func TestIsolationUnavailableRefused(t *testing.T) {
func TestIsolationUnavailableRefused(t *testing.T) {