Files
Unraid-AppOps-Gateway-Public/backend/tests/test_runner_task_image_producer.py

265 lines
13 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
import yaml
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 test_post_activation_producer_uses_fixed_job_local_build_route_and_preserves_artifact_contract():
workflow = yaml.safe_load((ROOT / ".gitea/workflows/ci-task-images.yml").read_text(encoding="utf-8"))
events = workflow.get("on", workflow.get(True))
assert events == {"push": {"branches": ["main"]}}
assert workflow["permissions"] == {"contents": "read"}
assert workflow["concurrency"]["cancel-in-progress"] is False
assert set(workflow["jobs"]) == {"build"}
job = workflow["jobs"]["build"]
assert job["runs-on"] == "itworx-ci-build"
assert job["timeout-minutes"] == 90
assert "services" not in job and "container" not in job
checkout, build, upload = job["steps"]
assert checkout["uses"] == "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
assert checkout["with"] == {
"fetch-depth": 1, "persist-credentials": False,
"github-server-url": "https://gitea.example.com",
}
assert build["run"].strip() == (
'set -euo pipefail\n'
'output="${RUNNER_TEMP}/ci-task-images-${GITHUB_SHA}"\n'
'bash scripts/build_ci_task_images.sh "$GITHUB_SHA" "$GITHUB_RUN_ID" "$output" --job-local'
)
assert "env" not in build
assert upload["uses"] == "ChristopherHX/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2"
assert upload.get("if") is None
assert upload["with"] == {
"name": "ci-task-images-${{ github.sha }}",
"path": "${{ runner.temp }}/ci-task-images-${{ github.sha }}/*",
"if-no-files-found": "error", "retention-days": 90,
"compression-level": 6, "overwrite": False,
}
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,
check=False,
)
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()