Skip to content

Commit 6608ad2

Browse files
committed
fix(runtime): normalize bounded adapter image
1 parent 1ad3439 commit 6608ad2

3 files changed

Lines changed: 119 additions & 21 deletions

File tree

‎scripts/build-source-bound-runner.sh‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ NORMALIZED_OCI_TAR="${BUILD_DIR}/runner.oci.tar"
132132
NORMALIZATION_REPORT="${BUILD_DIR}/normalization-report.json"
133133
BUILD_METADATA="${BUILD_DIR}/build-metadata.json"
134134
ADAPTER_RAW_OCI_TAR="${BUILD_DIR}/adapter.raw.oci.tar"
135-
ADAPTER_OCI_LAYOUT="${BUILD_DIR}/adapter-oci-layout"
135+
ADAPTER_RAW_OCI_LAYOUT="${BUILD_DIR}/adapter-oci-layout-raw"
136+
ADAPTER_OCI_LAYOUT="${BUILD_DIR}/adapter-oci-layout-normalized"
137+
ADAPTER_OCI_TAR="${BUILD_DIR}/adapter.oci.tar"
138+
ADAPTER_NORMALIZATION_REPORT="${BUILD_DIR}/adapter-normalization-report.json"
136139
ADAPTER_OCI_REPORT="${BUILD_DIR}/adapter-oci-report.json"
137140
ADAPTER_BUILD_METADATA="${BUILD_DIR}/adapter-build-metadata.json"
138141
BUILDCTL="$(repo_path "node_modules/.cache/counterlab-v6.1/rootless-tools/install-v2.3.1/bin/buildctl")"
@@ -378,7 +381,7 @@ ADAPTER_DOCKERFILE_SHA256="$(sha256sum "${ARCHIVE_ROOT}/services/runner/Dockerfi
378381
exit 2
379382
}
380383

381-
"${ROOT_DIR}/.venv/bin/python" - "${ROOT_DIR}" "${ADAPTER_RAW_OCI_TAR}" "${ADAPTER_OCI_LAYOUT}" <<'PY'
384+
"${ROOT_DIR}/.venv/bin/python" - "${ROOT_DIR}" "${ADAPTER_RAW_OCI_TAR}" "${ADAPTER_RAW_OCI_LAYOUT}" <<'PY'
382385
from pathlib import Path, PurePosixPath
383386
import sys
384387
import tarfile
@@ -402,6 +405,15 @@ with tarfile.open(archive, mode="r:") as source:
402405
source.extractall(destination, filter="data")
403406
PY
404407

408+
"${ROOT_DIR}/.venv/bin/python" scripts/normalize_runner_oci.py \
409+
--repo-root "${ROOT_DIR}" \
410+
--source-layout "${ADAPTER_RAW_OCI_LAYOUT}" \
411+
--output-layout "${ADAPTER_OCI_LAYOUT}" \
412+
--report "${ADAPTER_NORMALIZATION_REPORT}" \
413+
--profile adapter
414+
tar -C "${ADAPTER_OCI_LAYOUT}" -cf "${ADAPTER_OCI_TAR}" \
415+
oci-layout index.json blobs
416+
405417
node - "${ADAPTER_OCI_LAYOUT}" "${ADAPTER_OCI_REPORT}" "${SOURCE_COMMIT}" "${SOURCE_TREE_SHA256}" <<'NODE'
406418
const { createHash } = require("node:crypto");
407419
const { readFileSync, writeFileSync } = require("node:fs");
@@ -465,8 +477,8 @@ writeFileSync(
465477
);
466478
NODE
467479

468-
ADAPTER_OCI_ARCHIVE="$(realpath --relative-to="${ROOT_DIR}" "${ADAPTER_RAW_OCI_TAR}")"
469-
ADAPTER_OCI_ARCHIVE_SHA256="$(sha256sum "${ADAPTER_RAW_OCI_TAR}" | cut -d ' ' -f 1)"
480+
ADAPTER_OCI_ARCHIVE="$(realpath --relative-to="${ROOT_DIR}" "${ADAPTER_OCI_TAR}")"
481+
ADAPTER_OCI_ARCHIVE_SHA256="$(sha256sum "${ADAPTER_OCI_TAR}" | cut -d ' ' -f 1)"
470482

