Skip to content

Commit 03012d3

Browse files
authoredMay 2, 2024··
DPE-3115 Fix MAAS deployment (#444)
* refactor hostname resolution with maas fix * call on init * proper version definition and more specific offending address filtering
1 parent 0894bc7 commit 03012d3

7 files changed

+122
-120
lines changed
 

‎poetry.lock

+12-22
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ boto3 = "^1.28.23"
1717
pyopenssl = "^24.0.0"
1818
typing_extensions = "^4.7.1"
1919
jinja2 = "^3.1.2"
20+
python_hosts = "^1.0.6"
2021

2122
[tool.poetry.group.charm-libs.dependencies]
2223
# data_platform_libs/v0/data_interfaces.py

‎src/charm.py

+3
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,9 @@ def workload_initialise(self) -> None:
620620
self._mysql.reset_root_password_and_start_mysqld()
621621
self._mysql.configure_mysql_users()
622622

623+
# ensure hostname can be resolved
624+
self.hostname_resolution.update_etc_hosts(None)
625+
623626
current_mysqld_pid = self._mysql.get_pid_of_port_3306()
624627
self._mysql.configure_instance()
625628

‎src/hostname_resolution.py

+40-94
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33

44
"""Library containing logic pertaining to hostname resolutions in the VM charm."""
55

6-
import io
76
import json
87
import logging
98
import socket
109
import typing
1110

12-
from ops.charm import RelationDepartedEvent
1311
from ops.framework import Object
14-
from ops.model import BlockedStatus, Unit
12+
from ops.model import Unit
13+
from python_hosts import Hosts, HostsEntry
1514

1615
from constants import HOSTNAME_DETAILS, PEER
1716
from ip_address_observer import IPAddressChangeCharmEvents, IPAddressObserver
@@ -22,11 +21,15 @@
2221
if typing.TYPE_CHECKING:
2322
from charm import MySQLOperatorCharm
2423

24+
COMMENT = "Managed by mysql charm"
25+
2526

2627
class MySQLMachineHostnameResolution(Object):
2728
"""Encapsulation of the the machine hostname resolution."""
2829

29-
on = IPAddressChangeCharmEvents()
30+
on = ( # pyright: ignore [reportIncompatibleMethodOverride, reportAssignmentType
31+
IPAddressChangeCharmEvents()
32+
)
3033

3134
def __init__(self, charm: "MySQLOperatorCharm"):
3235
super().__init__(charm, "hostname-resolution")
@@ -38,12 +41,8 @@ def __init__(self, charm: "MySQLOperatorCharm"):
3841
self.framework.observe(self.charm.on.config_changed, self._update_host_details_in_databag)
3942
self.framework.observe(self.on.ip_address_change, self._update_host_details_in_databag)
4043

41-
self.framework.observe(
42-
self.charm.on[PEER].relation_changed, self._potentially_update_etc_hosts
43-
)
44-
self.framework.observe(
45-
self.charm.on[PEER].relation_departed, self._remove_host_from_etc_hosts
46-
)
44+
self.framework.observe(self.charm.on[PEER].relation_changed, self.update_etc_hosts)
45+
self.framework.observe(self.charm.on[PEER].relation_departed, self.update_etc_hosts)
4746

4847
self.ip_address_observer.start_observer()
4948

@@ -60,111 +59,58 @@ def _update_host_details_in_databag(self, _) -> None:
6059
logger.exception("Unable to get local IP address")
6160
ip = "127.0.0.1"
6261

63-
host_details = {
64-
"hostname": hostname,
65-
"fqdn": fqdn,
66-
"ip": ip,
67-
}
62+
host_details = {"names": [hostname, fqdn], "address": ip}
6863

6964
self.charm.unit_peer_data[HOSTNAME_DETAILS] = json.dumps(host_details)
7065

71-
def _get_host_details(self) -> dict[str, str]:
72-
host_details = {}
66+
def _get_host_details(self) -> list[HostsEntry]:
67+
host_details = list()
68+
69+
if not self.charm.peers:
70+
return []
7371

7472
for key, data in self.charm.peers.data.items():
7573
if isinstance(key, Unit) and data.get(HOSTNAME_DETAILS):
7674
unit_details = json.loads(data[HOSTNAME_DETAILS])
77-
unit_details["unit"] = key.name
78-
host_details[unit_details["hostname"]] = unit_details
79-
80-
return host_details
81-
82-
def _does_etc_hosts_need_update(self, host_details: dict[str, str]) -> bool:
83-
outdated_hosts = host_details.copy()
8475

85-
with open("/etc/hosts", "r") as hosts_file:
86-
for line in hosts_file:
87-
if "# unit=" not in line:
88-
continue
76+
if unit_details.get("address"):
77+
entry = HostsEntry(comment=COMMENT, entry_type="ipv4", **unit_details)
78+
else:
79+
# case when migrating from old format
80+
entry = HostsEntry(
81+
address=unit_details["ip"],
82+
names=[unit_details["hostname"], unit_details["fqdn"]],
83+
comment=COMMENT,
84+
entry_type="ipv4",
85+
)
8986

90-
ip, fqdn, hostname = line.split("#")[0].strip().split()
91-
if outdated_hosts.get(hostname).get("ip") == ip:
92-
outdated_hosts.pop(hostname)
87+
host_details.append(entry)
9388

94-
return bool(outdated_hosts)
89+
return host_details
9590

96-
def _potentially_update_etc_hosts(self, _) -> None:
91+
def update_etc_hosts(self, _) -> None:
9792
"""Potentially update the /etc/hosts file with new hostname to IP for units."""
9893
if not self.charm._is_peer_data_set:
9994
return
10095

101-
host_details = self._get_host_details()
102-
if not host_details:
96+
host_entries = self._get_host_details()
97+
if not host_entries:
10398
logger.debug("No hostnames in the peer databag. Skipping update of /etc/hosts")
10499
return
105100

106-
if not self._does_etc_hosts_need_update(host_details):
107-
logger.debug("No hostnames in /etc/hosts changed. Skipping update to /etc/hosts")
108-
return
109-
110-
hosts_in_file = []
111-
112-
with io.StringIO() as updated_hosts_file:
113-
with open("/etc/hosts", "r") as hosts_file:
114-
for line in hosts_file:
115-
if "# unit=" not in line:
116-
updated_hosts_file.write(line)
117-
continue
118-
119-
for hostname, details in host_details.items():
120-
if hostname == line.split()[2]:
121-
hosts_in_file.append(hostname)
122-
123-
fqdn, ip, unit = details["fqdn"], details["ip"], details["unit"]
124-
125-
logger.debug(
126-
f"Overwriting {hostname} ({unit=}) with {ip=}, {fqdn=} in /etc/hosts"
127-
)
128-
updated_hosts_file.write(f"{ip} {fqdn} {hostname} # unit={unit}\n")
129-
break
130-
131-
for hostname, details in host_details.items():
132-
if hostname not in hosts_in_file:
133-
fqdn, ip, unit = details["fqdn"], details["ip"], details["unit"]
134-
135-
logger.debug(f"Adding {hostname} ({unit=} with {ip=}, {fqdn=} in /etc/hosts")
136-
updated_hosts_file.write(f"{ip} {fqdn} {hostname} # unit={unit}\n")
137-
138-
with open("/etc/hosts", "w") as hosts_file:
139-
hosts_file.write(updated_hosts_file.getvalue())
140-
141-
try:
142-
self.charm._mysql.flush_host_cache()
143-
except MySQLFlushHostCacheError:
144-
self.charm.unit.status = BlockedStatus("Unable to flush MySQL host cache")
145-
146-
def _remove_host_from_etc_hosts(self, event: RelationDepartedEvent) -> None:
147-
departing_unit_name = event.unit.name
148-
149-
logger.debug(f"Checking if an entry for {departing_unit_name} is in /etc/hosts")
150-
with open("/etc/hosts", "r") as hosts_file:
151-
for line in hosts_file:
152-
if f"# unit={departing_unit_name}" in line:
153-
break
154-
else:
155-
return
101+
logger.debug("Updating /etc/hosts with new hostname to IP mappings")
102+
hosts = Hosts()
156103

157-
logger.debug(f"Removing entry for {departing_unit_name} from /etc/hosts")
158-
with io.StringIO() as updated_hosts_file:
159-
with open("/etc/hosts", "r") as hosts_file:
160-
for line in hosts_file:
161-
if f"# unit={departing_unit_name}" not in line:
162-
updated_hosts_file.write(line)
104+
if hosts.exists(address="127.0.1.1", names=[socket.getfqdn()]):
105+
# remove MAAS injected entry
106+
logger.debug("Removing MAAS injected entry from /etc/hosts")
107+
hosts.remove_all_matching(address="127.0.1.1")
163108

164-
with open("/etc/hosts", "w") as hosts_file:
165-
hosts_file.write(updated_hosts_file.getvalue())
109+
hosts.remove_all_matching(comment=COMMENT)
110+
hosts.add(host_entries)
111+
hosts.write()
166112

167113
try:
168114
self.charm._mysql.flush_host_cache()
169115
except MySQLFlushHostCacheError:
170-
self.charm.unit.status = BlockedStatus("Unable to flush MySQL host cache")
116+
logger.warning("Unable to flush MySQL host cache")

‎tests/unit/test_backups.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def test_can_unit_perform_backup(
327327
@patch_network_get(private_address="1.1.1.1")
328328
@patch("mysql_vm_helpers.MySQL.offline_mode_and_hidden_instance_exists", return_value=False)
329329
@patch("mysql_vm_helpers.MySQL.get_member_state")
330-
@patch("hostname_resolution.MySQLMachineHostnameResolution._remove_host_from_etc_hosts")
330+
@patch("python_hosts.Hosts.write")
331331
def test_can_unit_perform_backup_failure(
332332
self,
333333
_,
@@ -379,7 +379,7 @@ def test_can_unit_perform_backup_failure(
379379
@patch_network_get(private_address="1.1.1.1")
380380
@patch("mysql_vm_helpers.MySQL.set_instance_option")
381381
@patch("mysql_vm_helpers.MySQL.set_instance_offline_mode")
382-
@patch("hostname_resolution.MySQLMachineHostnameResolution._remove_host_from_etc_hosts")
382+
@patch("python_hosts.Hosts.write")
383383
def test_pre_backup(
384384
self,
385385
_,
@@ -549,7 +549,7 @@ def test_pre_restore_checks(
549549
@patch_network_get(private_address="1.1.1.1")
550550
@patch("mysql_vm_helpers.MySQL.is_server_connectable", return_value=True)
551551
@patch("charm.MySQLOperatorCharm.is_unit_busy", return_value=False)
552-
@patch("hostname_resolution.MySQLMachineHostnameResolution._remove_host_from_etc_hosts")
552+
@patch("python_hosts.Hosts.write")
553553
def test_pre_restore_checks_failure(
554554
self,
555555
_,

‎tests/unit/test_charm.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def test_on_start_exceptions(
290290
@patch("charm.is_volume_mounted", return_value=True)
291291
@patch("mysql_vm_helpers.MySQL.reboot_from_complete_outage")
292292
@patch("charm.snap_service_operation")
293-
@patch("hostname_resolution.MySQLMachineHostnameResolution._remove_host_from_etc_hosts")
293+
@patch("python_hosts.Hosts.write")
294294
def test_on_update(
295295
self,
296296
_,
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
import json
5+
import unittest
6+
from unittest.mock import patch
7+
8+
from ops.testing import Harness
9+
10+
from charm import MySQLOperatorCharm
11+
from constants import HOSTNAME_DETAILS, PEER
12+
13+
APP_NAME = "mysql"
14+
15+
16+
class TestHostnameResolution(unittest.TestCase):
17+
def setUp(self):
18+
self.harness = Harness(MySQLOperatorCharm)
19+
self.addCleanup(self.harness.cleanup)
20+
self.harness.begin()
21+
self.charm = self.harness.charm
22+
self.hostname_resolution = self.charm.hostname_resolution
23+
24+
def test_get_host_details(self):
25+
"""Test get_peer_host_details method."""
26+
host_entries = self.hostname_resolution._get_host_details()
27+
28+
# before relation
29+
self.assertEqual(host_entries, [])
30+
31+
# Add relation
32+
id = self.harness.add_relation(PEER, APP_NAME)
33+
34+
host_entries = self.hostname_resolution._get_host_details()
35+
self.assertEqual(host_entries, [])
36+
37+
# Add unit
38+
self.harness.add_relation_unit(id, f"{APP_NAME}/0")
39+
self.harness.update_relation_data(
40+
id,
41+
f"{APP_NAME}/0",
42+
{
43+
HOSTNAME_DETAILS: json.dumps(
44+
{"address": "1.1.1.1", "names": ["name1", "name2", "name3"]}
45+
)
46+
},
47+
)
48+
49+
host_entries = self.hostname_resolution._get_host_details()
50+
self.assertEqual(len(host_entries), 1)
51+
self.assertEqual(host_entries[0].address, "1.1.1.1")
52+
53+
@patch("socket.gethostname", return_value="mysql-0")
54+
def test_update_host_details_in_databag(self, _gethostname_mock):
55+
"""Test update_host_details_in_databag method."""
56+
# Add relation
57+
self.harness.add_relation(PEER, APP_NAME)
58+
self.assertEqual(self.charm.unit_peer_data.get(HOSTNAME_DETAILS), None)
59+
self.hostname_resolution._update_host_details_in_databag(None)
60+
_gethostname_mock.assert_called()
61+
62+
self.assertTrue("mysql-0" in self.charm.unit_peer_data[HOSTNAME_DETAILS])

0 commit comments

Comments
 (0)
Please sign in to comment.