From c2d653f1035d368b5fc73727730e88f1afcad96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Mon, 16 Dec 2024 15:49:38 +0100 Subject: [PATCH] [DPE-5588] Check against invalid arch (#344) --- src/architecture.py | 61 ++++++++++++++++++++++++ src/kubernetes_charm.py | 7 +++ tests/integration/helpers.py | 17 ++++++- tests/integration/test_architecture.py | 65 ++++++++++++++++++++++++++ tests/unit/test_architecture.py | 48 +++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/architecture.py create mode 100644 tests/integration/test_architecture.py create mode 100644 tests/unit/test_architecture.py diff --git a/src/architecture.py b/src/architecture.py new file mode 100644 index 000000000..36c4cd548 --- /dev/null +++ b/src/architecture.py @@ -0,0 +1,61 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Architecture utilities module""" + +import logging +import os +import pathlib +import platform + +import yaml +from ops.charm import CharmBase +from ops.model import BlockedStatus + +logger = logging.getLogger(__name__) + + +class WrongArchitectureWarningCharm(CharmBase): + """A fake charm class that only signals a wrong architecture deploy.""" + + def __init__(self, *args): + super().__init__(*args) + + hw_arch = platform.machine() + self.unit.status = BlockedStatus( + f"Charm incompatible with {hw_arch} architecture. " + f"If this app is being refreshed, rollback" + ) + raise RuntimeError( + f"Incompatible architecture: this charm revision does not support {hw_arch}. " + f"If this app is being refreshed, rollback with instructions from Charmhub docs. " + f"If this app is being deployed for the first time, remove it and deploy it again " + f"using a compatible revision." + ) + + +def is_wrong_architecture() -> bool: + """Checks if charm was deployed on wrong architecture.""" + charm_path = os.environ.get("CHARM_DIR", "") + manifest_path = pathlib.Path(charm_path, "manifest.yaml") + + if not manifest_path.exists(): + logger.error("Cannot check architecture: manifest file not found in %s", manifest_path) + return False + + manifest = yaml.safe_load(manifest_path.read_text()) + + manifest_archs = [] + for base in manifest["bases"]: + base_archs = base.get("architectures", []) + manifest_archs.extend(base_archs) + + hardware_arch = platform.machine() + if ("amd64" in manifest_archs and hardware_arch == "x86_64") or ( + "arm64" in manifest_archs and hardware_arch == "aarch64" + ): + logger.debug("Charm architecture matches") + return False + + logger.error("Charm architecture does not match") + return True diff --git a/src/kubernetes_charm.py b/src/kubernetes_charm.py index 0415cd122..393baa00a 100755 --- a/src/kubernetes_charm.py +++ b/src/kubernetes_charm.py @@ -6,6 +6,13 @@ """MySQL Router Kubernetes charm""" +import ops + +from architecture import WrongArchitectureWarningCharm, is_wrong_architecture + +if is_wrong_architecture() and __name__ == "__main__": + ops.main.main(WrongArchitectureWarningCharm) + import enum import functools import json diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index f3b0315db..b798dc2fb 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -7,7 +7,8 @@ import pathlib import subprocess import tempfile -from typing import Dict, List, Optional +from pathlib import Path +from typing import Dict, List, Optional, Union import mysql.connector import tenacity @@ -655,3 +656,17 @@ def get_juju_status(model_name: str) -> str: model_name: The model for which to retrieve juju status for """ return subprocess.check_output(["juju", "status", "--model", model_name]).decode("utf-8") + + +async def get_charm(charm_path: Union[str, Path], architecture: str, bases_index: int) -> Path: + """Fetches packed charm from CI runner without checking for architecture.""" + charm_path = Path(charm_path) + charmcraft_yaml = yaml.safe_load((charm_path / "charmcraft.yaml").read_text()) + assert charmcraft_yaml["type"] == "charm" + + base = charmcraft_yaml["bases"][bases_index] + build_on = base.get("build-on", [base])[0] + version = build_on["channel"] + packed_charms = list(charm_path.glob(f"*{version}-{architecture}.charm")) + + return packed_charms[0].resolve(strict=True) diff --git a/tests/integration/test_architecture.py b/tests/integration/test_architecture.py new file mode 100644 index 000000000..2883d9174 --- /dev/null +++ b/tests/integration/test_architecture.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +from . import markers +from .helpers import get_charm + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +MYSQL_ROUTER_APP_NAME = METADATA["name"] + + +@pytest.mark.group(1) +@markers.amd64_only +async def test_arm_charm_on_amd_host(ops_test: OpsTest) -> None: + """Tries deploying an arm64 charm on amd64 host.""" + charm = await get_charm(".", "arm64", 1) + + resources = { + "mysql-router-image": METADATA["resources"]["mysql-router-image"]["upstream-source"] + } + + await ops_test.model.deploy( + charm, + application_name=MYSQL_ROUTER_APP_NAME, + num_units=1, + resources=resources, + base="ubuntu@22.04", + ) + + await ops_test.model.wait_for_idle( + apps=[MYSQL_ROUTER_APP_NAME], + status="error", + raise_on_error=False, + ) + + +@pytest.mark.group(1) +@markers.arm64_only +async def test_amd_charm_on_arm_host(ops_test: OpsTest) -> None: + """Tries deploying an amd64 charm on arm64 host.""" + charm = await get_charm(".", "amd64", 0) + + resources = { + "mysql-router-image": METADATA["resources"]["mysql-router-image"]["upstream-source"] + } + + await ops_test.model.deploy( + charm, + application_name=MYSQL_ROUTER_APP_NAME, + num_units=1, + resources=resources, + base="ubuntu@22.04", + ) + + await ops_test.model.wait_for_idle( + apps=[MYSQL_ROUTER_APP_NAME], + status="error", + raise_on_error=False, + ) diff --git a/tests/unit/test_architecture.py b/tests/unit/test_architecture.py new file mode 100644 index 000000000..831ca68fa --- /dev/null +++ b/tests/unit/test_architecture.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from architecture import is_wrong_architecture + +TEST_MANIFEST = """ + bases: + - architectures: + - {arch} + channel: '22.04' + name: ubuntu +""" + + +def test_wrong_architecture_file_not_found(monkeypatch): + """Tests if the function returns False when the charm file doesn't exist.""" + monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"}) + monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: False) + assert not is_wrong_architecture() + + +def test_wrong_architecture_amd64(monkeypatch): + """Tests if the function correctly identifies arch when charm is AMD.""" + manifest = TEST_MANIFEST.format(arch="amd64") + monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"}) + monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: True) + monkeypatch.setattr("pathlib.Path.read_text", lambda *args, **kwargs: manifest) + + monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "x86_64") + assert not is_wrong_architecture() + + monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "aarch64") + assert is_wrong_architecture() + + +def test_wrong_architecture_arm64(monkeypatch): + """Tests if the function correctly identifies arch when charm is ARM.""" + manifest = TEST_MANIFEST.format(arch="arm64") + monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"}) + monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: True) + monkeypatch.setattr("pathlib.Path.read_text", lambda *args, **kwargs: manifest) + + monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "x86_64") + assert is_wrong_architecture() + + monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "aarch64") + assert not is_wrong_architecture()