471483
node -e '
472484
const fs = require("node:fs");

‎scripts/normalize_runner_oci.py‎

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424

2525
POLICY_VERSION = "counterlab-runner-nonroot-v3"
26+
ADAPTER_POLICY_VERSION = "counterlab-adapter-nonroot-v1"
2627
RUNNER_UID = 10001
2728
RUNNER_GID = 10001
2829

@@ -96,10 +97,25 @@ def _mode_for(member: tarfile.TarInfo, *, directory: int, regular: int) -> int:
9697

9798

9899
def _normalized_identity(
99-
member: tarfile.TarInfo,
100+
member: tarfile.TarInfo, *, profile: str = "runner"
100101
) -> tuple[int, int, int, str, str] | None:
101102
path = _clean_member_path(member.name)
102103

104+
if profile == "adapter":
105+
if path in {"opt", "opt/counterlab", "workspace", "fixtures"}:
106+
return (0, 0, 0o555, "root", "root")
107+
if path == "opt/counterlab/harness.py":
108+
return (0, 0, 0o555, "root", "root")
109+
if path == "opt/counterlab/counterlab_sdk.py":
110+
return (0, 0, 0o444, "root", "root")
111+
if path == "output":
112+
return (0, 0, 0o755, "root", "root")
113+
if path == "tmp":
114+
return (0, 0, 0o1777, "root", "root")
115+
return None
116+
if profile != "runner":
117+
raise ValueError(f"Unknown OCI normalization profile: {profile}")
118+
103119
if _under(path, "app"):
104120
return (
105121
0,
@@ -221,8 +237,8 @@ def _normalized_identity(
221237
return None
222238

223239

224-
def _apply_policy(member: tarfile.TarInfo) -> bool:
225-
normalized = _normalized_identity(member)
240+
def _apply_policy(member: tarfile.TarInfo, *, profile: str = "runner") -> bool:
241+
normalized = _normalized_identity(member, profile=profile)
226242
if normalized is None:
227243
return False
228244
uid, gid, mode, uname, gname = normalized
@@ -252,14 +268,14 @@ def _apply_policy(member: tarfile.TarInfo) -> bool:
252268

253269

254270
def _copy_normalized_tar(
255-
source: tarfile.TarFile, target: tarfile.TarFile
271+
source: tarfile.TarFile, target: tarfile.TarFile, *, profile: str = "runner"
256272
) -> tuple[int, tuple[str, ...]]:
257273
changed = 0
258274
samples: list[str] = []
259275
for original in source:
260276
member = copy.copy(original)
261277
member.pax_headers = dict(original.pax_headers)
262-
if _apply_policy(member):
278+
if _apply_policy(member, profile=profile):
263279
changed += 1
264280
if len(samples) < 24:
265281
samples.append(_clean_member_path(member.name))
@@ -268,7 +284,9 @@ def _copy_normalized_tar(
268284
return changed, tuple(samples)
269285

270286

271-
def rewrite_layer_bytes(value: bytes) -> tuple[bytes, LayerRewrite]:
287+
def rewrite_layer_bytes(
288+
value: bytes, *, profile: str = "runner"
289+
) -> tuple[bytes, LayerRewrite]:
272290
source_buffer = io.BytesIO(value)
273291
compressed_output = io.BytesIO()
274292
with tarfile.open(fileobj=source_buffer, mode="r:gz") as source:
@@ -283,7 +301,9 @@ def rewrite_layer_bytes(value: bytes) -> tuple[bytes, LayerRewrite]:
283301
with tarfile.open(
284302
fileobj=digesting, mode="w|", format=tarfile.PAX_FORMAT
285303
) as target:
286-
changed, samples = _copy_normalized_tar(source, target)
304+
changed, samples = _copy_normalized_tar(
305+
source, target, profile=profile
306+
)
287307
diff_id = f"sha256:{digesting.hexdigest}"
288308
output = compressed_output.getvalue()
289309
return output, LayerRewrite(
@@ -295,17 +315,19 @@ def rewrite_layer_bytes(value: bytes) -> tuple[bytes, LayerRewrite]:
295315
)
296316

297317

298-
def _layer_needs_rewrite(path: Path) -> bool:
318+
def _layer_needs_rewrite(path: Path, *, profile: str) -> bool:
299319
with tarfile.open(path, mode="r:gz") as source:
300320
for original in source:
301321
member = copy.copy(original)
302322
member.pax_headers = dict(original.pax_headers)
303-
if _apply_policy(member):
323+
if _apply_policy(member, profile=profile):
304324
return True
305325
return False
306326

307327

308-
def _rewrite_layer_file(source_path: Path, temporary_path: Path) -> LayerRewrite:
328+
def _rewrite_layer_file(
329+
source_path: Path, temporary_path: Path, *, profile: str
330+
) -> LayerRewrite:
309331
with source_path.open("rb") as raw_source, tarfile.open(
310332
fileobj=raw_source, mode="r:gz"
311333
) as source, temporary_path.open("xb") as raw_target:
@@ -320,7 +342,9 @@ def _rewrite_layer_file(source_path: Path, temporary_path: Path) -> LayerRewrite
320342
with tarfile.open(
321343
fileobj=digesting, mode="w|", format=tarfile.PAX_FORMAT
322344
) as target:
323-
changed, samples = _copy_normalized_tar(source, target)
345+
changed, samples = _copy_normalized_tar(
346+
source, target, profile=profile
347+
)
324348
diff_id = f"sha256:{digesting.hexdigest}"
325349
return LayerRewrite(
326350
compressed_sha256=_sha256_file(temporary_path),
@@ -363,7 +387,12 @@ def _write_blob(directory: Path, value: bytes) -> tuple[str, int]:
363387

364388

365389
def normalize_layout(
366-
*, repo_root: Path, source_layout: Path, output_layout: Path, report_path: Path
390+
*,
391+
repo_root: Path,
392+
source_layout: Path,
393+
output_layout: Path,
394+
report_path: Path,
395+
profile: str = "runner",
367396
) -> dict[str, Any]:
368397
root = repo_root.resolve(strict=True)
369398
source = source_layout.resolve(strict=True)
@@ -397,8 +426,13 @@ def normalize_layout(
397426
diff_ids = config.get("rootfs", {}).get("diff_ids")
398427
if not isinstance(diff_ids, list) or len(diff_ids) != len(layers):
399428
raise ValueError("Runner OCI rootfs diff_ids do not match its layers")
400-
if config.get("config", {}).get("User") != "10001:10001":
401-
raise ValueError("Runner OCI config must declare user 10001:10001")
429+
expected_user = "10001:10001" if profile == "runner" else "65532:65532"
430+
if profile not in {"runner", "adapter"}:
431+
raise ValueError(f"Unknown OCI normalization profile: {profile}")
432+
if config.get("config", {}).get("User") != expected_user:
433+
raise ValueError(
434+
f"{profile.title()} OCI config must declare user {expected_user}"
435+
)
402436

403437
output_layout.mkdir()
404438
blob_directory = output_layout / "blobs" / "sha256"
@@ -414,7 +448,7 @@ def normalize_layout(
414448
if layer.get("mediaType") != "application/vnd.oci.image.layer.v1.tar+gzip":
415449
raise ValueError("Runner OCI normalization requires gzip OCI layers")
416450
layer_path = _source_blob(source, layer)
417-
if not _layer_needs_rewrite(layer_path):
451+
if not _layer_needs_rewrite(layer_path, profile=profile):
418452
digest = _digest_value(layer)
419453
shutil.copyfile(layer_path, blob_directory / digest)
420454
updated_layers.append(dict(layer))
@@ -425,7 +459,7 @@ def normalize_layout(
425459
continue
426460

427461
temporary = blob_directory / f"normalized-layer-{index_value}.tar.gz"
428-
rewrite = _rewrite_layer_file(layer_path, temporary)
462+
rewrite = _rewrite_layer_file(layer_path, temporary, profile=profile)
429463
final_path = blob_directory / rewrite.compressed_sha256
430464
if final_path.exists():
431465
raise ValueError("Normalized layer digest unexpectedly already exists")
@@ -469,7 +503,9 @@ def normalize_layout(
469503

470504
report = {
471505
"schemaVersion": "1",
472-
"policyVersion": POLICY_VERSION,
506+
"policyVersion": (
507+
POLICY_VERSION if profile == "runner" else ADAPTER_POLICY_VERSION
508+
),
473509
"status": "NORMALIZED",
474510
"sourceManifestDigest": manifest_descriptor["digest"],
475511
"normalizedManifestDigest": f"sha256:{manifest_digest}",
@@ -489,12 +525,16 @@ def main(argv: Sequence[str] | None = None) -> int:
489525
parser.add_argument("--source-layout", type=Path, required=True)
490526
parser.add_argument("--output-layout", type=Path, required=True)
491527
parser.add_argument("--report", type=Path, required=True)
528+
parser.add_argument(
529+
"--profile", choices=("runner", "adapter"), default="runner"
530+
)
492531
args = parser.parse_args(argv)
493532
report = normalize_layout(
494533
repo_root=args.repo_root,
495534
source_layout=args.source_layout,
496535
output_layout=args.output_layout,
497536
report_path=args.report,
537+
profile=args.profile,
498538
)
499539
print(json.dumps(report, indent=2, sort_keys=True))
500540
return 0

‎scripts/test_normalize_runner_oci.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,49 @@ def test_rewrite_is_deterministic() -> None:
138138

139139
assert first == second
140140
assert first_report == second_report
141+
142+
143+
def test_adapter_profile_repairs_only_bounded_nonroot_paths() -> None:
144+
output = io.BytesIO()
145+
with gzip.GzipFile(
146+
filename="", mode="wb", fileobj=output, mtime=0
147+
) as compressed:
148+
with tarfile.open(
149+
fileobj=compressed, mode="w|", format=tarfile.PAX_FORMAT
150+
) as archive:
151+
for name, kind, body in (
152+
("opt/", tarfile.DIRTYPE, b""),
153+
("opt/counterlab/", tarfile.DIRTYPE, b""),
154+
("opt/counterlab/harness.py", tarfile.REGTYPE, b"harness"),
155+
(
156+
"opt/counterlab/counterlab_sdk.py",
157+
tarfile.REGTYPE,
158+
b"sdk",
159+
),
160+
("workspace/", tarfile.DIRTYPE, b""),
161+
("fixtures/", tarfile.DIRTYPE, b""),
162+
("output/", tarfile.DIRTYPE, b""),
163+
("tmp/", tarfile.DIRTYPE, b""),
164+
("unrelated", tarfile.REGTYPE, b"unchanged"),
165+
):
166+
member = tarfile.TarInfo(name)
167+
member.type = kind
168+
member.mode = 0o700
169+
member.uid = 0
170+
member.gid = 0
171+
member.size = len(body)
172+
archive.addfile(member, io.BytesIO(body) if body else None)
173+
174+
normalized, report = rewrite_layer_bytes(output.getvalue(), profile="adapter")
175+
members = _members(normalized)
176+
177+
assert report.changed_entries == 8
178+
assert members["opt"].mode == 0o555
179+
assert members["opt/counterlab"].mode == 0o555
180+
assert members["opt/counterlab/harness.py"].mode == 0o555
181+
assert members["opt/counterlab/counterlab_sdk.py"].mode == 0o444
182+
assert members["workspace"].mode == 0o555
183+
assert members["fixtures"].mode == 0o555
184+
assert members["output"].mode == 0o755
185+
assert members["tmp"].mode == 0o1777
186+
assert members["unrelated"].mode == 0o700

0 commit comments

Comments
 (0)