diff --git a/.github/actions/renovate/README.md b/.github/actions/renovate/README.md index b9ebb38..028670c 100644 --- a/.github/actions/renovate/README.md +++ b/.github/actions/renovate/README.md @@ -30,7 +30,7 @@ jobs: parse-mode: MarkdownV2 ``` -Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output. +Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output. The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action. diff --git a/.github/actions/renovate/action.yml b/.github/actions/renovate/action.yml index 2179041..d906f18 100644 --- a/.github/actions/renovate/action.yml +++ b/.github/actions/renovate/action.yml @@ -31,7 +31,7 @@ outputs: description: Comma-separated app@host pairs that were actually updated. value: ${{ steps.run.outputs.updated_hosts }} updated-items: - description: JSON array of app and host objects that were actually updated. + description: JSON array of app, host, and per-service image-version changes that were actually updated. value: ${{ steps.run.outputs.updated_items }} target-name: description: This target's name, derived from the manifest's own filename. diff --git a/.github/actions/renovate/renovate.py b/.github/actions/renovate/renovate.py index accb50b..ac8df29 100644 --- a/.github/actions/renovate/renovate.py +++ b/.github/actions/renovate/renovate.py @@ -27,7 +27,8 @@ calling workflow can notify only when a host's image actually changed, rather than on every run. `updated_hosts` lists `app@host` pairs, since more than one app may have been renovated in the same run. `updated_items` -contains the same data as structured JSON for downstream formatting. +contains the same data plus per-service image-version transitions as +structured JSON for downstream formatting. """ import json import os @@ -44,18 +45,63 @@ def expand_home(path, home): return home + path[1:] if path.startswith("~") else path +def image_version(connection, image_id): + command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'" + labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {} + return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version") + + +def container_snapshot(connection, compose_dir): + result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True) + snapshot = {} + versions = {} + for container_id in result.stdout.splitlines(): + inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True) + container = json.loads(inspect.stdout)[0] + config = container["Config"] + service = config["Labels"]["com.docker.compose.service"] + image_id = container["Image"] + if image_id not in versions: + versions[image_id] = image_version(connection, image_id) + snapshot[service] = { + "id": image_id, + "image": config["Image"], + "version": versions[image_id], + } + return snapshot + + +def image_transitions(before, after): + transitions = [] + for service in sorted(after): + current = after[service] + previous = before.get(service) + if previous and previous["id"] == current["id"]: + continue + transitions.append( + { + "service": service, + "image": current["image"], + "before": { + "id": previous["id"] if previous else "none", + "version": previous["version"] if previous else None, + }, + "after": {"id": current["id"], "version": current["version"]}, + } + ) + return transitions + + def renovate_host(host, base_path, app): connection = Connection(host) connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) home = connection.run("echo $HOME", hide=True).stdout.strip() compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}") - command = ( - f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull " - f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans ' - f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi' - ) - result = connection.run(command) - return "RENOVATE_UPDATED" in result.stdout + before = container_snapshot(connection, compose_dir) + connection.run(f"cd {compose_dir} && docker compose pull") + connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans") + after = container_snapshot(connection, compose_dir) + return image_transitions(before, after) def write_github_output(name, value): @@ -90,8 +136,9 @@ def main(): for app in matching_apps: for host in target["hosts"]: print(f"Renovating {app} on {host}") - if renovate_host(host, base_path, app): - updated.append({"app": app, "host": host}) + changes = renovate_host(host, base_path, app) + if changes: + updated.append({"app": app, "host": host, "changes": changes}) write_github_output("updated", "true" if updated else "false") write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated)) diff --git a/.github/actions/renovate/tests/test_renovate.py b/.github/actions/renovate/tests/test_renovate.py index d314f4d..023c0df 100644 --- a/.github/actions/renovate/tests/test_renovate.py +++ b/.github/actions/renovate/tests/test_renovate.py @@ -22,17 +22,41 @@ class FakeConnection: """Stand-in for fabric.Connection - records commands instead of opening a real SSH session, mirroring test_deploy.py's FakeConnection.""" - def __init__(self, host, pull_stdout=""): + def __init__(self, host, snapshots=None, labels=None): self.host = host self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None) self.commands = [] - self.pull_stdout = pull_stdout + self.snapshots = list(snapshots or [{}, {}]) + self.current_snapshot = {} + self.labels = labels or {} def run(self, command, hide=False): self.commands.append(command) if command == "echo $HOME": return SimpleNamespace(stdout="/home/deploy\n") - return SimpleNamespace(stdout=self.pull_stdout) + if command.endswith("docker compose ps --all -q"): + self.current_snapshot = self.snapshots.pop(0) + return SimpleNamespace(stdout="\n".join(self.current_snapshot)) + if command.startswith("docker inspect "): + container_id = command.split()[2] + container = self.current_snapshot[container_id] + return SimpleNamespace( + stdout=json.dumps( + [ + { + "Image": container["image_id"], + "Config": { + "Image": container["image"], + "Labels": {"com.docker.compose.service": container["service"]}, + }, + } + ] + ) + ) + if command.startswith("docker image inspect "): + image_id = command.split()[3] + return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id))) + return SimpleNamespace(stdout="") class ExpandHomeTest(unittest.TestCase): @@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self): joined = "\n".join(fake.commands) self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined) - self.assertIn("before=$(docker compose images -q)", joined) self.assertIn("docker compose pull", joined) - self.assertIn("after=$(docker compose images -q)", joined) self.assertIn("docker compose up -d --remove-orphans", joined) + self.assertEqual(joined.count("docker compose ps --all -q"), 2) + + def test_returns_version_transition_when_the_image_changed(self): + before = { + "container-old": { + "service": "beszel", + "image": "henrygd/beszel:latest", + "image_id": "sha256:old", + } + } + after = { + "container-new": { + "service": "beszel", + "image": "henrygd/beszel:latest", + "image_id": "sha256:new", + } + } + labels = { + "sha256:old": {"org.opencontainers.image.version": "0.12.8"}, + "sha256:new": {"org.opencontainers.image.version": "0.12.9"}, + } + fake = FakeConnection("deploy@host", [before, after], labels) + with patch.object(renovate, "Connection", return_value=fake): + self.assertEqual( + renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), + [ + { + "service": "beszel", + "image": "henrygd/beszel:latest", + "before": {"id": "sha256:old", "version": "0.12.8"}, + "after": {"id": "sha256:new", "version": "0.12.9"}, + } + ], + ) - def test_returns_true_when_the_image_changed(self): - fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n") + def test_returns_no_transitions_when_the_image_was_already_current(self): + containers = { + "container": { + "service": "beszel", + "image": "henrygd/beszel:latest", + "image_id": "sha256:same", + } + } + fake = FakeConnection("deploy@host", [containers, containers]) with patch.object(renovate, "Connection", return_value=fake): - self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel")) + self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), []) + + def test_reports_the_specific_changed_service_in_a_multiservice_app(self): + before = { + "client-container": { + "service": "client", + "image": "ghcr.io/rybbit-io/rybbit-client:latest", + "image_id": "sha256:client-old", + }, + "backend-container": { + "service": "backend", + "image": "ghcr.io/rybbit-io/rybbit-backend:latest", + "image_id": "sha256:backend-same", + }, + } + after = { + "client-container": { + "service": "client", + "image": "ghcr.io/rybbit-io/rybbit-client:latest", + "image_id": "sha256:client-new", + }, + "backend-container": { + "service": "backend", + "image": "ghcr.io/rybbit-io/rybbit-backend:latest", + "image_id": "sha256:backend-same", + }, + } + labels = { + "sha256:client-old": {"org.opencontainers.image.version": "1.6.0"}, + "sha256:client-new": {"org.opencontainers.image.version": "1.6.1"}, + } + fake = FakeConnection("deploy@host", [before, after], labels) - def test_returns_false_when_the_image_was_already_current(self): - fake = FakeConnection("deploy@host", pull_stdout="") with patch.object(renovate, "Connection", return_value=fake): - self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel")) + changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit") + + self.assertEqual([change["service"] for change in changes], ["client"]) + + def test_preserves_missing_versions_for_downstream_image_id_fallback(self): + before = { + "app": { + "id": "sha256:1234567890abcdef", + "image": "app:latest", + "version": None, + } + } + after = { + "app": { + "id": "sha256:abcdef1234567890", + "image": "app:latest", + "version": None, + } + } + + self.assertEqual( + renovate.image_transitions(before, after), + [ + { + "service": "app", + "image": "app:latest", + "before": {"id": "sha256:1234567890abcdef", "version": None}, + "after": {"id": "sha256:abcdef1234567890", "version": None}, + } + ], + ) class MainTest(unittest.TestCase): @@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self): def test_renovates_every_app_when_apps_is_empty(self): with ( tempfile.TemporaryDirectory() as directory, - patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host, + patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host, ): self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n") @@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self): def test_renovates_only_the_requested_apps_present_on_the_target(self): with ( tempfile.TemporaryDirectory() as directory, - patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host, + patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host, ): self._run_main( directory, @@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self): def test_reports_updated_app_host_pairs_when_the_image_changed(self): def fake_renovate_host(host, base_path, app): - return app == "beszel" and host == "deploy@app1.example.com" + if app == "beszel" and host == "deploy@app1.example.com": + return [ + { + "service": "beszel", + "image": "henrygd/beszel:latest", + "before": {"id": "sha256:old", "version": "0.12.8"}, + "after": {"id": "sha256:new", "version": "0.12.9"}, + } + ] + return [] manifest = ( "apps:\n beszel: {}\n traefik: {}\n" @@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app): self.assertIn("updated=true\n", outputs) self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs) - self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs) + self.assertIn( + 'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":' + '[{"service":"beszel","image":"henrygd/beszel:latest","before":' + '{"id":"sha256:old","version":"0.12.8"},"after":' + '{"id":"sha256:new","version":"0.12.9"}}]}]\n', + outputs, + ) def test_reports_not_updated_when_every_host_was_already_current(self): with ( tempfile.TemporaryDirectory() as directory, - patch.object(renovate, "renovate_host", return_value=False), + patch.object(renovate, "renovate_host", return_value=[]), ): outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n") @@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self): def test_defaults_path_when_omitted(self): with ( tempfile.TemporaryDirectory() as directory, - patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host, + patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host, ): self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n") @@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self): def test_derives_target_name_from_manifest_filename(self): with ( tempfile.TemporaryDirectory() as directory, - patch.object(renovate, "renovate_host", return_value=False), + patch.object(renovate, "renovate_host", return_value=[]), ): outputs = self._run_main( directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe" diff --git a/.github/actions/telegram-message/prepare.py b/.github/actions/telegram-message/prepare.py index 8a1736e..3c3bf9f 100644 --- a/.github/actions/telegram-message/prepare.py +++ b/.github/actions/telegram-message/prepare.py @@ -32,24 +32,95 @@ def validate_items(items): for index, item in enumerate(items): if not isinstance(item, dict): raise ValueError(f"items[{index}] must be an object") - validated.append( - { - "app": require_text(item.get("app"), f"items[{index}].app"), - "host": require_text(item.get("host"), f"items[{index}].host"), - } - ) + validated_item = { + "app": require_text(item.get("app"), f"items[{index}].app"), + "host": require_text(item.get("host"), f"items[{index}].host"), + } + changes = item.get("changes", []) + if not isinstance(changes, list): + raise ValueError(f"items[{index}].changes must be an array") + validated_item["changes"] = [] + for change_index, change in enumerate(changes): + if not isinstance(change, dict): + raise ValueError(f"items[{index}].changes[{change_index}] must be an object") + before = change.get("before") + after = change.get("after") + if not isinstance(before, dict): + raise ValueError(f"items[{index}].changes[{change_index}].before must be an object") + if not isinstance(after, dict): + raise ValueError(f"items[{index}].changes[{change_index}].after must be an object") + validated_item["changes"].append( + { + "service": require_text( + change.get("service"), f"items[{index}].changes[{change_index}].service" + ), + "image": require_text( + change.get("image"), f"items[{index}].changes[{change_index}].image" + ), + "before": validate_image(before, f"items[{index}].changes[{change_index}].before"), + "after": validate_image(after, f"items[{index}].changes[{change_index}].after"), + } + ) + validated.append(validated_item) return validated -def format_items(items): - groups = OrderedDict() +def validate_image(image, name): + image_id = require_text(image.get("id"), f"{name}.id") + version = image.get("version") + if version is not None and (not isinstance(version, str) or not version): + raise ValueError(f"{name}.version must be a non-empty string or null") + return {"id": image_id, "version": version} + + +def short_image_id(image_id): + return image_id.removeprefix("sha256:")[:8] + + +def format_versions(before, after): + before_version = before["version"] + after_version = after["version"] + if before_version and after_version and before_version != after_version: + return before_version, after_version + if before_version and after_version: + return ( + f"{before_version} (sha:{short_image_id(before['id'])})", + f"{after_version} (sha:{short_image_id(after['id'])})", + ) + return ( + before_version or f"sha:{short_image_id(before['id'])}", + after_version or f"sha:{short_image_id(after['id'])}", + ) + + +def format_app(item, change=None): + app = escape_markdown(item["app"]) + if change is None: + return f"• {app}" + previous, current = format_versions(change["before"], change["after"]) + service = escape_markdown(change["service"]) + return f"• {app} • {service}: {escape_markdown(previous)} → {escape_markdown(current)}" + + +def flatten_items(items): + entries = [] for item in items: - groups.setdefault(item["host"], []).append(item["app"]) + changes = item["changes"] + entries.extend((item, change) for change in changes) + if not changes: + entries.append((item, None)) + return entries + + +def format_entries(entries): + groups = OrderedDict() + for item, change in entries: + groups.setdefault(item["host"], []).append((item, change)) sections = [] - for host, apps in groups.items(): + for host, host_entries in groups.items(): lines = [f"*{escape_markdown(host)}*"] - lines.extend(f"• {escape_markdown(app)}" for app in apps) + lines.extend(format_app(item, change) for item, change in host_entries) sections.append("\n".join(lines)) return "\n\n".join(sections) @@ -61,22 +132,23 @@ def format_message(repository, operation, target, run_url, items=None): run_url = require_text(run_url, "run-url") items = validate_items([] if items is None else items) header = ( - f"*{escape_markdown(repository)}* · " - f"[{escape_markdown(operation)}]({escape_link_url(run_url)}) completed · " + f"*{escape_markdown(repository)}* • " + f"[{escape_markdown(operation)}]({escape_link_url(run_url)}) completed • " f"*{escape_markdown(target)}*" ) if not items: return header - message = f"{header}\n\n{format_items(items)}" + entries = flatten_items(items) + message = f"{header}\n{format_entries(entries)}" if len(message) <= TELEGRAM_MESSAGE_LIMIT: return message - for visible_count in range(len(items) - 1, -1, -1): - remaining = len(items) - visible_count - suffix = escape_markdown(f"…and {remaining} more") - body = format_items(items[:visible_count]) - candidate = f"{header}\n\n{body}\n\n{suffix}" if body else f"{header}\n\n{suffix}" + for visible_count in range(len(entries) - 1, -1, -1): + remaining = len(entries) - visible_count + suffix = escape_markdown(f"…and {remaining} more services") + body = format_entries(entries[:visible_count]) + candidate = f"{header}\n{body}\n\n{suffix}" if body else f"{header}\n{suffix}" if len(candidate) <= TELEGRAM_MESSAGE_LIMIT: return candidate raise ValueError("operation notification header exceeds Telegram's message limit") diff --git a/.github/actions/telegram-message/tests/test_prepare.py b/.github/actions/telegram-message/tests/test_prepare.py index 71f1141..bf388b8 100644 --- a/.github/actions/telegram-message/tests/test_prepare.py +++ b/.github/actions/telegram-message/tests/test_prepare.py @@ -24,8 +24,8 @@ def test_formats_deploy_header(self): self.assertEqual( message, - "*dupmachine/flightdeck* · " - "[deploy](https://github.com/dupmachine/flightdeck/actions/runs/123) completed · " + "*dupmachine/flightdeck* • " + "[deploy](https://github.com/dupmachine/flightdeck/actions/runs/123) completed • " "*mainframe*", ) @@ -44,9 +44,9 @@ def test_groups_renovated_apps_by_host(self): self.assertEqual( message, - "*dupmachine/flightdeck* · " - "[renovate](https://github.com/dupmachine/flightdeck/actions/runs/123) completed · " - "*hawkeye*\n\n" + "*dupmachine/flightdeck* • " + "[renovate](https://github.com/dupmachine/flightdeck/actions/runs/123) completed • " + "*hawkeye*\n" "*root@100\\.75\\.50\\.2*\n" "• gatus\n" "• yamtrack\n\n" @@ -65,10 +65,119 @@ def test_escapes_dynamic_markdown(self): self.assertEqual( message, - "*owner/repo\\_test* · [deploy\\-now](https://example.com/run/1\\)) completed · " - "*prod\\.main*\n\n*root@host\\.example*\n• api\\_v2", + "*owner/repo\\_test* • [deploy\\-now](https://example.com/run/1\\)) completed • " + "*prod\\.main*\n*root@host\\.example*\n• api\\_v2", ) + def test_formats_image_version_transition(self): + message = prepare.format_message( + "owner/repo", + "renovate", + "target", + "https://example.com", + [ + { + "app": "gatus", + "host": "root@host", + "changes": [ + { + "service": "gatus", + "image": "twinproduction/gatus:latest", + "before": {"id": "sha256:old", "version": "5.21.0"}, + "after": {"id": "sha256:new", "version": "5.22.0"}, + } + ], + } + ], + ) + + self.assertIn("• gatus • gatus: 5\\.21\\.0 → 5\\.22\\.0", message) + + def test_formats_each_changed_service_on_its_own_line(self): + message = prepare.format_message( + "owner/repo", + "renovate", + "target", + "https://example.com", + [ + { + "app": "rybbit", + "host": "root@host", + "changes": [ + { + "service": "client", + "image": "ghcr.io/rybbit-io/rybbit-client:latest", + "before": {"id": "sha256:client-old", "version": "1.6.0"}, + "after": {"id": "sha256:client-new", "version": "1.6.1"}, + }, + { + "service": "backend", + "image": "ghcr.io/rybbit-io/rybbit-backend:latest", + "before": {"id": "sha256:backend-old", "version": "1.6.0"}, + "after": {"id": "sha256:backend-new", "version": "1.6.1"}, + }, + ], + } + ], + ) + + self.assertIn( + "• rybbit • client: 1\\.6\\.0 → 1\\.6\\.1\n" + "• rybbit • backend: 1\\.6\\.0 → 1\\.6\\.1", + message, + ) + + def test_adds_image_ids_when_the_version_label_did_not_change(self): + message = prepare.format_message( + "owner/repo", + "renovate", + "target", + "https://example.com", + [ + { + "app": "app", + "host": "root@host", + "changes": [ + { + "service": "web", + "image": "owner/app:latest", + "before": {"id": "sha256:1234567890abcdef", "version": "1.0.0"}, + "after": {"id": "sha256:abcdef1234567890", "version": "1.0.0"}, + } + ], + } + ], + ) + + self.assertIn( + "• app • web: 1\\.0\\.0 \\(sha:12345678\\) → 1\\.0\\.0 \\(sha:abcdef12\\)", + message, + ) + + def test_falls_back_to_image_ids_when_version_labels_are_missing(self): + message = prepare.format_message( + "owner/repo", + "renovate", + "target", + "https://example.com", + [ + { + "app": "app", + "host": "root@host", + "changes": [ + { + "service": "web", + "image": "owner/app:latest", + "before": {"id": "sha256:1234567890abcdef", "version": None}, + "after": {"id": "sha256:abcdef1234567890", "version": None}, + } + ], + } + ], + ) + + self.assertIn("• app • web: sha:12345678 → sha:abcdef12", message) + def test_rejects_unstructured_items(self): with self.assertRaisesRegex(ValueError, "items must be a JSON array"): prepare.format_message("owner/repo", "renovate", "target", "https://example.com", {"app": "gatus"}) @@ -83,7 +192,7 @@ def test_truncates_an_oversized_item_list(self): message = prepare.format_message("owner/repo", "renovate", "target", "https://example.com", items) self.assertLessEqual(len(message), prepare.TELEGRAM_MESSAGE_LIMIT) - self.assertRegex(message, r"…and \d+ more$") + self.assertRegex(message, r"…and \d+ more services$") class MainTest(unittest.TestCase):