Skip to content

Commit 65772dd

Browse files
[DPE-5588] Check against invalid arch (#539)
1 parent 6748a31 commit 65772dd

File tree

4 files changed

+174
-2
lines changed

4 files changed

+174
-2
lines changed

lib/charms/mysql/v0/architecture.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Copyright 2024 Canonical Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Library to provide hardware architecture checks for VMs and K8s charms.
16+
17+
The WrongArchitectureWarningCharm class is designed to be used alongside
18+
the is-wrong-architecture helper function, as follows:
19+
20+
```python
21+
from ops import main
22+
from charms.mysql.v0.architecture import WrongArchitectureWarningCharm, is_wrong_architecture
23+
24+
if __name__ == "__main__":
25+
if is_wrong_architecture():
26+
main(WrongArchitectureWarningCharm)
27+
```
28+
"""
29+
30+
import logging
31+
import os
32+
import pathlib
33+
import platform
34+
35+
import yaml
36+
from ops.charm import CharmBase
37+
from ops.model import BlockedStatus
38+
39+
# The unique Charmhub library identifier, never change it
40+
LIBID = "827e04542dba4c2a93bdc70ae40afdb1"
41+
LIBAPI = 0
42+
LIBPATCH = 1
43+
44+
PYDEPS = ["ops>=2.0.0", "pyyaml>=5.0"]
45+
46+
47+
logger = logging.getLogger(__name__)
48+
49+
50+
class WrongArchitectureWarningCharm(CharmBase):
51+
"""A fake charm class that only signals a wrong architecture deploy."""
52+
53+
def __init__(self, *args):
54+
super().__init__(*args)
55+
56+
hw_arch = platform.machine()
57+
self.unit.status = BlockedStatus(
58+
f"Charm incompatible with {hw_arch} architecture. "
59+
f"If this app is being refreshed, rollback"
60+
)
61+
raise RuntimeError(
62+
f"Incompatible architecture: this charm revision does not support {hw_arch}. "
63+
f"If this app is being refreshed, rollback with instructions from Charmhub docs. "
64+
f"If this app is being deployed for the first time, remove it and deploy it again "
65+
f"using a compatible revision."
66+
)
67+
68+
69+
def is_wrong_architecture() -> bool:
70+
"""Checks if charm was deployed on wrong architecture."""
71+
charm_path = os.environ.get("CHARM_DIR", "")
72+
manifest_path = pathlib.Path(charm_path, "manifest.yaml")
73+
74+
if not manifest_path.exists():
75+
logger.error("Cannot check architecture: manifest file not found in %s", manifest_path)
76+
return False
77+
78+
manifest = yaml.safe_load(manifest_path.read_text())
79+
80+
manifest_archs = []
81+
for base in manifest["bases"]:
82+
base_archs = base.get("architectures", [])
83+
manifest_archs.extend(base_archs)
84+
85+
hardware_arch = platform.machine()
86+
if ("amd64" in manifest_archs and hardware_arch == "x86_64") or (
87+
"arm64" in manifest_archs and hardware_arch == "aarch64"
88+
):
89+
logger.debug("Charm architecture matches")
90+
return False
91+
92+
logger.error("Charm architecture does not match")
93+
return True

src/charm.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
"""Charm for MySQL."""
66

7+
from charms.mysql.v0.architecture import WrongArchitectureWarningCharm, is_wrong_architecture
8+
from ops.main import main
9+
10+
if is_wrong_architecture() and __name__ == "__main__":
11+
main(WrongArchitectureWarningCharm)
12+
713
import logging
814
import random
915
from socket import getfqdn
@@ -44,7 +50,6 @@
4450
from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer
4551
from ops import EventBase, RelationBrokenEvent, RelationCreatedEvent
4652
from ops.charm import RelationChangedEvent, UpdateStatusEvent
47-
from ops.main import main
4853
from ops.model import (
4954
ActiveStatus,
5055
BlockedStatus,

tests/integration/helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import string
99
import subprocess
1010
import tempfile
11-
from typing import Dict, List, Optional
11+
from pathlib import Path
12+
from typing import Dict, List, Optional, Union
1213

1314
import mysql.connector
1415
import yaml
@@ -771,3 +772,17 @@ async def dispatch_custom_event_for_logrotate(ops_test: OpsTest, unit_name: str)
771772
)
772773

773774
assert return_code == 0
775+
776+
777+
async def get_charm(charm_path: Union[str, Path], architecture: str, bases_index: int) -> Path:
778+
"""Fetches packed charm from CI runner without checking for architecture."""
779+
charm_path = Path(charm_path)
780+
charmcraft_yaml = yaml.safe_load((charm_path / "charmcraft.yaml").read_text())
781+
assert charmcraft_yaml["type"] == "charm"
782+
783+
base = charmcraft_yaml["bases"][bases_index]
784+
build_on = base.get("build-on", [base])[0]
785+
version = build_on["channel"]
786+
packed_charms = list(charm_path.glob(f"*{version}-{architecture}.charm"))
787+
788+
return packed_charms[0].resolve(strict=True)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2024 Canonical Ltd.
3+
# See LICENSE file for licensing details.
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
import yaml
9+
from pytest_operator.plugin import OpsTest
10+
11+
from . import markers
12+
from .helpers import get_charm
13+
14+
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
15+
APP_NAME = METADATA["name"]
16+
17+
18+
@pytest.mark.group(1)
19+
@markers.amd64_only
20+
async def test_arm_charm_on_amd_host(ops_test: OpsTest) -> None:
21+
"""Tries deploying an arm64 charm on amd64 host."""
22+
charm = await get_charm(".", "arm64", 1)
23+
24+
await ops_test.model.deploy(
25+
charm,
26+
application_name=APP_NAME,
27+
num_units=1,
28+
config={"profile": "testing"},
29+
resources={"mysql-image": METADATA["resources"]["mysql-image"]["upstream-source"]},
30+
31+
)
32+
33+
await ops_test.model.wait_for_idle(
34+
apps=[APP_NAME],
35+
status="error",
36+
raise_on_error=False,
37+
)
38+
39+
40+
@pytest.mark.group(1)
41+
@markers.arm64_only
42+
async def test_amd_charm_on_arm_host(ops_test: OpsTest) -> None:
43+
"""Tries deploying an amd64 charm on arm64 host."""
44+
charm = await get_charm(".", "amd64", 0)
45+
46+
await ops_test.model.deploy(
47+
charm,
48+
application_name=APP_NAME,
49+
num_units=1,
50+
config={"profile": "testing"},
51+
resources={"mysql-image": METADATA["resources"]["mysql-image"]["upstream-source"]},
52+
53+
)
54+
55+
await ops_test.model.wait_for_idle(
56+
apps=[APP_NAME],
57+
status="error",
58+
raise_on_error=False,
59+
)

0 commit comments

Comments
 (0)