630 lines
31 KiB
Python
630 lines
31 KiB
Python
"""Job-local Docker producer contract without starting a container daemon."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import runpy
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
PROFILES = ("checks", "backend", "frontend", "security", "browser", "build")
|
|
BASE_LABELS = {
|
|
"io.buildah.version": "1.35.0",
|
|
"org.opencontainers.image.authors": "Gitea",
|
|
"org.opencontainers.image.created": "2026-10-01T00:01:57Z",
|
|
"org.opencontainers.image.description": "Official docker images used by Gitea runner to run workflows.",
|
|
"org.opencontainers.image.documentation": "https://gitea.com/gitea/runner-images/src/branch/main/README.md",
|
|
"org.opencontainers.image.licenses": "MIT",
|
|
"org.opencontainers.image.title": "Official Gitea Actions runner images",
|
|
"org.opencontainers.image.url": "https://gitea.com/gitea/runner-images",
|
|
"org.opencontainers.image.vendor": "Gitea",
|
|
"org.opencontainers.image.version": "v26.10.01",
|
|
}
|
|
|
|
|
|
def _job_local_producer(
|
|
tmp_path: Path,
|
|
*,
|
|
wrong_driver: bool = False,
|
|
wrong_security: bool = False,
|
|
job_local: bool = True,
|
|
):
|
|
repo = tmp_path / "repository"
|
|
repo.mkdir()
|
|
shutil.copytree(ROOT / "ops" / "runner-task-images", repo / "ops" / "runner-task-images")
|
|
scripts = repo / "scripts"
|
|
scripts.mkdir()
|
|
shutil.copyfile(ROOT / "scripts" / "normalize_ci_task_image_archive.py", scripts / "normalize_ci_task_image_archive.py")
|
|
code = repo / "ops" / "gitea-deploy-broker"
|
|
code.mkdir()
|
|
shutil.copyfile(ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py", code / "runner_profiles.py")
|
|
for args in (
|
|
["init", "-q"],
|
|
["add", "ops", "scripts"],
|
|
["-c", "user.name=Producer test", "-c", "user.email=producer-test@localhost", "commit", "-qm", "fixture"],
|
|
):
|
|
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
|
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
|
fake_bin = tmp_path / "bin"
|
|
fake_bin.mkdir()
|
|
docker = fake_bin / "docker"
|
|
docker.write_text(
|
|
f"#!{sys.executable}\n"
|
|
"import hashlib,io,json,os,sys,tarfile\n"
|
|
"from pathlib import Path\n"
|
|
"a=sys.argv[1:]; rows=Path(os.environ['FAKE_DOCKER_ROWS']); calls=Path(os.environ['FAKE_DOCKER_CALLS'])\n"
|
|
"history=json.loads(calls.read_text()) if calls.exists() else []\n"
|
|
"history.append({'argv':a,'host':os.environ.get('DOCKER_HOST'),'context':os.environ.get('DOCKER_CONTEXT'),'tls':os.environ.get('DOCKER_TLS_VERIFY'),'buildkit':os.environ.get('DOCKER_BUILDKIT')})\n"
|
|
"calls.write_text(json.dumps(history))\n"
|
|
"layerbuf=io.BytesIO()\n"
|
|
"with tarfile.open(fileobj=layerbuf,mode='w:') as t:\n"
|
|
" p=b'producer fixture layer'; x=tarfile.TarInfo('fixture.txt'); x.size=len(p); t.addfile(x,io.BytesIO(p))\n"
|
|
"layer=layerbuf.getvalue(); layer_sha=hashlib.sha256(layer).hexdigest()\n"
|
|
"if a[:1]==['info']:\n"
|
|
" fmt=a[a.index('--format')+1] if '--format' in a else ''\n"
|
|
f" driver={('vfs' if wrong_driver else 'overlay2')!r}\n"
|
|
" security='[\"name=seccomp,profile=builtin\",\"name=rootless\",\"name=cgroupns\"]'\n"
|
|
" if os.environ.get('FAKE_DOCKER_WRONG_SECURITY')=='1': security='[\"name=seccomp,profile=builtin\",\"name=rootless\",\"name=cgroupns\",\"name=unknown\"]'\n"
|
|
" if fmt=='{{.Driver}}|{{.CgroupDriver}}|{{.DockerRootDir}}|{{json .SecurityOptions}}': print(driver+'|none|/home/itworx-builder/.local/share/docker|'+security); sys.exit(0)\n"
|
|
" sys.exit(0)\n"
|
|
"if a[0]=='build':\n"
|
|
" builds=Path(os.environ['FAKE_DOCKER_BUILDS']); argv=json.loads(builds.read_text()) if builds.exists() else []\n"
|
|
" pairs=[a[i+1].split('=',1) for i,x in enumerate(a) if x=='--build-arg']\n"
|
|
" is_build=dict(pairs).get('PROFILE')=='build'\n"
|
|
" staged=Path(a[-1]).is_dir() and all((Path(a[-1])/n).is_file() for n in ['job_docker.py','itworx-ci-job-docker-probe','normalize_ci_task_image_archive.py']) if is_build else True\n"
|
|
" argv.append({'argv':a,'staged':staged}); builds.write_text(json.dumps(argv))\n"
|
|
" b=dict(pairs); profile=b['PROFILE']; ref=a[a.index('--tag')+1]\n"
|
|
f" labels={BASE_LABELS!r}.copy()\n"
|
|
" labels.update({'org.opencontainers.image.source':'https://gitea.example.com/Jens/Unraid-AppOps-Gateway','org.opencontainers.image.revision':b['SOURCE_REVISION'],'tech.itworx.source-tree':b['SOURCE_TREE'],'tech.itworx.ci.profile':profile})\n"
|
|
" config={'architecture':'amd64','os':'linux','config':{'Labels':labels},'rootfs':{'type':'layers','diff_ids':['sha256:'+layer_sha]}}\n"
|
|
" config_bytes=json.dumps(config,sort_keys=True,separators=(',',':')).encode(); image_id='sha256:'+hashlib.sha256(config_bytes).hexdigest()\n"
|
|
" current=json.loads(rows.read_text()) if rows.exists() else []\n"
|
|
" current.append({'Id':image_id,'Os':'linux','Architecture':'amd64','RepoTags':[ref],'Config':{'Labels':labels}})\n"
|
|
" Path(os.environ['FAKE_DOCKER_CONFIGS']+'/'+profile+'.json').write_bytes(config_bytes)\n"
|
|
" rows.write_text(json.dumps(current)); sys.exit(0)\n"
|
|
"if a[:2]==['image','inspect']: print(rows.read_text()); sys.exit(0)\n"
|
|
"if a[:2]==['image','save']:\n"
|
|
" selected=set(a[a.index('--output')+2:]); current=[r for r in json.loads(rows.read_text()) if r['RepoTags'][0] in selected]; items=[]; members={'blobs/sha256/'+layer_sha:layer}\n"
|
|
" for row in current:\n"
|
|
" profile=row['Config']['Labels']['tech.itworx.ci.profile']; config=Path(os.environ['FAKE_DOCKER_CONFIGS']+'/'+profile+'.json').read_bytes(); config_sha=hashlib.sha256(config).hexdigest(); config_path='blobs/sha256/'+config_sha; members[config_path]=config\n"
|
|
" items.append({'Config':config_path,'RepoTags':row['RepoTags'],'Layers':['blobs/sha256/'+layer_sha],'LayerSources':{'sha256:'+layer_sha:{'mediaType':'application/vnd.oci.image.layer.v1.tar','size':len(layer),'digest':'sha256:'+layer_sha}}})\n"
|
|
" members['manifest.json']=json.dumps(items,separators=(',',':')).encode(); members['oci-layout']=b'{\"imageLayoutVersion\":\"1.0.0\"}'; members['index.json']=b'{\"schemaVersion\":2,\"manifests\":[]}'\n"
|
|
" with tarfile.open(a[a.index('--output')+1],mode='w:') as t:\n"
|
|
" for name,payload in members.items():\n"
|
|
" x=tarfile.TarInfo(name); x.size=len(payload); t.addfile(x,io.BytesIO(payload))\n"
|
|
" sys.exit(0)\n"
|
|
"sys.exit('unexpected Docker invocation')\n",
|
|
encoding="utf-8",
|
|
)
|
|
docker.chmod(0o755)
|
|
output = tmp_path / "output"
|
|
env = {
|
|
**os.environ,
|
|
"PATH": str(fake_bin) + os.pathsep + os.environ["PATH"],
|
|
"GITHUB_EVENT_NAME": "push",
|
|
"GITHUB_REF": "refs/heads/main",
|
|
"GITHUB_REPOSITORY": "Jens/Unraid-AppOps-Gateway",
|
|
"DOCKER_HOST": "tcp://shared-dind:2375",
|
|
"DOCKER_CONTEXT": "shared-context",
|
|
"DOCKER_TLS_VERIFY": "1",
|
|
"FAKE_DOCKER_ROWS": str(tmp_path / "docker-rows.json"),
|
|
"FAKE_DOCKER_CALLS": str(tmp_path / "docker-calls.json"),
|
|
"FAKE_DOCKER_BUILDS": str(tmp_path / "docker-builds.json"),
|
|
"FAKE_DOCKER_CONFIGS": str(tmp_path / "docker-configs"),
|
|
"FAKE_DOCKER_WRONG_SECURITY": "1" if wrong_security else "0",
|
|
}
|
|
Path(env["FAKE_DOCKER_CONFIGS"]).mkdir()
|
|
command = ["bash", str(ROOT / "scripts" / "build_ci_task_images.sh"), revision, "123", str(output)]
|
|
if job_local:
|
|
command.append("--job-local")
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=repo,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
tree = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True).strip()
|
|
return result, output, tmp_path, wrong_driver, revision, tree
|
|
|
|
|
|
def _oci_task_image_fixture(
|
|
tmp_path: Path,
|
|
*,
|
|
layer_media_type: str = "application/vnd.oci.image.layer.v1.tar",
|
|
profiles: tuple[str, ...] = PROFILES,
|
|
):
|
|
layer_buffer = io.BytesIO()
|
|
with tarfile.open(fileobj=layer_buffer, mode="w:") as layer_tar:
|
|
payload = b"task image rootfs fixture"
|
|
member = tarfile.TarInfo("fixture.txt")
|
|
member.size = len(payload)
|
|
layer_tar.addfile(member, io.BytesIO(payload))
|
|
layer_bytes = layer_buffer.getvalue()
|
|
layer_sha = hashlib.sha256(layer_bytes).hexdigest()
|
|
labels_base = {
|
|
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
|
"org.opencontainers.image.revision": "a" * 40,
|
|
"tech.itworx.source-tree": "b" * 40,
|
|
}
|
|
rows = []
|
|
source_manifest = []
|
|
members: dict[str, bytes] = {f"blobs/sha256/{layer_sha}": layer_bytes}
|
|
for profile in profiles:
|
|
labels = {**labels_base, "tech.itworx.ci.profile": profile}
|
|
config = {
|
|
"architecture": "amd64",
|
|
"os": "linux",
|
|
"config": {"Labels": labels},
|
|
"rootfs": {"type": "layers", "diff_ids": [f"sha256:{layer_sha}"]},
|
|
}
|
|
config_bytes = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
|
|
config_sha = hashlib.sha256(config_bytes).hexdigest()
|
|
config_path = f"blobs/sha256/{config_sha}"
|
|
members[config_path] = config_bytes
|
|
reference = f"localhost/itworx-ci-{profile}:sha-{'a' * 40}"
|
|
rows.append({
|
|
"Id": f"sha256:{config_sha}",
|
|
"Os": "linux",
|
|
"Architecture": "amd64",
|
|
"RepoTags": [reference],
|
|
"Config": {"Labels": labels},
|
|
})
|
|
source_manifest.append({
|
|
"Config": config_path,
|
|
"RepoTags": [reference],
|
|
"Layers": [f"blobs/sha256/{layer_sha}"],
|
|
"LayerSources": {
|
|
f"sha256:{layer_sha}": {
|
|
"mediaType": layer_media_type,
|
|
"size": len(layer_bytes),
|
|
"digest": f"sha256:{layer_sha}",
|
|
},
|
|
},
|
|
})
|
|
members["manifest.json"] = json.dumps(source_manifest, separators=(",", ":")).encode()
|
|
members["oci-layout"] = b'{"imageLayoutVersion":"1.0.0"}'
|
|
members["index.json"] = b'{"schemaVersion":2,"manifests":[]}'
|
|
source = tmp_path / "source-oci.tar"
|
|
with tarfile.open(source, mode="w:") as archive:
|
|
for name, payload in members.items():
|
|
info = tarfile.TarInfo(name)
|
|
info.size = len(payload)
|
|
archive.addfile(info, io.BytesIO(payload))
|
|
inspection = tmp_path / "inspection.json"
|
|
inspection.write_text(json.dumps(rows), encoding="utf-8")
|
|
output = tmp_path / "normalized.tar"
|
|
return source, inspection, output, rows, layer_bytes
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_archive_normalizer_converts_uncompressed_oci_blobs_to_legacy_docker_save_layout(tmp_path):
|
|
source, inspection, output, rows, layer_bytes = _oci_task_image_fixture(tmp_path)
|
|
result = subprocess.run(
|
|
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), str(source), str(output), str(inspection)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
with tarfile.open(output, mode="r:") as archive:
|
|
members = {member.name: member for member in archive}
|
|
normalized = json.loads(archive.extractfile(members["manifest.json"]).read())
|
|
assert len(normalized) == len(PROFILES)
|
|
assert all(set(item) == {"Config", "RepoTags", "Layers"} for item in normalized)
|
|
for item, expected in zip(normalized, rows, strict=True):
|
|
assert item["Config"] == expected["Id"].removeprefix("sha256:") + ".json"
|
|
assert item["RepoTags"] == expected["RepoTags"]
|
|
assert len(item["Layers"]) == 1
|
|
assert item["Layers"][0].endswith("/layer.tar")
|
|
config_bytes = archive.extractfile(members[item["Config"]]).read()
|
|
assert "sha256:" + hashlib.sha256(config_bytes).hexdigest() == expected["Id"]
|
|
assert json.loads(config_bytes)["config"]["Labels"] == expected["Config"]["Labels"]
|
|
assert archive.extractfile(members[item["Layers"][0]]).read() == layer_bytes
|
|
assert not any(name.startswith("blobs/") for name in members)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_archive_normalizer_rejects_compressed_oci_layers_before_emitting_output(tmp_path):
|
|
source, inspection, output, _rows, _layer_bytes = _oci_task_image_fixture(
|
|
tmp_path,
|
|
layer_media_type="application/vnd.oci.image.layer.v1.tar+gzip",
|
|
)
|
|
result = subprocess.run(
|
|
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), str(source), str(output), str(inspection)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode != 0
|
|
assert "compressed or unsupported layer media type" in result.stderr
|
|
assert not output.exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_archive_normalizer_probe_mode_accepts_and_emits_one_build_image(tmp_path):
|
|
source, inspection, output, rows, _layer_bytes = _oci_task_image_fixture(tmp_path, profiles=("build",))
|
|
result = subprocess.run(
|
|
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), "--probe", str(source), str(output), str(inspection)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
with tarfile.open(output, mode="r:") as archive:
|
|
manifest = json.loads(archive.extractfile("manifest.json").read())
|
|
assert len(manifest) == 1
|
|
assert set(manifest[0]) == {"Config", "RepoTags", "Layers"}
|
|
assert manifest[0]["RepoTags"] == rows[0]["RepoTags"]
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_job_local_mode_builds_all_profiles_through_private_rootless_engine_without_sibling_caps(tmp_path):
|
|
result, output, scratch, _wrong_driver, revision, tree = _job_local_producer(tmp_path)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
calls = json.loads((scratch / "docker-calls.json").read_text())
|
|
assert calls[0] == {
|
|
"argv": ["info", "--format", "{{.Driver}}|{{.CgroupDriver}}|{{.DockerRootDir}}|{{json .SecurityOptions}}"],
|
|
"host": "unix:///run/itworx-job-docker/docker.sock",
|
|
"context": None,
|
|
"tls": None,
|
|
"buildkit": "1",
|
|
}
|
|
builds = json.loads((scratch / "docker-builds.json").read_text())
|
|
assert len(builds) == len(PROFILES)
|
|
assert all(row["staged"] for row in builds)
|
|
build_arguments = [row["argv"] for row in builds]
|
|
assert [next(args[i + 1].split("=", 1)[1] for i, arg in enumerate(args[:-1]) if arg == "--build-arg" and args[i + 1].startswith("PROFILE=")) for args in build_arguments] == list(PROFILES)
|
|
for args in build_arguments:
|
|
assert not any(arg.startswith(("--cpu-period", "--cpu-quota", "--memory", "--cgroup-parent", "--cpus")) for arg in args)
|
|
assert "--rm=true" in args and "--force-rm=true" in args
|
|
|
|
manifest = json.loads((output / "task-image-manifest.json").read_text())
|
|
assert manifest["apiVersion"] == "itworx.runner-task-images/v1"
|
|
assert manifest["sourceRevision"] == revision
|
|
assert manifest["sourceTree"] == tree
|
|
assert tuple(row["profile"] for row in manifest["images"]) == PROFILES
|
|
assert manifest["dockerTarSha256"] == hashlib.sha256((output / "task-images.tar").read_bytes()).hexdigest()
|
|
for row in manifest["images"]:
|
|
assert row["labels"] == {
|
|
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
|
"org.opencontainers.image.revision": revision,
|
|
"tech.itworx.source-tree": tree,
|
|
"tech.itworx.ci.profile": row["profile"],
|
|
}
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_job_local_mode_rejects_non_overlay_or_non_rootless_server_before_build(tmp_path):
|
|
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, wrong_driver=True)
|
|
assert result.returncode != 0
|
|
assert "job-local Docker daemon verification failed" in result.stderr
|
|
assert not (scratch / "docker-builds.json").exists()
|
|
assert not (output / "task-image-manifest.json").exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_job_local_mode_rejects_unknown_daemon_security_options_before_build(tmp_path):
|
|
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, wrong_security=True)
|
|
assert result.returncode != 0
|
|
assert "job-local Docker daemon verification failed" in result.stderr
|
|
assert not (scratch / "docker-builds.json").exists()
|
|
assert not (output / "task-image-manifest.json").exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_bootstrap_mode_keeps_classic_shared_daemon_caps_and_archive_contract(tmp_path):
|
|
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, job_local=False)
|
|
assert result.returncode == 0, result.stderr
|
|
calls = json.loads((scratch / "docker-calls.json").read_text())
|
|
assert calls[0]["argv"] == ["info"]
|
|
assert calls[0]["host"] == "tcp://shared-dind:2375"
|
|
assert calls[0]["buildkit"] == "0"
|
|
builds = json.loads((scratch / "docker-builds.json").read_text())
|
|
assert len(builds) == len(PROFILES)
|
|
assert all(row["staged"] for row in builds)
|
|
build_arguments = [row["argv"] for row in builds]
|
|
assert all(any(arg.startswith("--cpu-quota=") for arg in args) for args in build_arguments)
|
|
assert all(any(arg.startswith("--memory=5120m") for arg in args) for args in build_arguments)
|
|
assert (output / "task-images.tar").is_file()
|
|
with tarfile.open(output / "task-images.tar", mode="r:") as archive:
|
|
manifest = json.loads(archive.extractfile("manifest.json").read())
|
|
assert len(manifest) == len(PROFILES)
|
|
assert all(set(item) == {"Config", "RepoTags", "Layers"} for item in manifest)
|
|
|
|
|
|
def test_build_image_uses_only_minimal_file_capabilities_for_uidmap_helpers():
|
|
dockerfile = (ROOT / "ops" / "runner-task-images" / "build" / "Dockerfile").read_text(encoding="utf-8")
|
|
pins = json.loads((ROOT / "ops" / "runner-task-images" / "pins.json").read_text(encoding="utf-8"))
|
|
assert pins["jobLocalDocker"]["uidmapFileCapabilities"] == {
|
|
"newuidmap": "cap_setuid=ep",
|
|
"newgidmap": "cap_setgid=ep",
|
|
"setuidBits": False,
|
|
}
|
|
assert '"$setcap_bin" cap_setuid=ep "$newuidmap_bin"' in dockerfile
|
|
assert '"$setcap_bin" cap_setgid=ep "$newgidmap_bin"' in dockerfile
|
|
assert 'test "$("$getcap_bin" "$newuidmap_bin")" = "$newuidmap_bin cap_setuid=ep"' in dockerfile
|
|
assert 'test "$("$getcap_bin" "$newgidmap_bin")" = "$newgidmap_bin cap_setgid=ep"' in dockerfile
|
|
assert '! test -u "$newuidmap_bin"' in dockerfile
|
|
assert '! test -u "$newgidmap_bin"' in dockerfile
|
|
|
|
|
|
def test_build_image_declares_exactly_one_private_daemon_data_volume():
|
|
dockerfile = (ROOT / "ops" / "runner-task-images" / "build" / "Dockerfile").read_text(encoding="utf-8")
|
|
pins = json.loads((ROOT / "ops" / "runner-task-images" / "pins.json").read_text(encoding="utf-8"))
|
|
volumes = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("VOLUME")]
|
|
assert volumes == ['VOLUME ["/home/itworx-builder/.local/share/docker"]']
|
|
assert pins["jobLocalDocker"]["dataRoot"] == "/home/itworx-builder/.local/share/docker"
|
|
assert pins["jobLocalDocker"]["anonymousDataVolume"] == volumes[0].split('"')[1]
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
@pytest.mark.parametrize("mode", [0o700, 0o710])
|
|
def test_job_docker_accepts_private_data_root_modes_used_before_and_after_daemon_start(tmp_path, monkeypatch, mode):
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
runtime = tmp_path / "runtime"
|
|
data_root = tmp_path / "data"
|
|
runtime.mkdir(mode=0o700)
|
|
data_root.mkdir(mode=mode)
|
|
os.chmod(runtime, 0o700)
|
|
os.chmod(data_root, mode)
|
|
data_info = data_root.stat()
|
|
monkeypatch.setattr(job_docker, "BUILDER_UID", data_info.st_uid)
|
|
monkeypatch.setattr(job_docker, "BUILDER_GID", data_info.st_gid)
|
|
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
|
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root)
|
|
|
|
job_docker._ensure_runtime()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
@pytest.mark.parametrize("mode", [0o711, 0o750, 0o777])
|
|
def test_job_docker_rejects_unexpected_private_data_root_modes(tmp_path, monkeypatch, mode):
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
runtime = tmp_path / "runtime"
|
|
data_root = tmp_path / "data"
|
|
runtime.mkdir(mode=0o700)
|
|
data_root.mkdir(mode=mode)
|
|
os.chmod(runtime, 0o700)
|
|
os.chmod(data_root, mode)
|
|
data_info = data_root.stat()
|
|
monkeypatch.setattr(job_docker, "BUILDER_UID", data_info.st_uid)
|
|
monkeypatch.setattr(job_docker, "BUILDER_GID", data_info.st_gid)
|
|
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
|
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root)
|
|
|
|
with pytest.raises(job_docker.WrapperError, match="data directory ownership invalid"):
|
|
job_docker._ensure_runtime()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
def test_job_docker_rejects_symlink_private_data_root(tmp_path, monkeypatch):
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
runtime = tmp_path / "runtime"
|
|
real_data_root = tmp_path / "real-data"
|
|
data_root_link = tmp_path / "data-link"
|
|
runtime.mkdir(mode=0o700)
|
|
real_data_root.mkdir(mode=0o700)
|
|
os.chmod(runtime, 0o700)
|
|
os.chmod(real_data_root, 0o700)
|
|
data_root_link.symlink_to(real_data_root, target_is_directory=True)
|
|
real_data_info = real_data_root.stat()
|
|
monkeypatch.setattr(job_docker, "BUILDER_UID", real_data_info.st_uid)
|
|
monkeypatch.setattr(job_docker, "BUILDER_GID", real_data_info.st_gid)
|
|
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
|
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root_link)
|
|
|
|
with pytest.raises(job_docker.WrapperError, match="data directory ownership invalid"):
|
|
job_docker._ensure_runtime()
|
|
|
|
|
|
def test_private_socket_ping_reads_the_complete_bounded_docker_response(monkeypatch):
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
class ChunkedSocket:
|
|
def __init__(self, response: bytes):
|
|
self.response = response
|
|
self.offset = 0
|
|
self.recv_calls = 0
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def settimeout(self, _timeout):
|
|
pass
|
|
|
|
def connect(self, _path):
|
|
pass
|
|
|
|
def sendall(self, _request):
|
|
pass
|
|
|
|
def recv(self, size):
|
|
self.recv_calls += 1
|
|
end = min(self.offset + min(size, 256), len(self.response))
|
|
chunk = self.response[self.offset:end]
|
|
self.offset = end
|
|
return chunk
|
|
|
|
response = (
|
|
b"HTTP/1.1 200 OK\r\nX-Long-Header: "
|
|
+ b"x" * 300
|
|
+ b"\r\nContent-Length: 2\r\n\r\nOK"
|
|
)
|
|
assert len(response) > 256
|
|
|
|
wrapper_socket = ChunkedSocket(response)
|
|
monkeypatch.setattr(
|
|
job_docker,
|
|
"socket",
|
|
type("SocketModule", (), {
|
|
"AF_UNIX": 1,
|
|
"SOCK_STREAM": 2,
|
|
"socket": lambda *_args: wrapper_socket,
|
|
}),
|
|
)
|
|
assert job_docker._socket_ping(Path("/run/itworx-job-docker/docker.sock"))
|
|
assert wrapper_socket.recv_calls == 2
|
|
|
|
probe = runpy.run_path(str(ROOT / "ops" / "runner-task-images" / "build" / "itworx-ci-job-docker-probe"))
|
|
probe_socket = ChunkedSocket(response)
|
|
probe["_socket_ready"].__globals__["socket"] = type("SocketModule", (), {
|
|
"AF_UNIX": 1,
|
|
"SOCK_STREAM": 2,
|
|
"socket": lambda *_args: probe_socket,
|
|
})
|
|
assert probe["_socket_ready"]()
|
|
assert probe_socket.recv_calls == 2
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
def test_job_docker_wrapper_sanitizes_remote_endpoint_environment_and_rejects_cli_overrides(monkeypatch):
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
monkeypatch.setenv("DOCKER_HOST", "tcp://shared-dind:2375")
|
|
monkeypatch.setenv("DOCKER_CONTEXT", "shared-context")
|
|
monkeypatch.setenv("DOCKER_TLS_VERIFY", "1")
|
|
monkeypatch.setenv("DOCKER_CERT_PATH", "/shared/certs")
|
|
monkeypatch.setenv("DOCKER_CONFIG", "/job/config")
|
|
environment = job_docker._client_environment()
|
|
assert environment["DOCKER_HOST"] == "unix:///run/itworx-job-docker/docker.sock"
|
|
assert environment["DOCKER_CONFIG"] == "/job/config"
|
|
assert not any(name in environment for name in ("DOCKER_CONTEXT", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"))
|
|
with pytest.raises(job_docker.WrapperError, match="endpoint override"):
|
|
job_docker._reject_endpoint_override(["--host=tcp://shared-dind:2375", "build"])
|
|
with pytest.raises(job_docker.WrapperError, match="endpoint override"):
|
|
job_docker._reject_endpoint_override(["--context", "shared-context", "info"])
|
|
job_docker._reject_endpoint_override(["run", "alpine", "sh", "-c", "echo --context"])
|
|
assert job_docker._is_local_version_request(["compose", "version"])
|
|
assert job_docker._is_local_version_request(["compose", "--version"])
|
|
assert job_docker._is_local_version_request(["buildx", "version"])
|
|
assert job_docker._is_local_version_request(["buildx", "--version"])
|
|
assert not job_docker._is_local_version_request(["compose", "up"])
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
def test_job_docker_daemon_command_uses_fixed_rootlesskit_and_unix_only_engine():
|
|
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
|
import job_docker
|
|
|
|
command = job_docker._daemon_command()
|
|
assert command[:8] == [
|
|
"/usr/local/bin/rootlesskit",
|
|
"--state-dir=/run/itworx-job-docker/rootlesskit",
|
|
"--net=slirp4netns",
|
|
"--mtu=1500",
|
|
"--disable-host-loopback",
|
|
"--port-driver=builtin",
|
|
"--copy-up=/etc",
|
|
"--copy-up=/run",
|
|
]
|
|
assert command[8:11] == ["/usr/local/bin/docker-init", "--", "/usr/local/bin/dockerd"]
|
|
daemon_args = command[11:]
|
|
assert "--host=unix:///run/itworx-job-docker/docker.sock" in daemon_args
|
|
assert not any(argument.startswith("--host=tcp:") for argument in daemon_args)
|
|
assert "--storage-driver=overlay2" in daemon_args
|
|
assert "--feature=containerd-snapshotter=false" in daemon_args
|
|
assert "--data-root=/home/itworx-builder/.local/share/docker" in daemon_args
|
|
|
|
|
|
def test_nested_build_subordinate_ranges_cover_mapped_nonroot_ids_only():
|
|
dockerfile = (ROOT / "ops/runner-task-images/build/Dockerfile").read_text()
|
|
assert "'ubuntu:1:999' 'ubuntu:1001:64536'" in dockerfile
|
|
assert "sed -i '/^ubuntu:/d'" in dockerfile
|
|
pins = json.loads((ROOT / "ops/runner-task-images/pins.json").read_text())
|
|
ranges = pins["jobLocalDocker"]["subordinateIdRanges"]
|
|
assert ranges == [{"start": 1, "count": 999}, {"start": 1001, "count": 64536}]
|
|
subordinate = {value for row in ranges for value in range(row["start"], row["start"] + row["count"])}
|
|
assert len(subordinate) == 65535
|
|
assert not {0, 1000} & subordinate
|
|
assert min(subordinate) == 1 and max(subordinate) == 65536
|
|
# All normal image IDs, including nobody=65534, fit in the inner namespace.
|
|
assert len(subordinate) >= 65534
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
@pytest.mark.parametrize("bad", ["", "0 0 4294967294\n", "0 1000 1\n1 100000 65535\n",
|
|
"0 1000 1\n100000 100000 65536\n", "SECRET-CANARY\n",
|
|
"0 1000 1\n1 100000 65536\n2 2 1\n", "x" * 257])
|
|
@pytest.mark.parametrize("kind", ["uid", "gid"])
|
|
def test_private_engine_refuses_wrong_parent_mapping_before_runtime_effects(tmp_path, monkeypatch, bad, kind):
|
|
sys.path.insert(0, str(ROOT / "ops/runner-task-images/build"))
|
|
import job_docker
|
|
for name in ("uid", "gid"):
|
|
path = tmp_path / (name + "_map")
|
|
path.write_text(bad if name == kind else "0 1000 1\n1 100000 65536\n")
|
|
monkeypatch.setattr(job_docker, name.upper() + "_MAP_PATH", path)
|
|
effects = []
|
|
monkeypatch.setattr(job_docker, "_ensure_runtime", lambda: effects.append("runtime"))
|
|
monkeypatch.setattr(job_docker, "_launch_daemon", lambda: effects.append("daemon"))
|
|
with pytest.raises(job_docker.WrapperError) as caught:
|
|
job_docker._ensure_daemon()
|
|
assert str(caught.value) in {"private Docker parent ID mapping invalid",
|
|
"private Docker parent ID mapping unavailable"}
|
|
assert "SECRET-CANARY" not in str(caught.value)
|
|
assert effects == []
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
@pytest.mark.parametrize("mapping", [" 0 1000 1\n 1 100000 65536\n",
|
|
" 0 0 4294967295\n"])
|
|
def test_private_engine_accepts_own_supported_parent_maps_and_refuses_missing_map(tmp_path, monkeypatch, mapping):
|
|
sys.path.insert(0, str(ROOT / "ops/runner-task-images/build"))
|
|
import job_docker
|
|
for kind in ("uid", "gid"):
|
|
path = tmp_path / kind
|
|
path.write_text(mapping)
|
|
monkeypatch.setattr(job_docker, kind.upper() + "_MAP_PATH", path)
|
|
job_docker._validate_parent_id_maps()
|
|
job_docker.GID_MAP_PATH.unlink()
|
|
with pytest.raises(job_docker.WrapperError, match="mapping unavailable"):
|
|
job_docker._validate_parent_id_maps()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
@pytest.mark.parametrize("rootful_kind", ["uid", "gid"])
|
|
def test_private_engine_refuses_mixed_supported_parent_maps_before_effects(tmp_path, monkeypatch, rootful_kind):
|
|
sys.path.insert(0, str(ROOT / "ops/runner-task-images/build"))
|
|
import job_docker
|
|
for kind in ("uid", "gid"):
|
|
path = tmp_path / kind
|
|
path.write_text("0 0 4294967295\n" if kind == rootful_kind else "0 1000 1\n1 100000 65536\n")
|
|
monkeypatch.setattr(job_docker, kind.upper() + "_MAP_PATH", path)
|
|
effects = []
|
|
monkeypatch.setattr(job_docker, "_ensure_runtime", lambda: effects.append("runtime"))
|
|
monkeypatch.setattr(job_docker, "_launch_daemon", lambda: effects.append("daemon"))
|
|
with pytest.raises(job_docker.WrapperError, match="mapping invalid"):
|
|
job_docker._ensure_daemon()
|
|
assert effects == []
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
|
def test_private_engine_accepts_actual_supported_runtime_parent_namespace():
|
|
sys.path.insert(0, str(ROOT / "ops/runner-task-images/build"))
|
|
import job_docker
|
|
# Real own namespace from the Linux test container; no host /proc or effects.
|
|
job_docker._validate_parent_id_maps()
|