Skip to content

Commit 8a340e4

Browse files
[DPE-5588] Check against invalid arch (#194)
1 parent 96841dc commit 8a340e4

File tree

6 files changed

+246
-2
lines changed

6 files changed

+246
-2
lines changed

src/architecture.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""Architecture utilities module"""
5+
6+
import logging
7+
import os
8+
import pathlib
9+
import platform
10+
11+
import yaml
12+
from ops.charm import CharmBase
13+
from ops.model import BlockedStatus
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class WrongArchitectureWarningCharm(CharmBase):
19+
"""A fake charm class that only signals a wrong architecture deploy."""
20+
21+
def __init__(self, *args):
22+
super().__init__(*args)
23+
24+
hw_arch = platform.machine()
25+
self.unit.status = BlockedStatus(
26+
f"Charm incompatible with {hw_arch} architecture. "
27+
f"If this app is being refreshed, rollback"
28+
)
29+
raise RuntimeError(
30+
f"Incompatible architecture: this charm revision does not support {hw_arch}. "
31+
f"If this app is being refreshed, rollback with instructions from Charmhub docs. "
32+
f"If this app is being deployed for the first time, remove it and deploy it again "
33+
f"using a compatible revision."
34+
)
35+
36+
37+
def is_wrong_architecture() -> bool:
38+
"""Checks if charm was deployed on wrong architecture."""
39+
charm_path = os.environ.get("CHARM_DIR", "")
40+
manifest_path = pathlib.Path(charm_path, "manifest.yaml")
41+
42+
if not manifest_path.exists():
43+
logger.error("Cannot check architecture: manifest file not found in %s", manifest_path)
44+
return False
45+
46+
manifest = yaml.safe_load(manifest_path.read_text())
47+
48+
manifest_archs = []
49+
for base in manifest["bases"]:
50+
base_archs = base.get("architectures", [])
51+
manifest_archs.extend(base_archs)
52+
53+
hardware_arch = platform.machine()
54+
if ("amd64" in manifest_archs and hardware_arch == "x86_64") or (
55+
"arm64" in manifest_archs and hardware_arch == "aarch64"
56+
):
57+
logger.debug("Charm architecture matches")
58+
return False
59+
60+
logger.error("Charm architecture does not match")
61+
return True

src/machine_charm.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@
66

77
"""MySQL Router machine charm"""
88

9+
import ops
10+
11+
from architecture import WrongArchitectureWarningCharm, is_wrong_architecture
12+
13+
if is_wrong_architecture() and __name__ == "__main__":
14+
ops.main.main(WrongArchitectureWarningCharm)
15+
916
import logging
1017
import socket
1118
import typing
1219

13-
import ops
1420
import tenacity
1521
from charms.tempo_coordinator_k8s.v0.charm_tracing import trace_charm
1622

tests/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# See LICENSE file for licensing details.
33

44
import argparse
5+
import subprocess
56

67
import pytest
78

@@ -32,6 +33,37 @@ def pytest_configure(config):
3233
config.option.mysql_router_charm_bases_index = 1
3334

3435

36+
@pytest.fixture(autouse=True)
37+
def architecture() -> str:
38+
return subprocess.run(
39+
["dpkg", "--print-architecture"],
40+
capture_output=True,
41+
check=True,
42+
encoding="utf-8",
43+
).stdout.strip()
44+
45+
46+
@pytest.fixture
47+
def only_amd64(architecture):
48+
"""Pretty way to skip ARM tests."""
49+
if architecture != "amd64":
50+
pytest.skip("Requires amd64 architecture")
51+
52+
53+
@pytest.fixture
54+
def only_arm64(architecture):
55+
"""Pretty way to skip AMD tests."""
56+
if architecture != "arm64":
57+
pytest.skip("Requires arm64 architecture")
58+
59+
60+
@pytest.fixture
61+
def only_ubuntu_jammy(mysql_router_charm_series):
62+
"""Pretty way to skip < Ubuntu 22.04 tests."""
63+
if mysql_router_charm_series != "jammy":
64+
pytest.skip("Requires Ubuntu Jammy")
65+
66+
3567
@pytest.fixture
3668
def only_with_juju_secrets(juju_has_secrets):
3769
"""Pretty way to skip Juju 3 tests."""

tests/integration/helpers.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
import logging
66
import subprocess
77
import tempfile
8-
from typing import Dict, List, Optional
8+
from pathlib import Path
9+
from typing import Dict, List, Optional, Union
910

1011
import tenacity
12+
import yaml
1113
from juju.model import Model
1214
from juju.unit import Unit
1315
from pytest_operator.plugin import OpsTest
@@ -486,3 +488,17 @@ async def get_machine_address(ops_test: OpsTest, unit: Unit) -> str:
486488
return line.split()[2]
487489

488490
assert False, "Unable to find the unit's machine"
491+
492+
493+
async def get_charm(charm_path: Union[str, Path], architecture: str, bases_index: int) -> Path:
494+
"""Fetches packed charm from CI runner without checking for architecture."""
495+
charm_path = Path(charm_path)
496+
charmcraft_yaml = yaml.safe_load((charm_path / "charmcraft.yaml").read_text())
497+
assert charmcraft_yaml["type"] == "charm"
498+
499+
base = charmcraft_yaml["bases"][bases_index]
500+
build_on = base.get("build-on", [base])[0]
501+
version = build_on["channel"]
502+
packed_charms = list(charm_path.glob(f"*{version}-{architecture}.charm"))
503+
504+
return packed_charms[0].resolve(strict=True)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
import asyncio
6+
7+
import pytest
8+
from pytest_operator.plugin import OpsTest
9+
10+
from .helpers import get_charm
11+
12+
MYSQL_ROUTER_APP_NAME = "mysql-router"
13+
MYSQL_TEST_APP_NAME = "mysql-test-app"
14+
15+
16+
@pytest.mark.group(1)
17+
@pytest.mark.usefixtures("only_amd64", "only_ubuntu_jammy")
18+
async def test_arm_charm_on_amd_host(ops_test: OpsTest, mysql_router_charm_series: str) -> None:
19+
"""Tries deploying an arm64 charm on amd64 host."""
20+
charm = await get_charm(".", "arm64", 2)
21+
22+
await asyncio.gather(
23+
ops_test.model.deploy(
24+
charm,
25+
application_name=MYSQL_ROUTER_APP_NAME,
26+
num_units=0,
27+
series=mysql_router_charm_series,
28+
),
29+
ops_test.model.deploy(
30+
MYSQL_TEST_APP_NAME,
31+
application_name=MYSQL_TEST_APP_NAME,
32+
num_units=1,
33+
channel="latest/edge",
34+
series=mysql_router_charm_series,
35+
),
36+
)
37+
38+
await ops_test.model.relate(
39+
f"{MYSQL_ROUTER_APP_NAME}:database",
40+
f"{MYSQL_TEST_APP_NAME}:database",
41+
)
42+
43+
await ops_test.model.wait_for_idle(
44+
apps=[MYSQL_ROUTER_APP_NAME],
45+
status="error",
46+
raise_on_error=False,
47+
)
48+
49+
50+
@pytest.mark.group(1)
51+
@pytest.mark.usefixtures("only_arm64", "only_ubuntu_jammy")
52+
async def test_amd_charm_on_arm_host(ops_test: OpsTest, mysql_router_charm_series: str) -> None:
53+
"""Tries deploying an amd64 charm on arm64 host."""
54+
charm = await get_charm(".", "amd64", 1)
55+
56+
await asyncio.gather(
57+
ops_test.model.deploy(
58+
charm,
59+
application_name=MYSQL_ROUTER_APP_NAME,
60+
num_units=0,
61+
series=mysql_router_charm_series,
62+
),
63+
ops_test.model.deploy(
64+
MYSQL_TEST_APP_NAME,
65+
application_name=MYSQL_TEST_APP_NAME,
66+
num_units=1,
67+
channel="latest/edge",
68+
series=mysql_router_charm_series,
69+
),
70+
)
71+
72+
await ops_test.model.relate(
73+
f"{MYSQL_ROUTER_APP_NAME}:database",
74+
f"{MYSQL_TEST_APP_NAME}:database",
75+
)
76+
77+
await ops_test.model.wait_for_idle(
78+
apps=[MYSQL_ROUTER_APP_NAME],
79+
status="error",
80+
raise_on_error=False,
81+
)

tests/unit/test_architecture.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
from architecture import is_wrong_architecture
6+
7+
TEST_MANIFEST = """
8+
bases:
9+
- architectures:
10+
- {arch}
11+
channel: '22.04'
12+
name: ubuntu
13+
"""
14+
15+
16+
def test_wrong_architecture_file_not_found(monkeypatch):
17+
"""Tests if the function returns False when the charm file doesn't exist."""
18+
monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"})
19+
monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: False)
20+
assert not is_wrong_architecture()
21+
22+
23+
def test_wrong_architecture_amd64(monkeypatch):
24+
"""Tests if the function correctly identifies arch when charm is AMD."""
25+
manifest = TEST_MANIFEST.format(arch="amd64")
26+
monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"})
27+
monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: True)
28+
monkeypatch.setattr("pathlib.Path.read_text", lambda *args, **kwargs: manifest)
29+
30+
monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "x86_64")
31+
assert not is_wrong_architecture()
32+
33+
monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "aarch64")
34+
assert is_wrong_architecture()
35+
36+
37+
def test_wrong_architecture_arm64(monkeypatch):
38+
"""Tests if the function correctly identifies arch when charm is ARM."""
39+
manifest = TEST_MANIFEST.format(arch="arm64")
40+
monkeypatch.setattr("os.environ", {"CHARM_DIR": "/tmp"})
41+
monkeypatch.setattr("pathlib.Path.exists", lambda *args, **kwargs: True)
42+
monkeypatch.setattr("pathlib.Path.read_text", lambda *args, **kwargs: manifest)
43+
44+
monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "x86_64")
45+
assert is_wrong_architecture()
46+
47+
monkeypatch.setattr("platform.machine", lambda *args, **kwargs: "aarch64")
48+
assert not is_wrong_architecture()

0 commit comments

Comments
 (0)