nodus-container 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodus_container-0.1.0/PKG-INFO +100 -0
- nodus_container-0.1.0/README.md +77 -0
- nodus_container-0.1.0/pyproject.toml +51 -0
- nodus_container-0.1.0/setup.cfg +4 -0
- nodus_container-0.1.0/src/nodus_container/__init__.py +23 -0
- nodus_container-0.1.0/src/nodus_container/models.py +69 -0
- nodus_container-0.1.0/src/nodus_container/runner.py +95 -0
- nodus_container-0.1.0/src/nodus_container.egg-info/PKG-INFO +100 -0
- nodus_container-0.1.0/src/nodus_container.egg-info/SOURCES.txt +11 -0
- nodus_container-0.1.0/src/nodus_container.egg-info/dependency_links.txt +1 -0
- nodus_container-0.1.0/src/nodus_container.egg-info/requires.txt +3 -0
- nodus_container-0.1.0/src/nodus_container.egg-info/top_level.txt +1 -0
- nodus_container-0.1.0/tests/test_nodus_container.py +145 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-container
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Container execution primitives for Nodus runtimes (Docker, Podman)
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/Nodus
|
|
9
|
+
Project-URL: Issues, https://github.com/Masterplanner25/Nodus/issues
|
|
10
|
+
Keywords: nodus,container,docker,podman,workflow,orchestration
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=9.0; extra == "test"
|
|
23
|
+
|
|
24
|
+
# nodus-container
|
|
25
|
+
|
|
26
|
+
Container execution for Nodus workflows — launch a container, capture its result,
|
|
27
|
+
without dropping back to bash.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from nodus_container import ContainerSpec, Mount, build_argv, run
|
|
31
|
+
|
|
32
|
+
spec = ContainerSpec(
|
|
33
|
+
image="alpine:3",
|
|
34
|
+
command=("echo", "hello"),
|
|
35
|
+
mounts=(Mount(source="/data", target="/data"),), # read-only by default
|
|
36
|
+
)
|
|
37
|
+
build_argv(spec)
|
|
38
|
+
# ['docker', 'run', '--rm', '-v', '/data:/data:ro', 'alpine:3', 'echo', 'hello']
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run(spec, runner=...)` executes it through a runner you supply — from Nodus,
|
|
42
|
+
that is `std:subprocess.run`. This package never imports a subprocess module.
|
|
43
|
+
|
|
44
|
+
## Security: read this before granting it
|
|
45
|
+
|
|
46
|
+
This is an adapter over `subprocess`, and it declares **no capability of its
|
|
47
|
+
own**. A `container` capability would be unenforceable: anything holding
|
|
48
|
+
`subprocess` can already run `docker` directly, so a control beside it would be
|
|
49
|
+
bypassed by the permission the caller already has.
|
|
50
|
+
|
|
51
|
+
**The enforceable boundary is `allowed_commands`:**
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
NodusRuntime(allow_subprocess=True, allowed_commands=["docker"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Withhold `docker` from that list and container execution is refused. Setting any
|
|
58
|
+
allowlist also refuses `subprocess.shell`, so `sh -c "docker …"` cannot step
|
|
59
|
+
around it.
|
|
60
|
+
|
|
61
|
+
**What is still not bounded:** `-v /:/host` escapes `allowed_paths`, and
|
|
62
|
+
`--privileged` exists. That is not a gap this package opens —
|
|
63
|
+
[SECURITY_POSTURE.md §5](https://github.com/Masterplanner25/Nodus/blob/main/docs/governance/SECURITY_POSTURE.md)
|
|
64
|
+
already records that a permitted subprocess's binary and arguments are
|
|
65
|
+
unrestricted — but this package makes it a one-liner, which is worth knowing
|
|
66
|
+
before you grant it. Mounts are read-only by default for that reason.
|
|
67
|
+
|
|
68
|
+
To restrict *arguments*, use a `CapabilityPolicy`: it receives the call's
|
|
69
|
+
arguments, and `build_argv` is pure so the argv a call would make can be
|
|
70
|
+
inspected before it runs.
|
|
71
|
+
|
|
72
|
+
### Under `nodus serve`, check which nodus-lang you are on
|
|
73
|
+
|
|
74
|
+
The server's treatment of submitted code changed in
|
|
75
|
+
[#754](https://github.com/Masterplanner25/Nodus/issues/754), and the two
|
|
76
|
+
behaviours are opposites:
|
|
77
|
+
|
|
78
|
+
- **Through nodus-lang 5.9.0**, code sent to `POST /execute` ran with subprocess,
|
|
79
|
+
network and environment access permitted, and **no flag could restrict it**. If
|
|
80
|
+
you are on one of those releases, anything that can reach the port can run
|
|
81
|
+
`docker` — put the server behind something that authenticates, and treat this
|
|
82
|
+
package's presence as incidental to that.
|
|
83
|
+
- **After #754 ships**, submitted code is denied those by default and the
|
|
84
|
+
operator grants them:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
nodus serve --auth-token "$TOKEN" --allow-subprocess --allowed-commands docker
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The allowlist is the part worth typing. `--allow-subprocess` alone permits every
|
|
91
|
+
executable on the host.
|
|
92
|
+
|
|
93
|
+
## Status
|
|
94
|
+
|
|
95
|
+
Published from the [nodus-lang repository](https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container),
|
|
96
|
+
where it is developed alongside the runtime rather than in a repository of its own.
|
|
97
|
+
|
|
98
|
+
Design and the capability argument in full:
|
|
99
|
+
[NODUS_CONTAINER.md](https://github.com/Masterplanner25/Nodus/blob/main/docs/ecosystem/NODUS_CONTAINER.md).
|
|
100
|
+
Issue: [#85](https://github.com/Masterplanner25/Nodus/issues/85).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# nodus-container
|
|
2
|
+
|
|
3
|
+
Container execution for Nodus workflows — launch a container, capture its result,
|
|
4
|
+
without dropping back to bash.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from nodus_container import ContainerSpec, Mount, build_argv, run
|
|
8
|
+
|
|
9
|
+
spec = ContainerSpec(
|
|
10
|
+
image="alpine:3",
|
|
11
|
+
command=("echo", "hello"),
|
|
12
|
+
mounts=(Mount(source="/data", target="/data"),), # read-only by default
|
|
13
|
+
)
|
|
14
|
+
build_argv(spec)
|
|
15
|
+
# ['docker', 'run', '--rm', '-v', '/data:/data:ro', 'alpine:3', 'echo', 'hello']
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`run(spec, runner=...)` executes it through a runner you supply — from Nodus,
|
|
19
|
+
that is `std:subprocess.run`. This package never imports a subprocess module.
|
|
20
|
+
|
|
21
|
+
## Security: read this before granting it
|
|
22
|
+
|
|
23
|
+
This is an adapter over `subprocess`, and it declares **no capability of its
|
|
24
|
+
own**. A `container` capability would be unenforceable: anything holding
|
|
25
|
+
`subprocess` can already run `docker` directly, so a control beside it would be
|
|
26
|
+
bypassed by the permission the caller already has.
|
|
27
|
+
|
|
28
|
+
**The enforceable boundary is `allowed_commands`:**
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
NodusRuntime(allow_subprocess=True, allowed_commands=["docker"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Withhold `docker` from that list and container execution is refused. Setting any
|
|
35
|
+
allowlist also refuses `subprocess.shell`, so `sh -c "docker …"` cannot step
|
|
36
|
+
around it.
|
|
37
|
+
|
|
38
|
+
**What is still not bounded:** `-v /:/host` escapes `allowed_paths`, and
|
|
39
|
+
`--privileged` exists. That is not a gap this package opens —
|
|
40
|
+
[SECURITY_POSTURE.md §5](https://github.com/Masterplanner25/Nodus/blob/main/docs/governance/SECURITY_POSTURE.md)
|
|
41
|
+
already records that a permitted subprocess's binary and arguments are
|
|
42
|
+
unrestricted — but this package makes it a one-liner, which is worth knowing
|
|
43
|
+
before you grant it. Mounts are read-only by default for that reason.
|
|
44
|
+
|
|
45
|
+
To restrict *arguments*, use a `CapabilityPolicy`: it receives the call's
|
|
46
|
+
arguments, and `build_argv` is pure so the argv a call would make can be
|
|
47
|
+
inspected before it runs.
|
|
48
|
+
|
|
49
|
+
### Under `nodus serve`, check which nodus-lang you are on
|
|
50
|
+
|
|
51
|
+
The server's treatment of submitted code changed in
|
|
52
|
+
[#754](https://github.com/Masterplanner25/Nodus/issues/754), and the two
|
|
53
|
+
behaviours are opposites:
|
|
54
|
+
|
|
55
|
+
- **Through nodus-lang 5.9.0**, code sent to `POST /execute` ran with subprocess,
|
|
56
|
+
network and environment access permitted, and **no flag could restrict it**. If
|
|
57
|
+
you are on one of those releases, anything that can reach the port can run
|
|
58
|
+
`docker` — put the server behind something that authenticates, and treat this
|
|
59
|
+
package's presence as incidental to that.
|
|
60
|
+
- **After #754 ships**, submitted code is denied those by default and the
|
|
61
|
+
operator grants them:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
nodus serve --auth-token "$TOKEN" --allow-subprocess --allowed-commands docker
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The allowlist is the part worth typing. `--allow-subprocess` alone permits every
|
|
68
|
+
executable on the host.
|
|
69
|
+
|
|
70
|
+
## Status
|
|
71
|
+
|
|
72
|
+
Published from the [nodus-lang repository](https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container),
|
|
73
|
+
where it is developed alongside the runtime rather than in a repository of its own.
|
|
74
|
+
|
|
75
|
+
Design and the capability argument in full:
|
|
76
|
+
[NODUS_CONTAINER.md](https://github.com/Masterplanner25/Nodus/blob/main/docs/ecosystem/NODUS_CONTAINER.md).
|
|
77
|
+
Issue: [#85](https://github.com/Masterplanner25/Nodus/issues/85).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodus-container"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Container execution primitives for Nodus runtimes (Docker, Podman)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Shawn Knight" }]
|
|
13
|
+
keywords = ["nodus", "container", "docker", "podman", "workflow", "orchestration"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Topic :: System :: Systems Administration",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# No runtime dependencies, and no `nodus-lang` floor: the engine is reached
|
|
26
|
+
# through an injected runner, so nothing here imports a subprocess module or the
|
|
27
|
+
# runtime. Companions do not cap nodus-lang (policy, 2026-08-17) -- see #445.
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
# This package is developed inside the nodus-lang repository rather than in one
|
|
32
|
+
# of its own, so both point there. `nodus-store-sql` was promoted out to a
|
|
33
|
+
# separate repo before publishing; that has not been done here, and the URLs say
|
|
34
|
+
# so honestly rather than naming a repository that does not exist.
|
|
35
|
+
Homepage = "https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container"
|
|
36
|
+
Repository = "https://github.com/Masterplanner25/Nodus"
|
|
37
|
+
Issues = "https://github.com/Masterplanner25/Nodus/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = ["pytest>=9.0"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
package-dir = { "" = "src" }
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
# The scaffold's own package must win over any same-named production install
|
|
50
|
+
# (#312): `import nodus_container` here means *this* tree.
|
|
51
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Container execution primitives for Nodus runtimes (#85).
|
|
2
|
+
|
|
3
|
+
An adapter over `std:subprocess`, not a container runtime: it builds an argv,
|
|
4
|
+
hands it to an injected runner, and shapes the result into what
|
|
5
|
+
`subprocess.run` already returns.
|
|
6
|
+
|
|
7
|
+
Read `docs/ecosystem/NODUS_CONTAINER.md` before extending it -- particularly the
|
|
8
|
+
capability decision, which is the reason this package declares no capability of
|
|
9
|
+
its own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .models import SUPPORTED_ENGINES, ContainerResult, ContainerSpec, Mount
|
|
13
|
+
from .runner import ContainerSpecError, build_argv, run
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SUPPORTED_ENGINES",
|
|
17
|
+
"ContainerResult",
|
|
18
|
+
"ContainerSpec",
|
|
19
|
+
"ContainerSpecError",
|
|
20
|
+
"Mount",
|
|
21
|
+
"build_argv",
|
|
22
|
+
"run",
|
|
23
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Value types for a container invocation (#85).
|
|
2
|
+
|
|
3
|
+
Pure data. Nothing here reaches an engine, imports a subprocess module, or knows
|
|
4
|
+
what a Nodus runtime is -- which is what lets `build_argv` be tested without
|
|
5
|
+
Docker installed, and lets a `CapabilityPolicy` inspect the argv a call *would*
|
|
6
|
+
make rather than pattern-matching a shell string.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
#: Engines whose CLI takes the argv shape this builds. Named once; `build_argv`
|
|
14
|
+
#: validates against it rather than accepting any string, so a typo is refused
|
|
15
|
+
#: where it is written instead of surfacing as "docker: command not found".
|
|
16
|
+
SUPPORTED_ENGINES = frozenset({"docker", "podman"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Mount:
|
|
21
|
+
"""A host path made visible inside the container.
|
|
22
|
+
|
|
23
|
+
`read_only` defaults to **True**, and that default is the one opinion this
|
|
24
|
+
package holds. A writable bind mount is the mechanism behind
|
|
25
|
+
`docker run -v /:/host` -- the escape from `allowed_paths` that
|
|
26
|
+
`SECURITY_POSTURE.md §5` describes -- so it is the thing a reader should have
|
|
27
|
+
to type rather than the thing they get by forgetting.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
source: str
|
|
31
|
+
target: str
|
|
32
|
+
read_only: bool = True
|
|
33
|
+
|
|
34
|
+
def as_argument(self) -> str:
|
|
35
|
+
suffix = "ro" if self.read_only else "rw"
|
|
36
|
+
return f"{self.source}:{self.target}:{suffix}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ContainerSpec:
|
|
41
|
+
"""What to run, as data."""
|
|
42
|
+
|
|
43
|
+
image: str
|
|
44
|
+
command: tuple[str, ...] = ()
|
|
45
|
+
engine: str = "docker"
|
|
46
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
mounts: tuple[Mount, ...] = ()
|
|
48
|
+
workdir: str | None = None
|
|
49
|
+
network: str | None = None
|
|
50
|
+
remove: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ContainerResult:
|
|
55
|
+
"""The same shape `subprocess.run` returns, plus the argv that produced it.
|
|
56
|
+
|
|
57
|
+
Nothing new to learn for a reader who has used `std:subprocess`, and keeping
|
|
58
|
+
the argv means a refusal or a surprise can be read back afterwards -- which
|
|
59
|
+
matters most when the refusal came from a capability policy inspecting it.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
exit_code: int
|
|
63
|
+
stdout: str = ""
|
|
64
|
+
stderr: str = ""
|
|
65
|
+
argv: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def ok(self) -> bool:
|
|
69
|
+
return self.exit_code == 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Turning a `ContainerSpec` into an argv, and running it through someone else's runner (#85).
|
|
2
|
+
|
|
3
|
+
**`build_argv` is pure and `run` takes an injected runner**, and the split is the
|
|
4
|
+
design rather than a convenience. Three things follow from it:
|
|
5
|
+
|
|
6
|
+
- The argv can be tested without a container engine present.
|
|
7
|
+
- A `CapabilityPolicy` can inspect the argv a call *would* make -- refusing
|
|
8
|
+
`--privileged`, or a writable mount of `/` -- instead of pattern-matching a
|
|
9
|
+
shell string. Argument-level restriction is a policy question, not a capability
|
|
10
|
+
one, and this is what makes it answerable.
|
|
11
|
+
- Nothing here imports a subprocess module, so the package declares no capability
|
|
12
|
+
of its own. The Nodus side passes `std:subprocess.run` in, and **that** call is
|
|
13
|
+
what `allow_subprocess` and `allowed_commands` gate.
|
|
14
|
+
|
|
15
|
+
On why there is no `container` capability: one would be unenforceable. Anything
|
|
16
|
+
holding `subprocess` can already run `docker` directly, so a capability beside it
|
|
17
|
+
would be bypassed by the capability the caller already has -- a control an
|
|
18
|
+
operator believes they have and does not. `docs/ecosystem/NODUS_CONTAINER.md`
|
|
19
|
+
carries the full argument and the measurements behind it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
|
|
26
|
+
from .models import SUPPORTED_ENGINES, ContainerResult, ContainerSpec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ContainerSpecError(ValueError):
|
|
30
|
+
"""A spec that cannot become an argv. Raised where it is written."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_argv(spec: ContainerSpec) -> list[str]:
|
|
34
|
+
"""The argv *spec* becomes. Pure -- nothing is executed.
|
|
35
|
+
|
|
36
|
+
Deterministic, including environment ordering: an argv that varies run to run
|
|
37
|
+
cannot be compared in a test, diffed in a trace, or matched by a policy.
|
|
38
|
+
"""
|
|
39
|
+
if spec.engine not in SUPPORTED_ENGINES:
|
|
40
|
+
raise ContainerSpecError(
|
|
41
|
+
f"unknown engine {spec.engine!r}; expected one of "
|
|
42
|
+
f"{', '.join(sorted(SUPPORTED_ENGINES))}"
|
|
43
|
+
)
|
|
44
|
+
if not spec.image or not spec.image.strip():
|
|
45
|
+
raise ContainerSpecError("a container spec needs an image")
|
|
46
|
+
|
|
47
|
+
argv: list[str] = [spec.engine, "run"]
|
|
48
|
+
if spec.remove:
|
|
49
|
+
argv.append("--rm")
|
|
50
|
+
for key in sorted(spec.env):
|
|
51
|
+
argv += ["-e", f"{key}={spec.env[key]}"]
|
|
52
|
+
for mount in spec.mounts:
|
|
53
|
+
if not mount.source or not mount.target:
|
|
54
|
+
raise ContainerSpecError(
|
|
55
|
+
f"a mount needs both a source and a target, got "
|
|
56
|
+
f"{mount.source!r} -> {mount.target!r}"
|
|
57
|
+
)
|
|
58
|
+
argv += ["-v", mount.as_argument()]
|
|
59
|
+
if spec.workdir:
|
|
60
|
+
argv += ["-w", spec.workdir]
|
|
61
|
+
if spec.network:
|
|
62
|
+
argv += ["--network", spec.network]
|
|
63
|
+
argv.append(spec.image)
|
|
64
|
+
argv.extend(spec.command)
|
|
65
|
+
return argv
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run(spec: ContainerSpec, *, runner: Callable[[list[str]], object]) -> ContainerResult:
|
|
69
|
+
"""Build the argv and hand it to *runner*.
|
|
70
|
+
|
|
71
|
+
`runner` is anything taking `list[str]` and returning a mapping or object with
|
|
72
|
+
`exit_code` / `stdout` / `stderr` -- which is exactly what
|
|
73
|
+
`std:subprocess.run` gives back. Injected rather than imported so this package
|
|
74
|
+
stays free of an execution dependency, and so a test can supply a fake.
|
|
75
|
+
"""
|
|
76
|
+
argv = build_argv(spec)
|
|
77
|
+
raw = runner(argv)
|
|
78
|
+
return ContainerResult(
|
|
79
|
+
exit_code=int(_field(raw, "exit_code", 0)),
|
|
80
|
+
stdout=str(_field(raw, "stdout", "")),
|
|
81
|
+
stderr=str(_field(raw, "stderr", "")),
|
|
82
|
+
argv=tuple(argv),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _field(raw: object, name: str, default: object) -> object:
|
|
87
|
+
"""Read *name* off a mapping or an object.
|
|
88
|
+
|
|
89
|
+
Both shapes are accepted because both are what callers have: a Nodus record
|
|
90
|
+
marshals to a mapping, and a Python caller is likelier to hand over an object.
|
|
91
|
+
Refusing one of them would make the runner injectable in theory only.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(raw, Mapping):
|
|
94
|
+
return raw.get(name, default)
|
|
95
|
+
return getattr(raw, name, default)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-container
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Container execution primitives for Nodus runtimes (Docker, Podman)
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/Nodus
|
|
9
|
+
Project-URL: Issues, https://github.com/Masterplanner25/Nodus/issues
|
|
10
|
+
Keywords: nodus,container,docker,podman,workflow,orchestration
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: System :: Systems Administration
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=9.0; extra == "test"
|
|
23
|
+
|
|
24
|
+
# nodus-container
|
|
25
|
+
|
|
26
|
+
Container execution for Nodus workflows — launch a container, capture its result,
|
|
27
|
+
without dropping back to bash.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from nodus_container import ContainerSpec, Mount, build_argv, run
|
|
31
|
+
|
|
32
|
+
spec = ContainerSpec(
|
|
33
|
+
image="alpine:3",
|
|
34
|
+
command=("echo", "hello"),
|
|
35
|
+
mounts=(Mount(source="/data", target="/data"),), # read-only by default
|
|
36
|
+
)
|
|
37
|
+
build_argv(spec)
|
|
38
|
+
# ['docker', 'run', '--rm', '-v', '/data:/data:ro', 'alpine:3', 'echo', 'hello']
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run(spec, runner=...)` executes it through a runner you supply — from Nodus,
|
|
42
|
+
that is `std:subprocess.run`. This package never imports a subprocess module.
|
|
43
|
+
|
|
44
|
+
## Security: read this before granting it
|
|
45
|
+
|
|
46
|
+
This is an adapter over `subprocess`, and it declares **no capability of its
|
|
47
|
+
own**. A `container` capability would be unenforceable: anything holding
|
|
48
|
+
`subprocess` can already run `docker` directly, so a control beside it would be
|
|
49
|
+
bypassed by the permission the caller already has.
|
|
50
|
+
|
|
51
|
+
**The enforceable boundary is `allowed_commands`:**
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
NodusRuntime(allow_subprocess=True, allowed_commands=["docker"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Withhold `docker` from that list and container execution is refused. Setting any
|
|
58
|
+
allowlist also refuses `subprocess.shell`, so `sh -c "docker …"` cannot step
|
|
59
|
+
around it.
|
|
60
|
+
|
|
61
|
+
**What is still not bounded:** `-v /:/host` escapes `allowed_paths`, and
|
|
62
|
+
`--privileged` exists. That is not a gap this package opens —
|
|
63
|
+
[SECURITY_POSTURE.md §5](https://github.com/Masterplanner25/Nodus/blob/main/docs/governance/SECURITY_POSTURE.md)
|
|
64
|
+
already records that a permitted subprocess's binary and arguments are
|
|
65
|
+
unrestricted — but this package makes it a one-liner, which is worth knowing
|
|
66
|
+
before you grant it. Mounts are read-only by default for that reason.
|
|
67
|
+
|
|
68
|
+
To restrict *arguments*, use a `CapabilityPolicy`: it receives the call's
|
|
69
|
+
arguments, and `build_argv` is pure so the argv a call would make can be
|
|
70
|
+
inspected before it runs.
|
|
71
|
+
|
|
72
|
+
### Under `nodus serve`, check which nodus-lang you are on
|
|
73
|
+
|
|
74
|
+
The server's treatment of submitted code changed in
|
|
75
|
+
[#754](https://github.com/Masterplanner25/Nodus/issues/754), and the two
|
|
76
|
+
behaviours are opposites:
|
|
77
|
+
|
|
78
|
+
- **Through nodus-lang 5.9.0**, code sent to `POST /execute` ran with subprocess,
|
|
79
|
+
network and environment access permitted, and **no flag could restrict it**. If
|
|
80
|
+
you are on one of those releases, anything that can reach the port can run
|
|
81
|
+
`docker` — put the server behind something that authenticates, and treat this
|
|
82
|
+
package's presence as incidental to that.
|
|
83
|
+
- **After #754 ships**, submitted code is denied those by default and the
|
|
84
|
+
operator grants them:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
nodus serve --auth-token "$TOKEN" --allow-subprocess --allowed-commands docker
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The allowlist is the part worth typing. `--allow-subprocess` alone permits every
|
|
91
|
+
executable on the host.
|
|
92
|
+
|
|
93
|
+
## Status
|
|
94
|
+
|
|
95
|
+
Published from the [nodus-lang repository](https://github.com/Masterplanner25/Nodus/tree/main/packages/nodus-container),
|
|
96
|
+
where it is developed alongside the runtime rather than in a repository of its own.
|
|
97
|
+
|
|
98
|
+
Design and the capability argument in full:
|
|
99
|
+
[NODUS_CONTAINER.md](https://github.com/Masterplanner25/Nodus/blob/main/docs/ecosystem/NODUS_CONTAINER.md).
|
|
100
|
+
Issue: [#85](https://github.com/Masterplanner25/Nodus/issues/85).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/nodus_container/__init__.py
|
|
4
|
+
src/nodus_container/models.py
|
|
5
|
+
src/nodus_container/runner.py
|
|
6
|
+
src/nodus_container.egg-info/PKG-INFO
|
|
7
|
+
src/nodus_container.egg-info/SOURCES.txt
|
|
8
|
+
src/nodus_container.egg-info/dependency_links.txt
|
|
9
|
+
src/nodus_container.egg-info/requires.txt
|
|
10
|
+
src/nodus_container.egg-info/top_level.txt
|
|
11
|
+
tests/test_nodus_container.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodus_container
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""`nodus-container` scaffold tests (#85).
|
|
2
|
+
|
|
3
|
+
No test requires a container engine, which is the point of splitting `build_argv`
|
|
4
|
+
from `run`: the argv is a pure function of the spec, and execution is somebody
|
|
5
|
+
else's callable. A live-engine test belongs in this package's own CI, marked and
|
|
6
|
+
skipped when `docker` is absent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from nodus_container import (
|
|
12
|
+
ContainerResult,
|
|
13
|
+
ContainerSpec,
|
|
14
|
+
ContainerSpecError,
|
|
15
|
+
Mount,
|
|
16
|
+
build_argv,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestBuildArgv:
|
|
22
|
+
def test_the_minimal_spec(self):
|
|
23
|
+
assert build_argv(ContainerSpec(image="alpine:3")) == [
|
|
24
|
+
"docker", "run", "--rm", "alpine:3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def test_the_command_goes_after_the_image(self):
|
|
28
|
+
"""Argv order is not cosmetic: anything after the image is the
|
|
29
|
+
container's argv, and anything before it is the engine's."""
|
|
30
|
+
argv = build_argv(ContainerSpec(image="alpine:3", command=("echo", "hi")))
|
|
31
|
+
assert argv[argv.index("alpine:3") + 1:] == ["echo", "hi"]
|
|
32
|
+
|
|
33
|
+
def test_environment_is_sorted(self):
|
|
34
|
+
"""Deterministic, so an argv can be compared in a test, diffed in a trace,
|
|
35
|
+
and matched by a policy. Insertion order would make all three unreliable."""
|
|
36
|
+
argv = build_argv(
|
|
37
|
+
ContainerSpec(image="i", env={"B": "2", "A": "1", "C": "3"})
|
|
38
|
+
)
|
|
39
|
+
assert argv[argv.index("-e"):][:6] == ["-e", "A=1", "-e", "B=2", "-e", "C=3"]
|
|
40
|
+
|
|
41
|
+
def test_mounts_are_read_only_unless_asked(self):
|
|
42
|
+
"""The one opinion this package holds -- a writable bind mount is the
|
|
43
|
+
mechanism behind `-v /:/host`, so it has to be typed."""
|
|
44
|
+
spec = ContainerSpec(image="i", mounts=(Mount(source="/a", target="/b"),))
|
|
45
|
+
assert "-v" in build_argv(spec)
|
|
46
|
+
assert "/a:/b:ro" in build_argv(spec)
|
|
47
|
+
|
|
48
|
+
writable = ContainerSpec(
|
|
49
|
+
image="i", mounts=(Mount(source="/a", target="/b", read_only=False),)
|
|
50
|
+
)
|
|
51
|
+
assert "/a:/b:rw" in build_argv(writable)
|
|
52
|
+
|
|
53
|
+
def test_optional_flags_appear_only_when_set(self):
|
|
54
|
+
bare = build_argv(ContainerSpec(image="i"))
|
|
55
|
+
assert "-w" not in bare and "--network" not in bare
|
|
56
|
+
|
|
57
|
+
full = build_argv(ContainerSpec(image="i", workdir="/src", network="none"))
|
|
58
|
+
assert full[full.index("-w") + 1] == "/src"
|
|
59
|
+
assert full[full.index("--network") + 1] == "none"
|
|
60
|
+
|
|
61
|
+
def test_remove_can_be_turned_off(self):
|
|
62
|
+
assert "--rm" not in build_argv(ContainerSpec(image="i", remove=False))
|
|
63
|
+
|
|
64
|
+
def test_podman_takes_the_same_shape(self):
|
|
65
|
+
assert build_argv(ContainerSpec(image="i", engine="podman"))[0] == "podman"
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize(
|
|
68
|
+
"spec, fragment",
|
|
69
|
+
[
|
|
70
|
+
(ContainerSpec(image=""), "needs an image"),
|
|
71
|
+
(ContainerSpec(image=" "), "needs an image"),
|
|
72
|
+
(ContainerSpec(image="i", engine="containerd"), "unknown engine"),
|
|
73
|
+
(
|
|
74
|
+
ContainerSpec(image="i", mounts=(Mount(source="/a", target=""),)),
|
|
75
|
+
"needs both a source and a target",
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
def test_a_bad_spec_is_refused_where_it_is_written(self, spec, fragment):
|
|
80
|
+
with pytest.raises(ContainerSpecError) as caught:
|
|
81
|
+
build_argv(spec)
|
|
82
|
+
assert fragment in str(caught.value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestRun:
|
|
86
|
+
def test_it_uses_the_injected_runner(self):
|
|
87
|
+
seen = {}
|
|
88
|
+
|
|
89
|
+
def fake(argv):
|
|
90
|
+
seen["argv"] = argv
|
|
91
|
+
return {"exit_code": 0, "stdout": "hello\n", "stderr": ""}
|
|
92
|
+
|
|
93
|
+
result = run(ContainerSpec(image="alpine:3", command=("echo", "hello")), runner=fake)
|
|
94
|
+
assert isinstance(result, ContainerResult)
|
|
95
|
+
assert result.ok and result.stdout == "hello\n"
|
|
96
|
+
assert seen["argv"][0] == "docker"
|
|
97
|
+
|
|
98
|
+
def test_the_argv_is_carried_on_the_result(self):
|
|
99
|
+
"""So a refusal or a surprise can be read back -- which matters most when
|
|
100
|
+
the refusal came from a policy inspecting the argv."""
|
|
101
|
+
result = run(ContainerSpec(image="i"), runner=lambda argv: {"exit_code": 0})
|
|
102
|
+
assert result.argv == ("docker", "run", "--rm", "i")
|
|
103
|
+
|
|
104
|
+
def test_a_non_zero_exit_is_reported_not_raised(self):
|
|
105
|
+
"""The same contract `subprocess.run` has: a failing command is a result,
|
|
106
|
+
not an exception. A spec that cannot become an argv is the exception."""
|
|
107
|
+
result = run(
|
|
108
|
+
ContainerSpec(image="i"),
|
|
109
|
+
runner=lambda argv: {"exit_code": 3, "stderr": "boom"},
|
|
110
|
+
)
|
|
111
|
+
assert not result.ok
|
|
112
|
+
assert result.exit_code == 3 and result.stderr == "boom"
|
|
113
|
+
|
|
114
|
+
def test_an_object_runner_works_as_well_as_a_mapping(self):
|
|
115
|
+
"""A Nodus record marshals to a mapping; a Python caller is likelier to
|
|
116
|
+
return an object. Accepting only one would make the runner injectable in
|
|
117
|
+
theory only."""
|
|
118
|
+
|
|
119
|
+
class Reply:
|
|
120
|
+
exit_code = 0
|
|
121
|
+
stdout = "ok"
|
|
122
|
+
stderr = ""
|
|
123
|
+
|
|
124
|
+
assert run(ContainerSpec(image="i"), runner=lambda argv: Reply()).stdout == "ok"
|
|
125
|
+
|
|
126
|
+
def test_a_bad_spec_never_reaches_the_runner(self):
|
|
127
|
+
def explode(argv): # pragma: no cover - must not be called
|
|
128
|
+
raise AssertionError("the runner was called with an invalid spec")
|
|
129
|
+
|
|
130
|
+
with pytest.raises(ContainerSpecError):
|
|
131
|
+
run(ContainerSpec(image=""), runner=explode)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestItDeclaresNoExecutionDependency:
|
|
135
|
+
def test_nothing_imports_a_subprocess_module(self):
|
|
136
|
+
"""The reason this package declares no capability of its own: it cannot
|
|
137
|
+
execute anything. If that stops being true, the security argument in
|
|
138
|
+
`docs/ecosystem/NODUS_CONTAINER.md` stops being true with it."""
|
|
139
|
+
from pathlib import Path
|
|
140
|
+
|
|
141
|
+
package = Path(__file__).resolve().parents[1] / "src" / "nodus_container"
|
|
142
|
+
for module in sorted(package.glob("*.py")):
|
|
143
|
+
source = module.read_text(encoding="utf-8")
|
|
144
|
+
for banned in ("import subprocess", "from subprocess", "os.system", "os.popen"):
|
|
145
|
+
assert banned not in source, f"{module.name} reaches for {banned}"
|