231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""Exercise the real producer script against Docker's inherited-label shape."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
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")
|
|
# Captured from the SHA-verified immutable amd64 base image config c474d435...
|
|
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 _run_producer(tmp_path: Path, mutation: str = ""):
|
|
repo = tmp_path / "repository"
|
|
repo.mkdir()
|
|
shutil.copytree(ROOT / "ops" / "runner-task-images", repo / "ops" / "runner-task-images")
|
|
code = repo / "ops" / "gitea-deploy-broker"
|
|
code.mkdir()
|
|
shutil.copyfile(ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py", code / "runner_profiles.py")
|
|
scripts = repo / "scripts"
|
|
scripts.mkdir()
|
|
shutil.copyfile(
|
|
ROOT / "scripts" / "normalize_ci_task_image_archive.py",
|
|
scripts / "normalize_ci_task_image_archive.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()
|
|
tree = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], 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:]; p=Path(os.environ['FAKE_DOCKER_ROWS'])\n"
|
|
"layer_buffer=io.BytesIO()\n"
|
|
"with tarfile.open(fileobj=layer_buffer,mode='w:') as layer_tar:\n"
|
|
" payload=b'producer fixture layer'; member=tarfile.TarInfo('fixture.txt'); member.size=len(payload); layer_tar.addfile(member,io.BytesIO(payload))\n"
|
|
"layer=layer_buffer.getvalue(); layer_sha=hashlib.sha256(layer).hexdigest()\n"
|
|
"if a==['info']: sys.exit(0)\n"
|
|
"if a[0]=='build':\n"
|
|
" builds=Path(os.environ['FAKE_DOCKER_BUILD_ARGS'])\n"
|
|
" argv=json.loads(builds.read_text()) if builds.exists() else []\n"
|
|
" argv.append(a); builds.write_text(json.dumps(argv))\n"
|
|
" pairs=[a[i+1].split('=',1) for i,x in enumerate(a) if x=='--build-arg']\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"
|
|
" mutation=os.environ.get('FAKE_LABEL_MUTATION','')\n"
|
|
" if mutation=='extra': labels['unexpected.label']='unexpected'\n"
|
|
" if mutation=='missing-inherited': labels.pop('org.opencontainers.image.vendor')\n"
|
|
" if mutation=='changed-inherited': labels['org.opencontainers.image.created']='different'\n"
|
|
" if mutation=='wrong-source': labels['org.opencontainers.image.source']='https://example.invalid'\n"
|
|
" if mutation=='missing-profile': labels.pop('tech.itworx.ci.profile')\n"
|
|
" config={'architecture':'amd64','os':'linux','config':{'User':'ubuntu','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"
|
|
" rows=json.loads(p.read_text()) if p.exists() else []\n"
|
|
" rows.append({'Id':image_id,'Os':'linux','Architecture':'amd64','RepoTags':[ref],'Config':{'User':'ubuntu','Labels':labels}})\n"
|
|
" configs=Path(os.environ['FAKE_DOCKER_CONFIGS']); configs.mkdir(exist_ok=True); (configs/(profile+'.json')).write_bytes(config_bytes)\n"
|
|
" p.write_text(json.dumps(rows)); sys.exit(0)\n"
|
|
"if a[:2]==['image','inspect']: print(p.read_text()); sys.exit(0)\n"
|
|
"if a[:2]==['image','save']:\n"
|
|
" selected=set(a[a.index('--output')+2:]); rows=json.loads(p.read_text()); members={'blobs/sha256/'+layer_sha:layer}; manifest=[]\n"
|
|
" for row in rows:\n"
|
|
" if row['RepoTags'][0] not in selected: continue\n"
|
|
" reference=row['RepoTags'][0]; profile=reference.split('itworx-ci-',1)[1].split(':sha-',1)[0]; 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"
|
|
" manifest.append({'Config':config_path,'RepoTags':[reference],'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(manifest,separators=(',',':')).encode(); members['oci-layout']=json.dumps({'imageLayoutVersion':'1.0.0'}).encode(); members['index.json']=json.dumps({'schemaVersion':2,'manifests':[]}).encode()\n"
|
|
" with tarfile.open(a[a.index('--output')+1],mode='w:') as archive:\n"
|
|
" for name,payload in members.items():\n"
|
|
" member=tarfile.TarInfo(name); member.size=len(payload); archive.addfile(member,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",
|
|
"FAKE_DOCKER_ROWS": str(tmp_path / "docker-rows.json"),
|
|
"FAKE_DOCKER_BUILD_ARGS": str(tmp_path / "docker-build-args.json"),
|
|
"FAKE_DOCKER_CONFIGS": str(tmp_path / "docker-configs"),
|
|
"FAKE_LABEL_MUTATION": mutation,
|
|
}
|
|
Path(env["FAKE_DOCKER_CONFIGS"]).mkdir()
|
|
result = subprocess.run(
|
|
["bash", str(ROOT / "scripts" / "build_ci_task_images.sh"), revision, "123", str(output)],
|
|
cwd=repo,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
return result, output, revision, tree
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
def test_producer_accepts_exact_pinned_base_metadata_and_keeps_provenance_manifest(tmp_path):
|
|
result, output, revision, tree = _run_producer(tmp_path)
|
|
assert result.returncode == 0, result.stderr
|
|
build_args = json.loads((tmp_path / "docker-build-args.json").read_text())
|
|
assert len(build_args) == len(PROFILES)
|
|
expected_limits = {
|
|
"--cpu-period=100000",
|
|
"--cpu-quota=300000",
|
|
"--memory=5120m",
|
|
"--memory-swap=5120m",
|
|
"--cgroup-parent=docker",
|
|
"--rm=true",
|
|
"--force-rm=true",
|
|
}
|
|
profiles = [
|
|
next(
|
|
args[index + 1]
|
|
for index, arg in enumerate(args[:-1])
|
|
if arg == "--build-arg" and args[index + 1].startswith("PROFILE=")
|
|
)
|
|
for args in build_args
|
|
]
|
|
assert profiles == [f"PROFILE={profile}" for profile in PROFILES]
|
|
for args in build_args:
|
|
resource_flags = [
|
|
arg
|
|
for arg in args
|
|
if arg.startswith(
|
|
(
|
|
"--cpu-period",
|
|
"--cpu-quota",
|
|
"--memory",
|
|
"--cgroup-parent",
|
|
"--rm",
|
|
"--force-rm",
|
|
"--cpus",
|
|
)
|
|
)
|
|
]
|
|
assert set(resource_flags) == expected_limits
|
|
assert len(resource_flags) == len(expected_limits)
|
|
manifest = json.loads((output / "task-image-manifest.json").read_text())
|
|
assert manifest["sourceRevision"] == revision
|
|
assert manifest["sourceTree"] == tree
|
|
assert tuple(row["profile"] for row in manifest["images"]) == PROFILES
|
|
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"],
|
|
}
|
|
archive_path = output / "task-images.tar"
|
|
archive_bytes = archive_path.read_bytes()
|
|
assert manifest["dockerTarSha256"] == hashlib.sha256(archive_bytes).hexdigest()
|
|
assert manifest["dockerTarBytes"] == len(archive_bytes)
|
|
inspected = {
|
|
image["RepoTags"][0]: image
|
|
for image in json.loads((tmp_path / "docker-rows.json").read_text())
|
|
}
|
|
with tarfile.open(archive_path, 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, row in zip(normalized, manifest["images"], strict=True):
|
|
assert item["RepoTags"] == [row["reference"]]
|
|
assert item["Config"] == row["imageId"].removeprefix("sha256:") + ".json"
|
|
assert len(item["Layers"]) == 1
|
|
image = inspected[row["reference"]]
|
|
config_bytes = archive.extractfile(members[item["Config"]]).read()
|
|
config = json.loads(config_bytes)
|
|
assert "sha256:" + hashlib.sha256(config_bytes).hexdigest() == image["Id"] == row["imageId"]
|
|
assert config["config"]["User"] == "ubuntu"
|
|
assert config["config"]["Labels"] == image["Config"]["Labels"] == {
|
|
**BASE_LABELS,
|
|
**row["labels"],
|
|
}
|
|
assert config["rootfs"]["type"] == "layers"
|
|
assert len(config["rootfs"]["diff_ids"]) == 1
|
|
diff_id = config["rootfs"]["diff_ids"][0]
|
|
layer_name = item["Layers"][0]
|
|
assert layer_name == diff_id.removeprefix("sha256:") + "/layer.tar"
|
|
layer_bytes = archive.extractfile(members[layer_name]).read()
|
|
assert "sha256:" + hashlib.sha256(layer_bytes).hexdigest() == diff_id
|
|
assert not any(name.startswith("blobs/") for name in members)
|
|
assert not (output / ".task-images.raw.tar").exists()
|
|
assert not (output / "images.inspect.json").exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
|
@pytest.mark.parametrize("mutation", ("extra", "missing-inherited", "changed-inherited", "wrong-source", "missing-profile"))
|
|
def test_producer_rejects_any_untrusted_image_label_change(tmp_path, mutation):
|
|
result, output, _revision, _tree = _run_producer(tmp_path, mutation)
|
|
assert result.returncode != 0
|
|
expected_error = (
|
|
"task image archive normalization failed: image inspection provenance invalid"
|
|
if mutation in {"wrong-source", "missing-profile"}
|
|
else "image identity verification failed"
|
|
)
|
|
assert expected_error in result.stderr
|
|
assert not (output / "task-image-manifest.json").exists()
|