Files
MoShiro/scripts/test-linux-agent-build.py
justme 63f62956d0
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/e2e Pipeline was successful
ci: skip buildah unshare when the cache cleanup runs as root
2026-10-21 15:42:35 +08:00

246 lines
14 KiB
Python

#!/usr/bin/env python3
"""Linux CI contracts using real staging/tar/shell and a Buildah boundary fake."""
import importlib.util
import fcntl
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
class LinuxBuildTests(unittest.TestCase):
def test_retired_store_cleanup_preserves_recent_locked_and_unrelated_directories(self):
spec = importlib.util.spec_from_file_location('retired_stores', ROOT / 'ci/cleanup-retired-stores.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
names = ['dev-image-build-noble-aot-v1-probe-amd64',
'dev-image-build-noble-aot-v1-agent-arm64',
'dev-node-index-agent', 'linux-agent', 'unrelated']
for name in names:
metadata = base / name / '.local/share/containers/storage/overlay-images/images.json'
metadata.parent.mkdir(parents=True)
metadata.write_text('[]')
if name != names[1]:
old = time.time() - 8 * 86400
os.utime(metadata, (old, old))
with (base / names[2] / '.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
self.assertEqual(module.cleanup(base), [names[0]])
self.assertFalse((base / names[0]).exists())
for name in names[1:]:
self.assertTrue((base / name).is_dir())
def test_restore_context_contains_only_the_executables_dependency_graph_and_is_stable(self):
spec = importlib.util.spec_from_file_location('stage_node', ROOT / 'ci/stage-node-image.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
first, second = base / 'first', base / 'second'
module.stage('agent', 'linux-x64', first)
module.stage('agent', 'linux-musl-arm64', second)
actual = {p.relative_to(first / 'restore/backend').as_posix()
for p in (first / 'restore/backend').rglob('*.csproj')}
expected = {'agent/src/MoShiro.Agent/MoShiro.Agent.csproj',
'fleet/tools/fleet-enroll/fleet-enroll.csproj',
'shared/MoShiro.Measurement/MoShiro.Measurement.csproj',
'shared/MoShiro.Protocol/MoShiro.Protocol.csproj'}
self.assertTrue(expected <= actual)
self.assertFalse(any('/MoShiro.Backend' in name or '/tests/' in name for name in actual))
left = {p.relative_to(first / 'restore'): (p.read_bytes(), p.stat().st_mtime)
for p in (first / 'restore').rglob('*') if p.is_file()}
right = {p.relative_to(second / 'restore'): (p.read_bytes(), p.stat().st_mtime)
for p in (second / 'restore').rglob('*') if p.is_file()}
self.assertEqual(left, right)
self.assertTrue(all(mtime == module.FIXED_MTIME for _, mtime in left.values()))
self.assertTrue((second / 'source/packaging/native/msquic/linux-musl-arm64/libmsquic.a').is_file())
traversal = (first / 'source/backend/Agent.Native.proj').read_text()
self.assertIn('Targets="Restore" BuildInParallel="false"', traversal)
self.assertIn('Targets="Build" BuildInParallel="false"', traversal)
self.assertIn('RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=$(SelfContained)', traversal)
containerfile = (first / 'Containerfile').read_text()
self.assertIn('test -n "$NODE_RID"', containerfile)
self.assertIn('-p:RuntimeIdentifier="$NODE_RID"', containerfile)
def test_retention_removes_only_expired_layers_without_pruning_package_caches(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
buildah = base / 'buildah'
buildah.write_text('''#!/usr/bin/env bash
set -eu
case "$1" in
images) printf 'old %s <none>:<none>\nrecent %s <none>:<none>\nversion %s localhost/moshiro-agent:1.0.0-amd64\nrestore %s localhost/moshiro-agent-restore:0123456789abcdef0123456789abcdef01234567-linux-x64\nactive %s localhost/moshiro-agent:ci-dev-amd64\n' "$OLD_TIME" "$RECENT_TIME" "$OLD_TIME" "$OLD_TIME" "$OLD_TIME" ;;
rmi) printf '%s\n' "$*" >> "$FIXTURE/removed" ;;
unshare) exit 0 ;;
*) exit 9 ;;
esac
''')
buildah.chmod(0o755)
env = dict(os.environ, PATH=str(base) + ':' + os.environ['PATH'], FIXTURE=str(base),
OLD_TIME=str(int(time.time()) - 8 * 86400), RECENT_TIME=str(int(time.time())),
CONTAINERS_STORAGE_CONF=str(base / 'moshiro-storage.conf'))
subprocess.run(['bash', str(ROOT / 'ci/cleanup-build-cache.sh')], env=env, check=True, capture_output=True)
self.assertEqual((base / 'removed').read_text(), 'rmi localhost/moshiro-agent-binaries:linux-musl-x64\nrmi old\nrmi version\nrmi restore\n')
@staticmethod
def oci_archive(path, layers):
"""A minimal OCI archive: each layer is a dict name -> bytes (None = whiteout of that name)."""
import gzip, hashlib, io, json
blobs = {}
def add(data):
digest = hashlib.sha256(data).hexdigest()
blobs[f'blobs/sha256/{digest}'] = data
return 'sha256:' + digest
descriptors = []
for files in layers:
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode='w') as layer:
for name, content in files.items():
if content is None:
directory, _, base = name.rpartition('/')
name, content = f'{directory}/.wh.{base}', b''
entry = tarfile.TarInfo(name)
entry.size = len(content)
layer.addfile(entry, io.BytesIO(content))
descriptors.append({'mediaType': 'application/vnd.oci.image.layer.v1.tar+gzip', 'digest': add(gzip.compress(raw.getvalue()))})
manifest = add(json.dumps({'schemaVersion': 2, 'layers': descriptors}).encode())
with tarfile.open(path, 'w') as archive:
for name, data in {**blobs, 'index.json': json.dumps({'schemaVersion': 2, 'manifests': [{'digest': manifest}]}).encode()}.items():
entry = tarfile.TarInfo(name)
entry.size = len(data)
archive.addfile(entry, io.BytesIO(data))
def test_node_binaries_are_read_from_an_oci_archive_without_mounting(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
(base / 'bin').mkdir()
self.oci_archive(base / 'image.tar', [
{'out/agent/moshiro-agent': b'old agent', 'out/enroll/fleet-enroll': b'enroll', 'out/agent/extra': b'x'},
{'out/agent/moshiro-agent': b'agent'},
])
buildah = base / 'bin/buildah'
buildah.write_text('''#!/usr/bin/env bash
# only `push` exists: any mount/unshare use fails like it does on the CI host
[[ "$1" == push ]] || { echo "memfd_create(): Invalid argument" >&2; exit 1; }
destination="${!#}"
cp "$FIXTURE/image.tar" "${destination#oci-archive:}"
''')
buildah.chmod(0o755)
env = dict(os.environ, PATH=str(base / 'bin') + ':' + os.environ['PATH'], FIXTURE=str(base))
command = ['bash', str(ROOT / 'ci/extract-node-binaries.sh'), 'test-image', str(base / 'output')]
result = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((base / 'output/moshiro-agent').read_bytes(), b'agent') # the upper layer wins
self.assertEqual((base / 'output/fleet-enroll').read_bytes(), b'enroll')
self.assertEqual(os.stat(base / 'output/moshiro-agent').st_mode & 0o777, 0o755)
self.assertFalse((base / 'output/extra').exists())
# a file removed by a later layer is an error, not a stale copy
self.oci_archive(base / 'image.tar', [{'out/agent/moshiro-agent': b'a', 'out/enroll/fleet-enroll': b'e'}, {'out/enroll/fleet-enroll': None}])
result = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('out/enroll/fleet-enroll', result.stderr)
def test_an_image_with_several_tags_still_has_one_id(self):
# buildah images <name> prints one row per tag of the image; identical restore images accumulate one tag per commit
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
buildah = base / 'buildah'
buildah.write_text('''#!/usr/bin/env bash
case "$1" in
images) shift; [[ "${*: -1}" == localhost/missing:x ]] && exit 0
for i in 1 2 3; do echo sha256:030789f05eac3bdb5ac9869725828897bbe39ac56d6274ccf31483e1b01f81ac; done ;;
esac
''')
buildah.chmod(0o755)
env = dict(os.environ, PATH=str(base) + ':' + os.environ['PATH'])
def lookup(name):
return subprocess.run(['bash', '-c', f'set -euo pipefail; source "{ROOT}/ci/buildah-env.sh"; moshiro_image_id {name}'],
env=env, check=True, capture_output=True, text=True).stdout
self.assertEqual(lookup('localhost/found:x'), 'sha256:030789f05eac3bdb5ac9869725828897bbe39ac56d6274ccf31483e1b01f81ac\n')
self.assertEqual(lookup('localhost/missing:x'), '')
def test_cache_cleanup_never_fails_the_step_that_called_it(self):
# 清理在调用方持锁之后才做,依赖已经还原或构建好了:它失败(比如 buildah unshare 报 memfd_create)只能是警告。
for script in ('ci/restore-linux-dependencies.sh', 'ci/build-linux-agent.sh'):
calls = [line for line in (ROOT / script).read_text().splitlines() if 'cleanup-build-cache.sh' in line and not line.lstrip().startswith('#')]
self.assertTrue(calls, script)
for line in calls:
self.assertIn('||', line, f'{script}: {line.strip()}')
cleanup = (ROOT / 'ci/cleanup-build-cache.sh').read_text()
self.assertIn('if ! in_userns python3', cleanup)
self.assertIn('[[ "$(id -u)" == 0 ]]', cleanup) # root 不进 buildah unshare,这台主机上它会报 memfd_create
def test_linux_packages_preserve_installers_and_shared_library_payloads(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
binaries = base / 'binaries'
binaries.mkdir()
for name in ('moshiro-agent', 'fleet-enroll', 'libmsquic.so.2'):
(binaries / name).write_bytes(name.encode())
for rid, suffix in [('linux-x64', 'amd64'), ('linux-arm64', 'arm64'),
('linux-musl-arm64', 'musl-arm64')]:
with self.subTest(rid=rid):
output = base / rid
env = dict(os.environ, CI_COMMIT_TAG='v1.2.3')
subprocess.run(['bash', str(ROOT / 'ci/package-linux-agent.sh'), rid, str(binaries), str(output)], env=env, check=True, capture_output=True)
archive = output / f'moshiro-agent-v1.2.3-linux-{suffix}.tar.gz'
with tarfile.open(archive) as package:
for name in ('moshiro-agent', 'fleet-enroll', 'libmsquic.so.2', 'install.sh',
'systemd/moshiro-agent.service', 'openwrt/moshiro-agent.init', 'LICENSE'):
self.assertIn(name, package.getnames())
self.assertEqual(package.extractfile('moshiro-agent').read(), b'moshiro-agent')
self.assertEqual(package.getmember('moshiro-agent').mode, 0o755)
subprocess.run(['sha256sum', '-c', archive.name + '.sha256'], cwd=output, check=True, capture_output=True)
def test_aot_slots_limit_concurrent_compiles_and_free_on_exit(self):
with tempfile.TemporaryDirectory() as temp:
slots = Path(temp) / 'slots'
env = dict(os.environ, MOSHIRO_AOT_SLOTS='1', MOSHIRO_AOT_SLOT_DIR=str(slots), MOSHIRO_AOT_SLOT_WAIT='1')
script = str(ROOT / 'ci/with-aot-slot.sh')
holder = subprocess.Popen(['bash', script, 'sleep', '5'], env=env)
try:
deadline = time.monotonic() + 5
while not (slots / 'slot-0').exists() and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.3)
busy = subprocess.run(['bash', script, 'true'], env=env, capture_output=True, text=True)
self.assertEqual(busy.returncode, 1, busy.stderr)
self.assertIn('no NativeAOT slot', busy.stderr)
finally:
holder.kill()
holder.wait()
env['MOSHIRO_AOT_SLOT_WAIT'] = '5'
self.assertEqual(subprocess.run(['bash', script, 'sh', '-c', 'exit 7'], env=env).returncode, 7)
def test_web_build_is_shared_with_the_backend_of_the_same_pipeline(self):
with tempfile.TemporaryDirectory() as temp:
temp = Path(temp)
env = dict(os.environ, MOSHIRO_CI_STATE=str(temp / 'state'), CI_REPO_ID='1', CI_PIPELINE_NUMBER='2',
MOSHIRO_SHARED_WEB_WAIT='1')
script = ROOT / 'ci/shared-web.sh'
# 复制一份脚本到假的仓库根,让它读写假的 web 目录
fake = temp / 'repo'
(fake / 'ci').mkdir(parents=True)
(fake / 'ci/shared-web.sh').write_text(script.read_text())
for app in ('display', 'admin'):
(fake / 'web' / app / 'dist').mkdir(parents=True)
(fake / 'web' / app / 'dist/index.html').write_text(app)
run = lambda *args: subprocess.run(['bash', str(fake / 'ci/shared-web.sh'), *args], env=env, capture_output=True, text=True)
self.assertEqual(run('fetch').returncode, 1)
self.assertEqual(run('publish').returncode, 0)
for app in ('display', 'admin'):
(fake / 'web' / app / 'dist/index.html').unlink()
self.assertEqual(run('fetch').returncode, 0)
self.assertEqual((fake / 'web/admin/dist/index.html').read_text(), 'admin')
if __name__ == '__main__':
unittest.main(verbosity=2)