Skip to content

Commit 67de24e

Browse files
committed
Add --image flag for local Docker images with ENTRYPOINT/CMD support
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 2e902b6 commit 67de24e

File tree

2 files changed

+151
-3
lines changed

2 files changed

+151
-3
lines changed

src/sandlock/_image.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Extract local Docker images into rootfs directories for sandboxing.
3+
4+
Uses ``docker create`` + ``docker export`` to extract a locally available
5+
image into a cached rootfs directory. No registry pulling — the image
6+
must already be present in the local Docker storage.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import hashlib
12+
import json
13+
import os
14+
import subprocess
15+
import tarfile
16+
import tempfile
17+
from pathlib import Path
18+
19+
from .exceptions import SandboxError
20+
21+
22+
_CACHE_DIR = Path("~/.cache/sandlock/images").expanduser()
23+
24+
25+
def extract(image: str, cache_dir: Path | None = None) -> str:
26+
"""Extract a local Docker image into a rootfs directory.
27+
28+
Creates a temporary container from the image, exports its filesystem,
29+
and extracts it into a cached directory. Subsequent calls with the
30+
same image name return the cached path immediately.
31+
32+
Args:
33+
image: Docker image name (e.g. "python:3.12-slim", "alpine").
34+
Must already be pulled locally.
35+
cache_dir: Override cache directory (default ~/.cache/sandlock/images).
36+
37+
Returns:
38+
Absolute path to the extracted rootfs directory.
39+
40+
Raises:
41+
SandboxError: If docker is not available or the image is not found.
42+
"""
43+
cache = cache_dir or _CACHE_DIR
44+
cache_key = hashlib.sha256(image.encode()).hexdigest()[:16]
45+
rootfs = cache / cache_key / "rootfs"
46+
47+
# Return cached rootfs if available
48+
if rootfs.is_dir() and any(rootfs.iterdir()):
49+
return str(rootfs)
50+
51+
# Create a temporary container (does not start it)
52+
try:
53+
container_id = subprocess.check_output(
54+
["docker", "create", image, "/bin/true"],
55+
stderr=subprocess.PIPE,
56+
).decode().strip()
57+
except FileNotFoundError:
58+
raise SandboxError("docker CLI not found")
59+
except subprocess.CalledProcessError as e:
60+
raise SandboxError(f"docker create failed: {e.stderr.decode().strip()}")
61+
62+
try:
63+
rootfs.mkdir(parents=True, exist_ok=True)
64+
65+
# Export and extract
66+
with tempfile.NamedTemporaryFile(suffix=".tar", delete=True) as tmp:
67+
subprocess.check_call(
68+
["docker", "export", "-o", tmp.name, container_id],
69+
stderr=subprocess.PIPE,
70+
)
71+
with tarfile.open(tmp.name, "r:*") as tar:
72+
members = [
73+
m for m in tar.getmembers()
74+
if not m.name.startswith("/")
75+
and ".." not in m.name
76+
and m.type not in (tarfile.CHRTYPE, tarfile.BLKTYPE)
77+
]
78+
tar.extractall(rootfs, members=members)
79+
except Exception:
80+
# Clean up partial extraction
81+
import shutil
82+
shutil.rmtree(rootfs, ignore_errors=True)
83+
raise
84+
finally:
85+
subprocess.call(
86+
["docker", "rm", container_id],
87+
stdout=subprocess.DEVNULL,
88+
stderr=subprocess.DEVNULL,
89+
)
90+
91+
return str(rootfs)
92+
93+
94+
def get_default_cmd(image: str) -> list[str]:
95+
"""Get the default command (ENTRYPOINT + CMD) for a local Docker image.
96+
97+
Returns:
98+
Command list, or ["/bin/sh"] if none is configured.
99+
100+
Raises:
101+
SandboxError: If docker inspect fails.
102+
"""
103+
try:
104+
raw = subprocess.check_output(
105+
["docker", "inspect", "--format",
106+
"{{json .Config.Entrypoint}}|{{json .Config.Cmd}}", image],
107+
stderr=subprocess.PIPE,
108+
).decode().strip()
109+
except (FileNotFoundError, subprocess.CalledProcessError):
110+
return ["/bin/sh"]
111+
112+
parts = raw.split("|", 1)
113+
entrypoint = json.loads(parts[0]) if parts[0] != "null" else None
114+
cmd = json.loads(parts[1]) if len(parts) > 1 and parts[1] != "null" else None
115+
116+
if entrypoint and cmd:
117+
return entrypoint + cmd
118+
if entrypoint:
119+
return entrypoint
120+
if cmd:
121+
return cmd
122+
return ["/bin/sh"]

