flashnode 0.2.0__py3-none-any.whl
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.
- flashnode/__init__.py +7 -0
- flashnode/agent/__init__.py +6 -0
- flashnode/agent/cli.py +285 -0
- flashnode/agent/daemon.py +108 -0
- flashnode/agent/kube.py +41 -0
- flashnode/artifacts/__init__.py +5 -0
- flashnode/benchmark/__init__.py +100 -0
- flashnode/config/__init__.py +103 -0
- flashnode/executor/__init__.py +27 -0
- flashnode/executor/archives.py +290 -0
- flashnode/executor/argv_runner.py +114 -0
- flashnode/executor/client.py +201 -0
- flashnode/executor/docker_runner.py +108 -0
- flashnode/executor/hardening.py +141 -0
- flashnode/executor/images.py +161 -0
- flashnode/executor/loop.py +363 -0
- flashnode/executor/runner.py +109 -0
- flashnode/identity/__init__.py +5 -0
- flashnode/identity/credentials.py +70 -0
- flashnode/identity/enrol.py +179 -0
- flashnode/identity/store.py +48 -0
- flashnode/inventory/__init__.py +5 -0
- flashnode/inventory/capabilities.py +117 -0
- flashnode/telemetry/__init__.py +79 -0
- flashnode-0.2.0.dist-info/METADATA +160 -0
- flashnode-0.2.0.dist-info/RECORD +30 -0
- flashnode-0.2.0.dist-info/WHEEL +5 -0
- flashnode-0.2.0.dist-info/entry_points.txt +2 -0
- flashnode-0.2.0.dist-info/licenses/LICENSE +202 -0
- flashnode-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tier-2 task runner: allowlisted container execution.
|
|
2
|
+
|
|
3
|
+
Same `run(payload, workdir, inputs) → outdir` interface as
|
|
4
|
+
`SubprocessRunner`; execution happens inside `docker run` with the AGENTS.md
|
|
5
|
+
security contract: image allowlist (fail closed, checked before any
|
|
6
|
+
subprocess), `--network none`, cpu/memory limits, read-only rootfs with the
|
|
7
|
+
work directory as the only writable mount, host-uid user mapping (no root in
|
|
8
|
+
the container's world), and a wall-clock timeout. The docker socket is used
|
|
9
|
+
*by the agent* to launch the task — it is never mounted into the task.
|
|
10
|
+
|
|
11
|
+
The task sees exactly one directory: its workdir bound at `/work`
|
|
12
|
+
(spec.json in, inputs under /work/inputs, outputs to /work/out) — so
|
|
13
|
+
spec.json is written with *container* paths, not host paths.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from flashnode.executor.hardening import CONTAINER_WORKDIR, container_name, harden_args
|
|
23
|
+
from flashnode.executor.images import DEFAULT_ALLOWED_IMAGE_PREFIXES, image_is_allowed
|
|
24
|
+
from flashnode.executor.runner import DEFAULT_ALLOWED_MODULES, TaskExecutionError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DockerRunner:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
allowed_images: frozenset[str] = frozenset(),
|
|
31
|
+
allowed_modules: frozenset[str] = DEFAULT_ALLOWED_MODULES,
|
|
32
|
+
cpus: float = 2.0,
|
|
33
|
+
memory_gb: float = 2.0,
|
|
34
|
+
timeout_seconds: float = 900.0,
|
|
35
|
+
allowed_image_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
|
|
36
|
+
):
|
|
37
|
+
# `allowed_images` now defaults to empty because the built-in
|
|
38
|
+
# namespace prefix is what a volunteer runs on. An empty exact list
|
|
39
|
+
# is no longer the "refusing to start" condition it once was — see
|
|
40
|
+
# executor/images.py for the two-knob model.
|
|
41
|
+
self.allowed_images = allowed_images
|
|
42
|
+
self.allowed_image_prefixes = allowed_image_prefixes
|
|
43
|
+
self.allowed_modules = allowed_modules
|
|
44
|
+
self.cpus = cpus
|
|
45
|
+
self.memory_gb = memory_gb
|
|
46
|
+
self.timeout_seconds = timeout_seconds
|
|
47
|
+
|
|
48
|
+
def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
|
|
49
|
+
module = payload.get("module", "")
|
|
50
|
+
if module not in self.allowed_modules:
|
|
51
|
+
raise TaskExecutionError(f"module {module!r} is not allowlisted — refusing to run")
|
|
52
|
+
image = payload.get("image")
|
|
53
|
+
if not image:
|
|
54
|
+
raise TaskExecutionError("payload carries no image — the docker runner requires one")
|
|
55
|
+
if not image_is_allowed(image, self.allowed_images, self.allowed_image_prefixes):
|
|
56
|
+
raise TaskExecutionError(f"image {image!r} is not allowlisted — refusing to run")
|
|
57
|
+
|
|
58
|
+
workdir = Path(workdir)
|
|
59
|
+
outdir = workdir / "out"
|
|
60
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
(workdir / "spec.json").write_text(
|
|
62
|
+
json.dumps(
|
|
63
|
+
{
|
|
64
|
+
"task_id": payload.get("task_id", ""),
|
|
65
|
+
"params": payload.get("params", {}),
|
|
66
|
+
# container paths: the task only ever sees /work
|
|
67
|
+
"inputs": {
|
|
68
|
+
name: f"{CONTAINER_WORKDIR}/{Path(path).relative_to(workdir)}"
|
|
69
|
+
for name, path in inputs.items()
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
name = container_name(payload.get("task_id"))
|
|
76
|
+
argv = [
|
|
77
|
+
"docker", "run", "--rm", "--name", name,
|
|
78
|
+
*harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
|
|
79
|
+
image,
|
|
80
|
+
"python", "-m", module,
|
|
81
|
+
"--spec", f"{CONTAINER_WORKDIR}/spec.json",
|
|
82
|
+
"--out", f"{CONTAINER_WORKDIR}/out",
|
|
83
|
+
]
|
|
84
|
+
try:
|
|
85
|
+
proc = subprocess.run(
|
|
86
|
+
argv, capture_output=True, timeout=self.timeout_seconds, check=False
|
|
87
|
+
)
|
|
88
|
+
except subprocess.TimeoutExpired:
|
|
89
|
+
# subprocess.run's timeout kills the docker CLIENT process, not
|
|
90
|
+
# the daemon-side container — it keeps running unless we kill it
|
|
91
|
+
# by name ourselves (same fix as ArgvDockerRunner; the leak was
|
|
92
|
+
# the reason hardening.py's container_name() is shared at all).
|
|
93
|
+
try:
|
|
94
|
+
subprocess.run(["docker", "kill", name], capture_output=True, timeout=10, check=False)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
raise TaskExecutionError(f"task exceeded {self.timeout_seconds}s wall clock")
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
# `docker` missing/removed mid-run raises FileNotFoundError here
|
|
100
|
+
# (a subclass of OSError). Degrade to a failed task, not a dead
|
|
101
|
+
# agent — execute_one only catches TaskExecutionError/LeaseLost.
|
|
102
|
+
raise TaskExecutionError(f"docker is unavailable: {exc}") from exc
|
|
103
|
+
if proc.returncode != 0:
|
|
104
|
+
tail = proc.stderr.decode(errors="replace")[-800:]
|
|
105
|
+
raise TaskExecutionError(f"task exited {proc.returncode}: {tail}")
|
|
106
|
+
if not (outdir / "metrics.json").is_file():
|
|
107
|
+
raise TaskExecutionError("task produced no metrics.json — nothing to commit")
|
|
108
|
+
return outdir
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The container security contract, in one place.
|
|
2
|
+
|
|
3
|
+
Every sandboxed runner builds its `docker run` flags here. Keeping this in a
|
|
4
|
+
single function is deliberate: two runners maintaining their own flag lists
|
|
5
|
+
drift, and the drift is invisible — the runner that quietly lost
|
|
6
|
+
`--cap-drop=ALL` still passes all its behavioural tests.
|
|
7
|
+
|
|
8
|
+
Changing this function changes the guarantee for ALL runners.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path, PureWindowsPath
|
|
18
|
+
|
|
19
|
+
CONTAINER_WORKDIR = "/work"
|
|
20
|
+
|
|
21
|
+
# Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*. We prefix
|
|
22
|
+
# with "flashnode-" (always alnum-first) so the sanitized task_id segment
|
|
23
|
+
# only has to avoid illegal characters, not worry about its own leading
|
|
24
|
+
# character.
|
|
25
|
+
_NAME_ILLEGAL = re.compile(r"[^A-Za-z0-9_.-]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def container_name(task_id: object) -> str:
|
|
29
|
+
"""A Docker-legal, collision-resistant name for this attempt's container.
|
|
30
|
+
|
|
31
|
+
Shared by every sandboxed runner (docker_runner, argv_runner) so a
|
|
32
|
+
timed-out `docker run` can be killed by the SAME name it was launched
|
|
33
|
+
with — the whole reason this lives in hardening.py rather than being
|
|
34
|
+
reimplemented per runner (AGENTS.md: a single security-contract seam,
|
|
35
|
+
not two copies that can drift).
|
|
36
|
+
|
|
37
|
+
task_id comes from an untrusted payload — sanitize it into the name
|
|
38
|
+
rather than interpolating it raw. A random suffix (not task_id alone)
|
|
39
|
+
guarantees uniqueness even when two concurrent attempts share a task_id
|
|
40
|
+
(e.g. a retried attempt racing a slow-to-expire prior one).
|
|
41
|
+
"""
|
|
42
|
+
safe = _NAME_ILLEGAL.sub("-", str(task_id or ""))
|
|
43
|
+
suffix = uuid.uuid4().hex[:8]
|
|
44
|
+
return f"flashnode-{safe}-{suffix}" if safe else f"flashnode-{suffix}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _user_flag() -> list[str]:
|
|
48
|
+
"""The `--user` flag, or nothing, depending on what the platform can
|
|
49
|
+
actually prove about identity.
|
|
50
|
+
|
|
51
|
+
- POSIX (`os.getuid`/`os.getgid` exist): pass the invoking user's real
|
|
52
|
+
uid:gid, exactly as before. Files written into the bind-mounted
|
|
53
|
+
workdir come back owned by the caller, not root.
|
|
54
|
+
- Windows: `os.getuid`/`os.getgid` don't exist, and Docker Desktop does
|
|
55
|
+
not map a host identity into the Linux container the way a native
|
|
56
|
+
Linux daemon does — there is no host uid to pass. Omitting `--user`
|
|
57
|
+
here is safe ONLY because every curated image
|
|
58
|
+
(flashml-cloud/images/*/Dockerfile) ends in a fixed non-root `USER`;
|
|
59
|
+
see that directory's README.md and flashml-cloud's
|
|
60
|
+
docs/superpowers/plans/2026-08-01-windows-hosts.md ("The trap at the
|
|
61
|
+
centre of this plan"). If any curated image ever regresses to
|
|
62
|
+
running as root, this omission silently runs strangers' code as
|
|
63
|
+
container root on every Windows host.
|
|
64
|
+
- Anything else: refuse. Security fields fail closed
|
|
65
|
+
(flashruntime/CLAUDE.md rule 3) — an unrecognised platform must not
|
|
66
|
+
silently produce a container running as an unknown, possibly root,
|
|
67
|
+
identity.
|
|
68
|
+
"""
|
|
69
|
+
if hasattr(os, "getuid"):
|
|
70
|
+
return ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
71
|
+
if sys.platform == "win32":
|
|
72
|
+
return []
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"cannot determine a safe --user for platform {sys.platform!r}: "
|
|
75
|
+
"no os.getuid/os.getgid and not win32 — refusing to run "
|
|
76
|
+
"unprivileged-in-name-only"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bind_mount_source(workdir: Path) -> str:
|
|
81
|
+
"""Render `workdir` as the source half of `-v src:dst`, in a form
|
|
82
|
+
Docker's flag parser won't misinterpret.
|
|
83
|
+
|
|
84
|
+
`-v` splits its argument on ':'. A POSIX path never contains one, so it
|
|
85
|
+
passes through byte-identical (this must not change — see the plan's
|
|
86
|
+
Global Constraints). A Windows path (`C:\\Users\\phong\\...`) contains
|
|
87
|
+
both backslashes AND a drive-letter colon, which is ambiguous with the
|
|
88
|
+
src:dst separator itself — that raw form is what breaks the split.
|
|
89
|
+
|
|
90
|
+
Detecting "is this a Windows path" by `isinstance(..., PureWindowsPath)`
|
|
91
|
+
rather than `sys.platform == "win32"` is deliberate: a real `Path`
|
|
92
|
+
passed in while running on Windows already IS a `WindowsPath`, a
|
|
93
|
+
`PureWindowsPath` subclass, so the same branch handles the live case
|
|
94
|
+
and lets tests exercise it on any host with a synthetic
|
|
95
|
+
`PureWindowsPath` — no `sys.platform` gate to bit-rot in CI.
|
|
96
|
+
|
|
97
|
+
Rewrites `C:\\Users\\phong\\work` to `/c/Users/phong/work`: lowercase
|
|
98
|
+
drive letter, forward slashes, exactly one ':' left in the whole
|
|
99
|
+
argument (the src:dst separator). This is the form Docker Desktop's
|
|
100
|
+
Windows CLI accepts for bind-mount sources.
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(workdir, PureWindowsPath):
|
|
103
|
+
drive = workdir.drive # e.g. "C:" — empty for a driveless path
|
|
104
|
+
if not drive:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Windows workdir {workdir!r} has no drive letter — cannot "
|
|
107
|
+
"build a Docker Desktop bind-mount source from it"
|
|
108
|
+
)
|
|
109
|
+
letter = drive.rstrip(":").lower()
|
|
110
|
+
rest = "/".join(workdir.parts[1:])
|
|
111
|
+
return f"/{letter}/{rest}" if rest else f"/{letter}"
|
|
112
|
+
return str(workdir)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def harden_args(
|
|
116
|
+
workdir: Path,
|
|
117
|
+
*,
|
|
118
|
+
cpus: float,
|
|
119
|
+
memory_gb: float,
|
|
120
|
+
pids_limit: int = 512,
|
|
121
|
+
) -> list[str]:
|
|
122
|
+
"""Docker flags common to every sandboxed task."""
|
|
123
|
+
return [
|
|
124
|
+
# the job never reaches the volunteer's LAN or the internet; the
|
|
125
|
+
# agent is the courier for inputs, outputs, and checkpoints
|
|
126
|
+
"--network", "none",
|
|
127
|
+
"--read-only",
|
|
128
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
|
|
129
|
+
*_user_flag(),
|
|
130
|
+
"--cap-drop=ALL",
|
|
131
|
+
"--security-opt=no-new-privileges",
|
|
132
|
+
f"--pids-limit={pids_limit}",
|
|
133
|
+
"--cpus", str(cpus),
|
|
134
|
+
# equal values: with a larger memory-swap the memory cap is
|
|
135
|
+
# bypassable by swapping
|
|
136
|
+
"--memory", f"{memory_gb}g",
|
|
137
|
+
"--memory-swap", f"{memory_gb}g",
|
|
138
|
+
"--ulimit", "nofile=1024:1024",
|
|
139
|
+
"-v", f"{_bind_mount_source(workdir)}:{CONTAINER_WORKDIR}",
|
|
140
|
+
"-w", CONTAINER_WORKDIR,
|
|
141
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Which container images this host will run, and why it is a prefix.
|
|
2
|
+
|
|
3
|
+
A volunteer installs FlashNode and runs `flashnode work`. They do not build
|
|
4
|
+
an image, do not log in to a registry, and do not maintain a list of image
|
|
5
|
+
references — the built-in allowlist below is what makes that true. When the
|
|
6
|
+
cloud publishes a new curated image, hosts pick it up on their next task
|
|
7
|
+
with no action from their owner.
|
|
8
|
+
|
|
9
|
+
WHY A NAMESPACE PREFIX RATHER THAN EXACT REFERENCES
|
|
10
|
+
---------------------------------------------------
|
|
11
|
+
Exact matching was the original design and it does not survive contact with
|
|
12
|
+
more than a handful of machines: every published image would strand every
|
|
13
|
+
host until its owner edited an env var, which in practice means the fleet
|
|
14
|
+
runs a mix of versions and security fixes never fully land.
|
|
15
|
+
|
|
16
|
+
The prefix is a namespace *we* control and publish to. It is genuinely
|
|
17
|
+
weaker than three fixed strings, and the honest reason that is acceptable
|
|
18
|
+
is this: the coordinator already tells a node which module to run, which
|
|
19
|
+
argv to execute, and which inputs to fetch. An attacker who could name an
|
|
20
|
+
arbitrary image has, by then, long since been able to name arbitrary work.
|
|
21
|
+
The image allowlist defends against a *confused* coordinator naming
|
|
22
|
+
something off-namespace — say `alpine` with a mounted socket — not against
|
|
23
|
+
a fully compromised one. Pinning exact tags buys nothing against the latter
|
|
24
|
+
while costing the whole fleet its ability to be updated.
|
|
25
|
+
|
|
26
|
+
See M1_DECISIONS.md D16.
|
|
27
|
+
|
|
28
|
+
WHAT THIS STILL REFUSES
|
|
29
|
+
-----------------------
|
|
30
|
+
- Anything outside the namespace, including near-misses that merely contain
|
|
31
|
+
it (`evil.example.com/ghcr.io/zolli-labs/flashml-x`).
|
|
32
|
+
- References with no explicit tag or digest. Docker would silently append
|
|
33
|
+
`:latest`, which is a floating pointer to whatever was pushed last — the
|
|
34
|
+
exact mutability the curated-image tags are immutable to avoid.
|
|
35
|
+
- Malformed references, rather than passing them to `docker run` and hoping
|
|
36
|
+
its parser agrees with ours about where the name ends.
|
|
37
|
+
|
|
38
|
+
TWO DIFFERENT KNOBS, TWO DIFFERENT DIRECTIONS
|
|
39
|
+
---------------------------------------------
|
|
40
|
+
- ``FLASHNODE_ALLOWED_IMAGES`` (env, passed here as ``allowed_exact``) is
|
|
41
|
+
ADDITIVE. It is an operator/testing escape hatch — self-hosting the whole
|
|
42
|
+
stack, or an integration test running `python:3.11-alpine`. Anyone who can
|
|
43
|
+
set it can already run anything on that machine, so it grants nothing they
|
|
44
|
+
did not have.
|
|
45
|
+
- ``policy.json`` ``allowed_images`` is SUBTRACTIVE. That is the host
|
|
46
|
+
*owner's* safety policy and it intersects with whatever the above permits
|
|
47
|
+
(see flashnode/config): an owner can forbid more, never permit code the
|
|
48
|
+
build does not trust.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import re
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"DEFAULT_ALLOWED_IMAGE_PREFIXES",
|
|
57
|
+
"InvalidImageReference",
|
|
58
|
+
"image_is_allowed",
|
|
59
|
+
"split_reference",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
#: Namespace the cloud publishes curated task images to. Must stay in step
|
|
63
|
+
#: with REGISTRY_PREFIX in flashml-cloud's apps/api/flashml_cloud_api/
|
|
64
|
+
#: images.py — the e2e suite asserts every reference that API can emit is
|
|
65
|
+
#: accepted here, because this seam has broken four times before and neither
|
|
66
|
+
#: repo's own tests can see it (M1_DECISIONS.md D12).
|
|
67
|
+
DEFAULT_ALLOWED_IMAGE_PREFIXES = frozenset({"ghcr.io/zolli-labs/flashml-"})
|
|
68
|
+
|
|
69
|
+
# Deliberately stricter than the distribution spec. A reference we cannot
|
|
70
|
+
# confidently parse is one we cannot confidently authorize, and every
|
|
71
|
+
# reference we actually need is a plain lowercase path — so the safe reading
|
|
72
|
+
# is to reject the exotic rest rather than approximate it.
|
|
73
|
+
# Structure: an optional `host[:port]/`, then one or more lowercase path
|
|
74
|
+
# components.
|
|
75
|
+
#
|
|
76
|
+
# The leading group exists for the port. A private registry is reached as
|
|
77
|
+
# `registry.example:5000/team/img:2.0`, and while split_reference() gets the
|
|
78
|
+
# tag boundary right (only a ':' after the final '/' can start a tag), the
|
|
79
|
+
# name it hands back still carries that ':5000' — so a name pattern with no
|
|
80
|
+
# room for a port would reject every self-hosted registry as malformed.
|
|
81
|
+
#
|
|
82
|
+
# The trailing path group is `*`, not `+`: a single-component name like
|
|
83
|
+
# `python` is valid Docker Hub shorthand, and an owner who explicitly
|
|
84
|
+
# allowlists one has named something real. It can never match a namespace
|
|
85
|
+
# prefix (those all contain '/'), so permitting the shape costs nothing.
|
|
86
|
+
_NAME_RE = re.compile(
|
|
87
|
+
r"^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]{1,5})?/)?"
|
|
88
|
+
r"[a-z0-9]+(?:[._-][a-z0-9]+)*"
|
|
89
|
+
r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$"
|
|
90
|
+
)
|
|
91
|
+
_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
|
|
92
|
+
_DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InvalidImageReference(ValueError):
|
|
96
|
+
"""A reference that could not be parsed, so could not be authorized."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def split_reference(image: str) -> tuple[str, str]:
|
|
100
|
+
"""Split ``image`` into ``(name, tag_or_digest)``.
|
|
101
|
+
|
|
102
|
+
Raises ``InvalidImageReference`` for anything malformed or lacking an
|
|
103
|
+
explicit version. Splitting is done here, once, rather than at each call
|
|
104
|
+
site: authorizing the *whole* string by prefix would let a crafted tag
|
|
105
|
+
smuggle the namespace past the check, so every caller must be comparing
|
|
106
|
+
the same parsed name this function returns.
|
|
107
|
+
"""
|
|
108
|
+
if not isinstance(image, str) or not image or image.strip() != image:
|
|
109
|
+
raise InvalidImageReference(f"not a usable image reference: {image!r}")
|
|
110
|
+
if any(c.isspace() or ord(c) < 32 for c in image):
|
|
111
|
+
raise InvalidImageReference(f"image reference contains whitespace or control characters: {image!r}")
|
|
112
|
+
|
|
113
|
+
# Digest form wins: `name@sha256:...` has no tag to strip afterwards.
|
|
114
|
+
if "@" in image:
|
|
115
|
+
name, _, digest = image.partition("@")
|
|
116
|
+
version = digest
|
|
117
|
+
if not _DIGEST_RE.match(digest):
|
|
118
|
+
raise InvalidImageReference(f"unsupported digest in {image!r} (want sha256:<64 hex>)")
|
|
119
|
+
else:
|
|
120
|
+
# A tag cannot contain '/', so only a ':' after the final '/' can be
|
|
121
|
+
# a tag separator — anything earlier is a registry port.
|
|
122
|
+
head, sep, tail = image.rpartition(":")
|
|
123
|
+
if sep and "/" not in tail:
|
|
124
|
+
name, version = head, tail
|
|
125
|
+
if not _TAG_RE.match(version):
|
|
126
|
+
raise InvalidImageReference(f"malformed tag in {image!r}")
|
|
127
|
+
else:
|
|
128
|
+
# No explicit version. Docker would default to `:latest`; we
|
|
129
|
+
# refuse instead, because a floating tag means the image a host
|
|
130
|
+
# runs is not the image the cloud validated the job against.
|
|
131
|
+
raise InvalidImageReference(
|
|
132
|
+
f"image {image!r} has no explicit tag or digest — refusing to "
|
|
133
|
+
"let docker default it to :latest"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if ".." in name or not _NAME_RE.match(name):
|
|
137
|
+
raise InvalidImageReference(f"malformed image name in {image!r}")
|
|
138
|
+
return name, version
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def image_is_allowed(
|
|
142
|
+
image: str,
|
|
143
|
+
allowed_exact: frozenset[str] = frozenset(),
|
|
144
|
+
allowed_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Whether this host will run ``image``.
|
|
147
|
+
|
|
148
|
+
Fails closed: an unparseable reference is not allowed, never an
|
|
149
|
+
exception the caller might treat as an unrelated failure.
|
|
150
|
+
|
|
151
|
+
``allowed_exact`` is the host owner's explicit list (env var or
|
|
152
|
+
policy.json) and is matched against the FULL reference — an owner naming
|
|
153
|
+
one exact pinned image means that image, not its namespace.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
name, _version = split_reference(image)
|
|
157
|
+
except InvalidImageReference:
|
|
158
|
+
return False
|
|
159
|
+
if image in allowed_exact:
|
|
160
|
+
return True
|
|
161
|
+
return any(name.startswith(prefix) for prefix in allowed_prefixes)
|