taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

test_taler_repos.py (20789B)


      1 # This file is in the public domain.
      2 
      3 """Run from the repository root with python3 -m unittest discover -s tools -v."""
      4 
      5 import importlib.machinery
      6 import importlib.util
      7 import io
      8 import json
      9 import os
     10 import subprocess
     11 import sys
     12 import tempfile
     13 import unittest
     14 from contextlib import redirect_stderr, redirect_stdout
     15 from pathlib import Path
     16 from unittest.mock import call, patch
     17 
     18 SCRIPT = Path(__file__).with_name("taler-repos")
     19 loader = importlib.machinery.SourceFileLoader("taler_repos", str(SCRIPT))
     20 spec = importlib.util.spec_from_loader(loader.name, loader)
     21 taler_repos = importlib.util.module_from_spec(spec)
     22 loader.exec_module(taler_repos)
     23 
     24 
     25 class BuildOrderTests(unittest.TestCase):
     26     EXPECTED = (
     27         "gnunet",
     28         "exchange",
     29         "donau",
     30         "merchant",
     31         "anastasis",
     32         "challenger",
     33         "paivana",
     34         "taler-mdb",
     35         "taler-typescript-core",
     36         "libeufin",
     37         "taler-merchant-demos",
     38         "taler-rust",
     39     )
     40 
     41     def setUp(self):
     42         temporary = tempfile.TemporaryDirectory()
     43         self.addCleanup(temporary.cleanup)
     44         self.root = Path(temporary.name)
     45         self.enterContext(patch.object(taler_repos, "ROOT", self.root))
     46 
     47     def test_order_without_deployment_checkout(self):
     48         self.assertFalse((self.root / "taler-deployment").exists())
     49         self.assertEqual(taler_repos.build_order(), list(self.EXPECTED))
     50 
     51     def test_all_dependencies_precede_consumers_when_repository_order_changes(self):
     52         components = tuple(reversed(taler_repos.REPOSITORIES))
     53         with patch.object(taler_repos, "REPOSITORIES", components):
     54             order = taler_repos.build_order()
     55         for name, dependencies in taler_repos.BUILD_DEPENDENCIES.items():
     56             for dependency in dependencies:
     57                 self.assertLess(order.index(dependency), order.index(name))
     58         self.assertEqual(order[0], "taler-rust")
     59 
     60     def test_cycle_reports_the_components(self):
     61         with (
     62             patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("merchant",)}),
     63             self.assertRaisesRegex(
     64                 taler_repos.BuildError,
     65                 "cycle: gnunet -> merchant -> gnunet",
     66             ),
     67         ):
     68             taler_repos.build_order()
     69 
     70     def test_unknown_repository_or_dependency(self):
     71         for entry, error in (
     72             ({"missing": ()}, "unknown repository in build dependencies: missing"),
     73             ({"gnunet": ("missing",)}, "gnunet: unknown build dependency missing"),
     74         ):
     75             with (
     76                 self.subTest(entry=entry),
     77                 patch.dict(taler_repos.BUILD_DEPENDENCIES, entry),
     78                 self.assertRaisesRegex(taler_repos.BuildError, error),
     79             ):
     80                 taler_repos.build_order()
     81 
     82     def test_build_pulls_every_repository_before_building_in_order(self):
     83         with (
     84             patch.object(
     85                 taler_repos, "repository", side_effect=lambda n: self.root / n
     86             ),
     87             patch.object(taler_repos, "preflight") as preflight,
     88             patch.object(taler_repos, "run") as run,
     89             redirect_stdout(io.StringIO()),
     90         ):
     91             taler_repos.build()
     92         repos = [self.root / name for name in self.EXPECTED]
     93         preflight.assert_called_once_with(repos)
     94         calls = run.call_args_list
     95         self.assertEqual(
     96             calls[: len(repos)], [call(r, "git", "pull", "--ff-only") for r in repos]
     97         )
     98         builds = calls[len(repos) :]
     99         self.assertEqual(len(builds), 4 * len(repos))
    100         for index, repo in enumerate(repos):
    101             commands = builds[4 * index : 4 * index + 4]
    102             self.assertEqual(commands[0], call(repo, "./bootstrap"))
    103             self.assertEqual(commands[1].args[:2], (repo, "./configure"))
    104             self.assertEqual(commands[2].args[:2], (repo, "make"))
    105             self.assertEqual(commands[3], call(repo, "make", "install"))
    106 
    107     def test_invalid_dependencies_stop_before_repository_commands(self):
    108         with (
    109             patch.dict(taler_repos.BUILD_DEPENDENCIES, {"gnunet": ("missing",)}),
    110             patch.object(taler_repos, "repository") as repository,
    111             self.assertRaises(taler_repos.BuildError),
    112         ):
    113             taler_repos.build()
    114         repository.assert_not_called()
    115 
    116 
    117 class WorkspaceTests(unittest.TestCase):
    118     def test_default_workspace_is_the_current_directory(self):
    119         with tempfile.TemporaryDirectory() as temporary:
    120             result = subprocess.run(
    121                 [
    122                     "python3",
    123                     "-B",
    124                     "-c",
    125                     "import runpy, sys; print(runpy.run_path(sys.argv[1])['ROOT'])",
    126                     str(SCRIPT),
    127                 ],
    128                 cwd=temporary,
    129                 check=True,
    130                 capture_output=True,
    131                 text=True,
    132             )
    133             self.assertEqual(Path(result.stdout.strip()), Path(temporary))
    134 
    135     def test_explicit_workspace_is_used_from_another_directory(self):
    136         with (
    137             tempfile.TemporaryDirectory() as temporary,
    138             patch.object(taler_repos, "ROOT", Path.cwd()),
    139             patch("sys.argv", [str(SCRIPT), "--root", temporary, "versions"]),
    140             patch.object(taler_repos, "versions") as versions,
    141         ):
    142             self.assertEqual(taler_repos.main(), 0)
    143             versions.assert_called_once_with()
    144             self.assertEqual(taler_repos.ROOT, Path(temporary).resolve())
    145 
    146 
    147 class ForeachTests(unittest.TestCase):
    148     def setUp(self):
    149         temporary = tempfile.TemporaryDirectory()
    150         self.addCleanup(temporary.cleanup)
    151         self.root = Path(temporary.name) / "workspace with spaces"
    152         self.root.mkdir()
    153         self.enterContext(
    154             patch.dict(
    155                 os.environ,
    156                 {
    157                     "GIT_CONFIG_NOSYSTEM": "1",
    158                     "GIT_CONFIG_GLOBAL": os.devnull,
    159                 },
    160             )
    161         )
    162         for name in taler_repos.REPOSITORIES:
    163             repo = self.root / name
    164             repo.mkdir()
    165             subprocess.run(
    166                 ["git", "init", "--quiet", "--initial-branch=main", "--template="],
    167                 cwd=repo,
    168                 check=True,
    169                 capture_output=True,
    170             )
    171             # These repositories have no upstream and contain untracked files.
    172             (repo / "dirty").write_text("uncommitted")
    173 
    174     def invoke(self, *args, explicit_root=True):
    175         command = [sys.executable, "-B", str(SCRIPT.resolve())]
    176         if explicit_root:
    177             command.extend(["--root", str(self.root)])
    178         return subprocess.run(
    179             [*command, "foreach", *args],
    180             cwd=self.root.parent if explicit_root else self.root,
    181             check=False,
    182             capture_output=True,
    183             text=True,
    184         )
    185 
    186     def records(self, result):
    187         return [
    188             json.loads(line)
    189             for line in result.stdout.splitlines()
    190             if line.startswith("{")
    191         ]
    192 
    193     def test_cwd_order_and_literal_arguments_with_optional_separator(self):
    194         code = (
    195             "import json, os, sys; "
    196             "print(json.dumps({'cwd': os.getcwd(), 'args': sys.argv[1:]}))"
    197         )
    198         arguments = [
    199             "with spaces", "", "'quoted'", "$HOME", "$(pwd)", "*", ";",
    200             "--help", "--root", "elsewhere", "--",
    201         ]
    202         expected = [
    203             {"cwd": str((self.root / name).resolve()), "args": arguments}
    204             for name in taler_repos.REPOSITORIES
    205         ]
    206         for separator in ((), ("--",)):
    207             with self.subTest(separator=separator):
    208                 result = self.invoke(*separator, sys.executable, "-c", code, *arguments)
    209                 self.assertEqual(result.returncode, 0, result.stderr)
    210                 self.assertEqual(self.records(result), expected)
    211                 for name in taler_repos.REPOSITORIES:
    212                     self.assertIn(f"[{name}] $ ", result.stdout)
    213 
    214     def test_default_root_and_explicit_shell_command(self):
    215         result = self.invoke("sh", "-c", "pwd && printf 'done\\n'", explicit_root=False)
    216         self.assertEqual(result.returncode, 0, result.stderr)
    217         lines = [
    218             line for line in result.stdout.splitlines()
    219             if line and not line.startswith("[")
    220         ]
    221         expected = []
    222         for name in taler_repos.REPOSITORIES:
    223             expected.extend([str((self.root / name).resolve()), "done"])
    224         self.assertEqual(lines, expected)
    225 
    226     def test_command_failure_stops_at_the_failed_repository(self):
    227         code = (
    228             "import json, os, sys; "
    229             "name = os.path.basename(os.getcwd()); "
    230             "print(json.dumps({'repo': name})); "
    231             "sys.exit(7 if name == sys.argv[1] else 0)"
    232         )
    233         for index in (0, 2):
    234             name = taler_repos.REPOSITORIES[index]
    235             with self.subTest(repository=name):
    236                 result = self.invoke(sys.executable, "-c", code, name)
    237                 self.assertEqual(result.returncode, 1)
    238                 self.assertEqual(
    239                     self.records(result),
    240                     [{"repo": n} for n in taler_repos.REPOSITORIES[:index + 1]],
    241                 )
    242                 self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:"))
    243                 self.assertIn("exited with status 7", result.stderr)
    244 
    245     def test_missing_program_stops_after_first_repository(self):
    246         result = self.invoke(str(self.root / "missing-program"))
    247         self.assertEqual(result.returncode, 1)
    248         self.assertIn("could not run", result.stderr)
    249         self.assertIn(f"[{taler_repos.REPOSITORIES[0]}] $ ", result.stdout)
    250         self.assertNotIn(f"[{taler_repos.REPOSITORIES[1]}] $ ", result.stdout)
    251 
    252     def test_invalid_last_repository_prevents_all_execution(self):
    253         name = taler_repos.REPOSITORIES[-1]
    254         repo = self.root / name
    255         repo.rename(self.root / "unused")
    256         for kind in ("missing", "not a Git repository"):
    257             with self.subTest(kind=kind):
    258                 if kind == "not a Git repository":
    259                     repo.mkdir()
    260                 result = self.invoke(sys.executable, "-c", "print('executed')")
    261                 self.assertEqual(result.returncode, 1)
    262                 self.assertTrue(result.stderr.startswith(f"taler-repos: {name}:"))
    263                 self.assertEqual(result.stdout, "")
    264 
    265     def test_empty_command_is_a_usage_error(self):
    266         for args in ((), ("--",)):
    267             with self.subTest(args=args):
    268                 result = self.invoke(*args)
    269                 self.assertEqual(result.returncode, 2)
    270                 self.assertIn("a program is required", result.stderr)
    271                 self.assertEqual(result.stdout, "")
    272 
    273 
    274 class NextTagTests(unittest.TestCase):
    275     def test_semver_transitions(self):
    276         cases = [
    277             (["v1.2.3"], "v1.2.4", "v1.2.4-dev.1"),
    278             (["v1.2.4-dev.4"], "v1.2.4", "v1.2.4-dev.5"),
    279             (["v1.2.4-dev.0"], "v1.2.4", "v1.2.4-dev.1"),
    280             (["v1.2.4", "v1.2.4-dev.99"], "v1.2.5", "v1.2.5-dev.1"),
    281             (
    282                 ["v1.2.9", "v1.2.10-dev.9", "v1.2.10-dev.10"],
    283                 "v1.2.10",
    284                 "v1.2.10-dev.11",
    285             ),
    286             (["v1.9.99", "v1.10.0", "v0.99.99"], "v1.10.1", "v1.10.1-dev.1"),
    287             (["v9.99.99", "v10.0.0"], "v10.0.1", "v10.0.1-dev.1"),
    288             (["v0.0.0"], "v0.0.1", "v0.0.1-dev.1"),
    289             (
    290                 ["v1.2.3", "deb-v9.9.9-1", "v9.9.9-rc.1", "v2.0", "v3.0.0+build"],
    291                 "v1.2.4",
    292                 "v1.2.4-dev.1",
    293             ),
    294         ]
    295         for tags, stable, dev in cases:
    296             with self.subTest(tags=tags):
    297                 self.assertEqual(taler_repos.next_tag(tags, False), stable)
    298                 self.assertEqual(taler_repos.next_tag(list(reversed(tags)), True), dev)
    299 
    300     def test_missing_supported_version(self):
    301         for tags in (
    302             [],
    303             [
    304                 "release",
    305                 "deb-v1.2.3-1",
    306                 "v01.2.3",
    307                 "v1.02.3",
    308                 "v1.2.03",
    309                 "v1.2.3-dev.01",
    310             ],
    311         ):
    312             with (
    313                 self.subTest(tags=tags),
    314                 self.assertRaisesRegex(
    315                     taler_repos.BuildError, "no supported local version"
    316                 ),
    317             ):
    318                 taler_repos.next_tag(tags, False)
    319 
    320 
    321 class BumpTests(unittest.TestCase):
    322     def setUp(self):
    323         temporary = tempfile.TemporaryDirectory()
    324         self.addCleanup(temporary.cleanup)
    325         self.root = Path(temporary.name)
    326         self.enterContext(patch.object(taler_repos, "ROOT", self.root))
    327         self.enterContext(patch.object(taler_repos, "REPOSITORIES", ("one", "two")))
    328         self.enterContext(
    329             patch.dict(
    330                 os.environ,
    331                 {
    332                     "GIT_CONFIG_NOSYSTEM": "1",
    333                     "GIT_CONFIG_GLOBAL": os.devnull,
    334                 },
    335             )
    336         )
    337         for name in taler_repos.REPOSITORIES:
    338             repo = self.root / name
    339             repo.mkdir()
    340             self.git(name, "init", "--quiet", "--initial-branch=main", "--template=")
    341             self.git(name, "config", "user.name", "Taler Repos Test")
    342             self.git(name, "config", "user.email", "test@example.invalid")
    343             self.git(name, "config", "commit.gpgSign", "false")
    344             self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Initial commit")
    345             self.git(name, "tag", "v1.2.3")
    346             self.git(name, "commit", "--quiet", "--allow-empty", "-m", "Next commit")
    347 
    348     def git(self, name, *args):
    349         return subprocess.run(
    350             ["git", *args],
    351             cwd=self.root / name,
    352             check=True,
    353             capture_output=True,
    354             text=True,
    355         ).stdout.strip()
    356 
    357     def bump(self, **kwargs):
    358         output = io.StringIO()
    359         with redirect_stdout(output):
    360             taler_repos.bump(**kwargs)
    361         return output.getvalue()
    362 
    363     def refs(self):
    364         return {name: self.git(name, "show-ref") for name in taler_repos.REPOSITORIES}
    365 
    366     def test_creates_lightweight_tags_without_upstream_and_skips_on_repeat(self):
    367         for name in taler_repos.REPOSITORIES:
    368             self.git(name, "config", "tag.gpgSign", "true")
    369         output = self.bump()
    370         self.assertIn("Created 2 local tag(s)", output)
    371         for name in taler_repos.REPOSITORIES:
    372             self.assertEqual(self.git(name, "cat-file", "-t", "v1.2.4"), "commit")
    373             self.assertEqual(
    374                 self.git(name, "rev-parse", "v1.2.4"),
    375                 self.git(name, "rev-parse", "HEAD"),
    376             )
    377         before = self.refs()
    378         self.assertIn("skip: HEAD tagged v1.2.4", self.bump())
    379         self.assertEqual(self.refs(), before)
    380 
    381     def test_dev_bumps_each_components_own_version(self):
    382         self.git("two", "tag", "v2.0.0-dev.9", "HEAD~")
    383         self.bump(dev=True)
    384         self.assertEqual(self.git("one", "tag", "--points-at", "HEAD"), "v1.2.4-dev.1")
    385         self.assertEqual(self.git("two", "tag", "--points-at", "HEAD"), "v2.0.0-dev.10")
    386 
    387     def test_both_modes_skip_all_v_tags_including_annotated_and_unsupported(self):
    388         self.git("one", "tag", "v-unusual-format")
    389         self.git("two", "tag", "-a", "v1.2.4-dev.1", "-m", "Development version")
    390         (self.root / "two" / "untracked").write_text("dirty tagged HEAD is skipped")
    391         before = self.refs()
    392         for dev in (False, True):
    393             with self.subTest(dev=dev):
    394                 output = self.bump(dev=dev)
    395                 self.assertIn("skip: HEAD tagged v-unusual-format", output)
    396                 self.assertIn("skip: HEAD tagged v1.2.4-dev.1", output)
    397                 self.assertEqual(self.refs(), before)
    398 
    399     def test_non_version_tag_does_not_skip_head(self):
    400         self.git("one", "tag", "checkpoint")
    401         self.bump()
    402         self.assertEqual(
    403             self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD")
    404         )
    405 
    406     def test_highest_version_can_be_on_another_branch(self):
    407         self.git("one", "checkout", "--quiet", "-b", "other")
    408         self.git("one", "commit", "--quiet", "--allow-empty", "-m", "Other branch")
    409         self.git("one", "tag", "-a", "v1.10.0-dev.9", "-m", "Other version")
    410         self.git("one", "checkout", "--quiet", "main")
    411         self.bump()
    412         self.assertEqual(
    413             self.git("one", "rev-parse", "v1.10.0"),
    414             self.git("one", "rev-parse", "HEAD"),
    415         )
    416 
    417     def test_dry_run_leaves_all_refs_unchanged(self):
    418         before = self.refs()
    419         for dev, expected in ((False, "v1.2.4"), (True, "v1.2.4-dev.1")):
    420             with self.subTest(dev=dev):
    421                 output = self.bump(dev=dev, dry_run=True)
    422                 self.assertIn("as " + expected, output)
    423                 self.assertIn("Dry run complete; no tags created.", output)
    424                 self.assertEqual(self.refs(), before)
    425 
    426     def test_dirty_repository_prevents_all_tagging_including_dry_run(self):
    427         (self.root / "two" / "untracked").write_text("uncommitted")
    428         before = self.refs()
    429         for dry_run in (False, True):
    430             with self.subTest(dry_run=dry_run):
    431                 with self.assertRaisesRegex(
    432                     taler_repos.BuildError, "two: worktree is not clean"
    433                 ):
    434                     self.bump(dry_run=dry_run)
    435                 self.assertEqual(self.refs(), before)
    436 
    437     def test_missing_baseline_prevents_all_tagging(self):
    438         self.git("two", "tag", "-d", "v1.2.3")
    439         before = self.refs()
    440         with self.assertRaisesRegex(
    441             taler_repos.BuildError, "two: no supported local version"
    442         ):
    443             self.bump()
    444         self.assertEqual(self.refs(), before)
    445 
    446     def test_preflight_tag_collision_prevents_all_tagging(self):
    447         # A normally calculated next tag is necessarily newer than all local
    448         # versions; inject a collision to exercise the explicit safety check.
    449         before = self.refs()
    450         with (
    451             patch.object(taler_repos, "next_tag", side_effect=["v1.2.4", "v1.2.3"]),
    452             self.assertRaisesRegex(
    453                 taler_repos.BuildError, "two: tag v1.2.3 already exists"
    454             ),
    455         ):
    456             self.bump()
    457         self.assertEqual(self.refs(), before)
    458 
    459     def test_creation_collision_retains_prior_tags_and_never_overwrites(self):
    460         original = self.git("two", "rev-parse", "HEAD~")
    461 
    462         def race(repo, *command):
    463             if repo.name == "two":
    464                 self.git("two", "tag", "v1.2.4", original)
    465             # Capture the expected Git error while executing the real tag command.
    466             taler_repos.capture(repo, *command)
    467 
    468         with (
    469             patch.object(taler_repos, "run", side_effect=race),
    470             self.assertRaisesRegex(taler_repos.BuildError, "retained: one:v1.2.4"),
    471         ):
    472             self.bump()
    473         self.assertEqual(
    474             self.git("one", "rev-parse", "v1.2.4"), self.git("one", "rev-parse", "HEAD")
    475         )
    476         self.assertEqual(self.git("two", "rev-parse", "v1.2.4"), original)
    477 
    478     def test_tags_the_inspected_commit_when_head_moves(self):
    479         original = self.git("one", "rev-parse", "HEAD")
    480         run = taler_repos.run
    481 
    482         def advance(repo, *command):
    483             if repo.name == "one":
    484                 self.git(
    485                     "one",
    486                     "commit",
    487                     "--quiet",
    488                     "--allow-empty",
    489                     "-m",
    490                     "Concurrent commit",
    491                 )
    492             run(repo, *command)
    493 
    494         with patch.object(taler_repos, "run", side_effect=advance):
    495             self.bump()
    496         self.assertEqual(self.git("one", "rev-parse", "v1.2.4"), original)
    497         self.assertNotEqual(self.git("one", "rev-parse", "HEAD"), original)
    498 
    499     def test_cli_dispatch_and_error_prefix(self):
    500         before = self.refs()
    501         with (
    502             patch("sys.argv", [str(SCRIPT), "bump", "--dev", "--dry-run"]),
    503             redirect_stdout(io.StringIO()) as output,
    504         ):
    505             self.assertEqual(taler_repos.main(), 0)
    506         self.assertIn("v1.2.4-dev.1", output.getvalue())
    507         self.assertEqual(self.refs(), before)
    508         (self.root / "two" / "dirty").touch()
    509         with (
    510             patch("sys.argv", [str(SCRIPT), "bump"]),
    511             redirect_stdout(io.StringIO()),
    512             redirect_stderr(io.StringIO()) as error,
    513         ):
    514             self.assertEqual(taler_repos.main(), 1)
    515         self.assertTrue(
    516             error.getvalue().startswith("taler-repos: bump preflight failed")
    517         )
    518         self.assertEqual(self.refs(), before)
    519 
    520 
    521 if __name__ == "__main__":
    522     unittest.main()