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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+
2
+ [test]
3
+ pytest>=9.0
@@ -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}"