src/sandlock/cli.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ def cmd_run(args: argparse.Namespace) -> int:
2929
if args.strict:
3030
from ._seccomp import DEFAULT_ALLOW_SYSCALLS
3131
cli_kwargs["allow_syscalls"] = DEFAULT_ALLOW_SYSCALLS
32+
if args.image:
33+
from ._image import extract
34+
try:
35+
rootfs = extract(args.image)
36+
except Exception as e:
37+
print(f"error: failed to pull image {args.image!r}: {e}",
38+
file=sys.stderr)
39+
return 1
40+
cli_kwargs["chroot"] = rootfs
41+
cli_kwargs["privileged"] = True
42+
if not args.readable:
43+
cli_kwargs.setdefault("fs_readable", ["/"])
44+
if not args.writable:
45+
cli_kwargs.setdefault("fs_writable", ["/tmp"])
3246
if args.chroot:
3347
cli_kwargs["chroot"] = args.chroot
3448
if args.privileged:
@@ -78,12 +92,22 @@ def cmd_run(args: argparse.Namespace) -> int:
7892
else:
7993
policy = Policy(**cli_kwargs)
8094

95+
# Resolve command: explicit args, or image default, or error
96+
command = args.command
97+
if not command:
98+
if args.image:
99+
from ._image import get_default_cmd
100+
command = get_default_cmd(args.image)
101+
else:
102+
print("error: no command specified", file=sys.stderr)
103+
return 1
104+
81105
sb = Sandbox(policy)
82106

83107
if args.interactive:
84-
result = sb.run_interactive(args.command, timeout=args.timeout)
108+
result = sb.run_interactive(command, timeout=args.timeout)
85109
else:
86-
result = sb.run(args.command, timeout=args.timeout)
110+
result = sb.run(command, timeout=args.timeout)
87111
if result.stdout:
88112
sys.stdout.buffer.write(result.stdout)
89113
if result.stderr:
@@ -174,7 +198,7 @@ def main() -> None:
174198
help="Interactive mode: inherit stdin/stdout/stderr")
175199
run_p.add_argument("-p", "--profile", metavar="NAME",
176200
help="Use a named profile from ~/.config/sandlock/profiles/")
177-
run_p.add_argument("command", nargs="+", help="Command to run")
201+
run_p.add_argument("command", nargs="*", help="Command to run")
178202
run_p.add_argument("-w", "--writable", action="append", help="Writable path")
179203
run_p.add_argument("-r", "--readable", action="append", help="Readable path")
180204
run_p.add_argument("-m", "--memory", help="Memory limit (e.g. 512M)")
@@ -183,6 +207,8 @@ def main() -> None:
183207
run_p.add_argument("-t", "--timeout", type=float, help="Timeout in seconds")
184208
run_p.add_argument("--strict", action="store_true",
185209
help="Allowlist mode: only permit known-safe syscalls")
210+
run_p.add_argument("--image", metavar="IMAGE",
211+
help="Use a Docker/OCI image as root filesystem (e.g. python:3.12-slim)")
186212
run_p.add_argument("--chroot", metavar="PATH",
187213
help="Use directory as root filesystem (requires --privileged)")
188214
run_p.add_argument("--privileged", action="store_true",

0 commit comments

Comments
 (0)