The #206 revert restored the pre-September upload code, which passed --force, while the workflow still pins wrangler 4.69.0. That version's 'r2 object put' has no --force option (it overwrites without prompting) and exits with 'Unknown argument: force', so the v0.20.3 release failed its first R2 upload and no apt, yum or Arch repository was published. Immutable objects are still checked with 'get' before any write. Signed-off-by: trvon <me@trevon.dev>
281 lines
11 KiB
Python
Executable file
281 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Tests for fail-closed stable/experimental repository publication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
MODULE_PATH = ROOT / "scripts" / "ci" / "publish_repository.py"
|
|
SPEC = importlib.util.spec_from_file_location("publish_repository", MODULE_PATH)
|
|
assert SPEC and SPEC.loader
|
|
publish_repository = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = publish_repository
|
|
SPEC.loader.exec_module(publish_repository)
|
|
|
|
PublicationError = publish_repository.PublicationError
|
|
|
|
|
|
class FakeStore:
|
|
def __init__(
|
|
self, existing: dict[str, bytes] | None = None, fail_on: str | None = None
|
|
):
|
|
self.objects = dict(existing or {})
|
|
self.fail_on = fail_on
|
|
self.puts: list[str] = []
|
|
|
|
def get(self, key: str) -> bytes | None:
|
|
return self.objects.get(key)
|
|
|
|
def put(self, key: str, source: Path, content_type: str) -> None:
|
|
del content_type
|
|
if key == self.fail_on:
|
|
raise PublicationError(f"injected failure for {key}")
|
|
self.objects[key] = source.read_bytes()
|
|
self.puts.append(key)
|
|
|
|
|
|
class RepositoryPublicationTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tempdir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.tempdir.name)
|
|
for directory in publish_repository.REPOSITORY_DIRS:
|
|
(self.root / directory).mkdir()
|
|
(self.root / "aptrepo" / "pool").mkdir()
|
|
(self.root / "aptrepo" / "dists").mkdir()
|
|
(self.root / "aptrepo" / "pool" / "yams.deb").write_bytes(b"deb-payload")
|
|
(self.root / "aptrepo" / "dists" / "Release").write_bytes(b"apt-metadata")
|
|
(self.root / "yumrepo" / "yams.rpm").write_bytes(b"rpm-payload")
|
|
(self.root / "yumrepo" / "repomd.xml").write_bytes(b"yum-metadata")
|
|
(self.root / "archrepo" / "yams.pkg.tar.zst").write_bytes(b"arch-payload")
|
|
(self.root / "archrepo" / "yams.db").write_bytes(b"arch-metadata")
|
|
self.public_key = self.root / "public.key"
|
|
self.public_key.write_bytes(b"public-key")
|
|
|
|
def tearDown(self) -> None:
|
|
self.tempdir.cleanup()
|
|
|
|
def manifest(self, channel: str, run_id: int = 42) -> Path:
|
|
path = self.root / "latest.json"
|
|
source_ref = (
|
|
"refs/tags/v1.2.3" if channel == "stable" else "refs/heads/experimental"
|
|
)
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"channel": channel,
|
|
"release_workflow_run_id": run_id,
|
|
"release_workflow_run_attempt": 3,
|
|
"source_sha": "a" * 40,
|
|
"source_ref": source_ref,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
def plan(self, channel: str, release_channel: str | None = None):
|
|
return publish_repository.build_publication_plan(
|
|
self.root,
|
|
self.manifest(release_channel or channel),
|
|
channel,
|
|
42,
|
|
3,
|
|
self.public_key,
|
|
)
|
|
|
|
def test_stable_keys_remain_unprefixed(self) -> None:
|
|
keys = [item.key for item in self.plan("stable")]
|
|
self.assertIn("aptrepo/pool/yams.deb", keys)
|
|
self.assertIn("yumrepo/yams.rpm", keys)
|
|
self.assertIn("archrepo/yams.pkg.tar.zst", keys)
|
|
self.assertIn("gpg.key", keys)
|
|
self.assertEqual(keys[-1], "latest.json")
|
|
self.assertFalse(any(key.startswith("experimental/") for key in keys))
|
|
|
|
def test_experimental_keys_are_strictly_channel_prefixed(self) -> None:
|
|
plan = self.plan("experimental", "nightly")
|
|
allowed = (
|
|
"experimental/aptrepo/",
|
|
"experimental/yumrepo/",
|
|
"experimental/archrepo/",
|
|
)
|
|
for item in plan:
|
|
self.assertTrue(
|
|
item.key == "experimental/latest.json" or item.key.startswith(allowed)
|
|
)
|
|
self.assertIn("experimental/aptrepo/gpg.key", [item.key for item in plan])
|
|
self.assertEqual(plan[-1].key, "experimental/latest.json")
|
|
|
|
def test_stable_and_experimental_plans_share_no_object_keys(self) -> None:
|
|
stable_keys = {item.key for item in self.plan("stable")}
|
|
experimental_keys = {item.key for item in self.plan("experimental", "nightly")}
|
|
self.assertTrue(stable_keys.isdisjoint(experimental_keys))
|
|
|
|
def test_missing_manifest_channel_is_rejected(self) -> None:
|
|
manifest = self.manifest("nightly")
|
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
del data["channel"]
|
|
manifest.write_text(json.dumps(data), encoding="utf-8")
|
|
with self.assertRaisesRegex(PublicationError, "missing or invalid channel"):
|
|
publish_repository.build_publication_plan(
|
|
self.root, manifest, "experimental", 42, 3, self.public_key
|
|
)
|
|
|
|
def test_wrong_channel_is_rejected(self) -> None:
|
|
with self.assertRaisesRegex(PublicationError, "does not match"):
|
|
self.plan("stable", "nightly")
|
|
|
|
def test_wrong_run_id_is_rejected(self) -> None:
|
|
manifest = self.manifest("stable", run_id=41)
|
|
with self.assertRaisesRegex(PublicationError, "does not match selected run"):
|
|
publish_repository.build_publication_plan(
|
|
self.root, manifest, "stable", 42, 3, self.public_key
|
|
)
|
|
|
|
def test_wrong_run_attempt_is_rejected(self) -> None:
|
|
manifest = self.manifest("stable")
|
|
with self.assertRaisesRegex(
|
|
PublicationError, "does not match selected attempt"
|
|
):
|
|
publish_repository.build_publication_plan(
|
|
self.root, manifest, "stable", 42, 2, self.public_key
|
|
)
|
|
|
|
def test_experimental_manifest_with_nonexperimental_ref_is_rejected(self) -> None:
|
|
manifest = self.manifest("nightly")
|
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
data["source_ref"] = "refs/heads/main"
|
|
manifest.write_text(json.dumps(data), encoding="utf-8")
|
|
with self.assertRaisesRegex(PublicationError, "refs/heads/experimental"):
|
|
publish_repository.build_publication_plan(
|
|
self.root, manifest, "experimental", 42, 3, self.public_key
|
|
)
|
|
|
|
def test_identical_immutable_put_is_idempotent(self) -> None:
|
|
plan = self.plan("experimental", "weekly")
|
|
package = next(item for item in plan if item.key.endswith("yams.deb"))
|
|
store = FakeStore({package.key: package.source.read_bytes()})
|
|
publish_repository.publish(plan, store)
|
|
self.assertNotIn(package.key, store.puts)
|
|
self.assertEqual(store.puts[-1], "experimental/latest.json")
|
|
|
|
def test_conflicting_immutable_put_fails_before_metadata_and_latest(self) -> None:
|
|
plan = self.plan("experimental", "nightly")
|
|
package = next(item for item in plan if item.key.endswith("yams.deb"))
|
|
store = FakeStore({package.key: b"different bytes"})
|
|
with self.assertRaisesRegex(PublicationError, "immutable object conflict"):
|
|
publish_repository.publish(plan, store)
|
|
self.assertFalse(any("dists/" in key for key in store.puts))
|
|
self.assertNotIn("experimental/latest.json", store.puts)
|
|
|
|
def test_interrupted_metadata_publication_does_not_advance_latest(self) -> None:
|
|
plan = self.plan("stable")
|
|
metadata = next(item for item in plan if item.phase == "metadata")
|
|
store = FakeStore(fail_on=metadata.key)
|
|
with self.assertRaisesRegex(PublicationError, "injected failure"):
|
|
publish_repository.publish(plan, store)
|
|
self.assertNotIn("latest.json", store.puts)
|
|
|
|
def test_interrupted_latest_write_recovers_on_idempotent_rerun(self) -> None:
|
|
plan = self.plan("experimental", "nightly")
|
|
latest_key = "experimental/latest.json"
|
|
store = FakeStore(fail_on=latest_key)
|
|
with self.assertRaisesRegex(PublicationError, "injected failure"):
|
|
publish_repository.publish(plan, store)
|
|
self.assertNotIn(latest_key, store.objects)
|
|
|
|
store.fail_on = None
|
|
store.puts.clear()
|
|
publish_repository.publish(plan, store)
|
|
self.assertEqual(store.puts[-1], latest_key)
|
|
self.assertEqual(
|
|
store.objects[latest_key], self.manifest("nightly").read_bytes()
|
|
)
|
|
|
|
def test_payloads_then_metadata_then_latest(self) -> None:
|
|
plan = self.plan("stable")
|
|
phases = [item.phase for item in plan]
|
|
order = {"payload": 0, "metadata": 1, "latest": 2}
|
|
self.assertEqual(phases, sorted(phases, key=lambda phase: order[phase]))
|
|
store = FakeStore()
|
|
publish_repository.publish(plan, store)
|
|
self.assertEqual(store.puts[-1], "latest.json")
|
|
|
|
def test_symlink_is_rejected(self) -> None:
|
|
target = self.root / "outside"
|
|
target.write_bytes(b"outside")
|
|
symlink = self.root / "aptrepo" / "escape.deb"
|
|
try:
|
|
symlink.symlink_to(target)
|
|
except OSError:
|
|
self.skipTest("symlinks unavailable")
|
|
with self.assertRaisesRegex(PublicationError, "unsafe symlink"):
|
|
self.plan("stable")
|
|
|
|
def test_wrangler_get_uses_remote_file_semantics(self) -> None:
|
|
result = publish_repository.subprocess.CompletedProcess(
|
|
args=[],
|
|
returncode=1,
|
|
stdout="",
|
|
stderr="The specified key does not exist",
|
|
)
|
|
store = publish_repository.WranglerObjectStore(Path("/wrangler"), "bucket")
|
|
with mock.patch.object(
|
|
publish_repository.subprocess, "run", return_value=result
|
|
) as run:
|
|
self.assertIsNone(store.get("experimental/aptrepo/pkg.deb"))
|
|
|
|
command = run.call_args.args[0]
|
|
self.assertEqual(
|
|
command[:5],
|
|
[
|
|
str(Path("/wrangler")),
|
|
"r2",
|
|
"object",
|
|
"get",
|
|
"bucket/experimental/aptrepo/pkg.deb",
|
|
],
|
|
)
|
|
self.assertIn("--remote", command)
|
|
self.assertIn("--file", command)
|
|
|
|
def test_wrangler_put_uses_noninteractive_remote_file_semantics(self) -> None:
|
|
source = self.root / "payload.deb"
|
|
source.write_bytes(b"payload")
|
|
result = publish_repository.subprocess.CompletedProcess(args=[], returncode=0)
|
|
store = publish_repository.WranglerObjectStore(Path("/wrangler"), "bucket")
|
|
with mock.patch.object(
|
|
publish_repository.subprocess, "run", return_value=result
|
|
) as run:
|
|
store.put("aptrepo/payload.deb", source, "application/octet-stream")
|
|
|
|
command = run.call_args.args[0]
|
|
self.assertEqual(
|
|
command[:5],
|
|
[
|
|
str(Path("/wrangler")),
|
|
"r2",
|
|
"object",
|
|
"put",
|
|
"bucket/aptrepo/payload.deb",
|
|
],
|
|
)
|
|
self.assertIn("--remote", command)
|
|
self.assertEqual(command[command.index("--file") + 1], str(source))
|
|
self.assertEqual(
|
|
command[command.index("--content-type") + 1],
|
|
"application/octet-stream",
|
|
)
|
|
# wrangler 4.x `r2 object put` overwrites without prompting and rejects --force.
|
|
self.assertNotIn("--force", command)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|