Skip to content

Commit 5c95451

Browse files
authored
Merge pull request #73 from jantman/status-updates
Add status tracking and monitoring improvements (v0.6.0)
2 parents fcf9174 + 006ede1 commit 5c95451

8 files changed

Lines changed: 109 additions & 6 deletions

File tree

docs/source/admin.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ An example response from the metrics endpoint is:
5858
# HELP user_config_load_timestamp The timestamp when the users config was loaded
5959
# TYPE user_config_load_timestamp gauge
6060
user_config_load_timestamp 1.689477248e+09
61+
# HELP user_config_file_mtime The modification time of the users config file
62+
# TYPE user_config_file_mtime gauge
63+
user_config_file_mtime 1.689477248e+09
6164
# HELP app_start_timestamp The timestamp when the server app started
6265
# TYPE app_start_timestamp gauge
6366
app_start_timestamp 1.689477248e+09

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "machine_access_control"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
description = "Decatur Makers Machine Access Control package"
55
authors = ["Jason Antman <jason@jasonantman.com>"]
66
license = "MIT"

src/dm_mac/models/users.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Models for users and tools for loading users config."""
22

33
import logging
4+
import os
45
from time import time
56
from typing import Any
67
from typing import Dict
@@ -127,11 +128,19 @@ def __init__(self) -> None:
127128
for fob in user.fob_codes:
128129
self.users_by_fob[fob] = user
129130
self.load_time: float = time()
131+
self.file_mtime: float = os.path.getmtime(self._get_config_path())
132+
133+
def _get_config_path(self) -> str:
134+
"""Get the path to the users config file."""
135+
if "USERS_CONFIG" in os.environ:
136+
return os.environ["USERS_CONFIG"]
137+
return "users.json"
130138

131139
def _load_and_validate_config(self) -> List[Dict[str, Any]]:
132140
"""Load and validate the config file."""
133141
config: List[Dict[str, Any]] = cast(
134142
List[Dict[str, Any]],
143+
# if changing, be sure to also update _get_config_path()
135144
load_json_config("USERS_CONFIG", "users.json"),
136145
)
137146
UsersConfig.validate_config(config)
@@ -211,4 +220,5 @@ def reload(self) -> Tuple[int, int, int]:
211220
added += 1
212221
logger.info("Done reloading users config.")
213222
self.load_time = time()
223+
self.file_mtime = os.path.getmtime(self._get_config_path())
214224
return removed, updated, added

src/dm_mac/slack_handler.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from dm_mac.models.machine import Machine
1818
from dm_mac.models.machine import MachinesConfig
19+
from dm_mac.models.users import UsersConfig
1920

2021

2122
logger: logging.Logger = logging.getLogger(__name__)
@@ -170,7 +171,15 @@ async def handle_command(self, msg: Message, say: AsyncSay) -> None:
170171

171172
async def machine_status(self, say: AsyncSay) -> None:
172173
"""Respond with machine status."""
173-
resp: str = ""
174+
server_uptime: str = naturaldelta(time.time() - self.quart.config["START_TIME"])
175+
uconf: UsersConfig = self.quart.config["USERS"]
176+
users_config_age: str = naturaldelta(time.time() - uconf.file_mtime)
177+
num_users: int = len(uconf.users)
178+
num_fobs: int = len(uconf.users_by_fob)
179+
resp: str = (
180+
f"Server uptime: {server_uptime}\n"
181+
f"Users config: {users_config_age} old, {num_users} users, {num_fobs} fobs\n\n"
182+
)
174183
mconf: MachinesConfig = self.quart.config["MACHINES"]
175184
mname: str
176185
mach: Machine

src/dm_mac/views/prometheus.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ def collect(self) -> Generator[LabeledGaugeMetricFamily, None, None]:
6969
"The timestamp when the users config was loaded",
7070
)
7171
uconf_load.add_metric({}, uconf.load_time)
72+
uconf_mtime: LabeledGaugeMetricFamily = LabeledGaugeMetricFamily(
73+
"user_config_file_mtime",
74+
"The modification time of the users config file",
75+
)
76+
uconf_mtime.add_metric({}, uconf.file_mtime)
7277
stime: LabeledGaugeMetricFamily = LabeledGaugeMetricFamily(
7378
"app_start_timestamp", "The timestamp when the server app started"
7479
)
@@ -167,6 +172,7 @@ def collect(self) -> Generator[LabeledGaugeMetricFamily, None, None]:
167172
)
168173
yield mconf_load
169174
yield uconf_load
175+
yield uconf_mtime
170176
yield stime
171177
yield numu
172178
yield numf

tests/models/test_users.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@ def test_default_config(self, fixtures_path: str, tmp_path: Path) -> None:
2727
conf_path: str = os.path.join(
2828
fixtures_path, "test_neongetter", "users-happy.json"
2929
)
30-
shutil.copy(conf_path, os.path.join(tmp_path, "users.json"))
30+
upath: str = os.path.join(tmp_path, "users.json")
31+
shutil.copy(conf_path, upath)
3132
os.chdir(tmp_path)
3233
cls: UsersConfig = UsersConfig()
3334
assert len(cls.users) == 594
3435
assert len(cls.users_by_fob) == 600
3536
assert cls.load_time == 1689477248.0
37+
# Check that file_mtime is set to the actual file's mtime
38+
assert isinstance(cls.file_mtime, float)
39+
assert cls.file_mtime == os.path.getmtime(upath)
3640

3741
@freeze_time("2023-07-16 03:14:08", tz_offset=0)
3842
def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None:
@@ -82,6 +86,50 @@ def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None:
8286
assert isinstance(cls.users[x], User)
8387
assert cls.users[x].as_dict == conf[x]
8488
assert cls.load_time == 1689477248.0
89+
# Check that file_mtime is set to the actual file's mtime
90+
assert isinstance(cls.file_mtime, float)
91+
assert cls.file_mtime == os.path.getmtime(cpath)
92+
93+
@freeze_time("2023-07-16 03:14:08", tz_offset=0)
94+
def test_reload_updates_file_mtime(self, tmp_path: Path) -> None:
95+
"""Test that reload() updates file_mtime."""
96+
conf: List[Dict[str, Any]] = [
97+
{
98+
"account_id": "410",
99+
"authorizations": ["Dimensioning Tools"],
100+
"email": "user@example.com",
101+
"expiration_ymd": "2024-08-27",
102+
"fob_codes": ["0725858614"],
103+
"full_name": "Test User",
104+
"first_name": "Test",
105+
"preferred_name": "PTest",
106+
}
107+
]
108+
cpath: str = str(os.path.join(tmp_path, "users.json"))
109+
with open(cpath, "w") as fh:
110+
json.dump(conf, fh, sort_keys=True, indent=4)
111+
with patch.dict(os.environ, {"USERS_CONFIG": cpath}):
112+
cls: UsersConfig = UsersConfig()
113+
assert cls.load_time == 1689477248.0
114+
initial_mtime = cls.file_mtime
115+
assert isinstance(initial_mtime, float)
116+
assert initial_mtime == os.path.getmtime(cpath)
117+
# Simulate time passing and file being modified
118+
import time
119+
120+
time.sleep(0.1)
121+
# Touch the file to update its mtime
122+
Path(cpath).touch()
123+
new_mtime = os.path.getmtime(cpath)
124+
assert new_mtime > initial_mtime
125+
# Reload the config
126+
removed, updated, added = cls.reload()
127+
assert removed == 0
128+
assert updated == 0
129+
assert added == 0
130+
# Check that file_mtime was updated
131+
assert cls.file_mtime == new_mtime
132+
assert cls.file_mtime > initial_mtime
85133

86134
def test_invalid_config(self, fixtures_path: str, tmp_path: Path) -> None:
87135
"""Test using default config file path."""

tests/test_slack_handler.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ async def test_handle_command_status_admin_channel(self, tmp_path) -> None:
263263
say = AsyncMock()
264264
await self.cls.handle_command(msg, say)
265265
expected = (
266+
"Server uptime: a moment\n"
267+
"Users config: a moment old, 4 users, 4 fobs\n\n"
266268
"always-on-machine: Idle \n"
267269
"esp32test: Idle \n"
268270
"hammer: Idle (last contact a minute ago; last update a minute ago;"
@@ -324,6 +326,8 @@ async def test_handle_command_status_oops_channel(self, tmp_path) -> None:
324326
say = AsyncMock()
325327
await self.cls.handle_command(msg, say)
326328
expected = (
329+
"Server uptime: a moment\n"
330+
"Users config: a moment old, 4 users, 4 fobs\n\n"
327331
"always-on-machine: Idle \n"
328332
"esp32test: Idle \n"
329333
"hammer: Idle (last contact a minute ago; "
@@ -727,8 +731,12 @@ def setup_machines(fixture_dir: Path, test_class: TestSlackHandler) -> None:
727731
"MACHINE_STATE_DIR": str(os.path.join(fixture_dir, "machine_state")),
728732
},
729733
):
734+
uconf = UsersConfig()
735+
# Set file_mtime to frozen time for consistent test output
736+
uconf.file_mtime = 1689477248.0
730737
type(test_class.quart_app).config = {
731738
"MACHINES": MachinesConfig(),
732-
"USERS": UsersConfig(),
739+
"USERS": uconf,
733740
"SLACK_HANDLER": test_class.cls,
741+
"START_TIME": 1689477248.0,
734742
}

tests/views/test_prometheus.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
5454
app, client = app_and_client(tmp_path)
5555
now: float = time()
5656
uconf: UsersConfig = app.config["USERS"]
57+
file_mtime: float = uconf.file_mtime
5758
jantman: User = uconf.users_by_fob["0014916441"]
5859
mconf: MachinesConfig = app.config["MACHINES"]
5960
mill: Machine = mconf.machines_by_name["metal-mill"]
@@ -90,6 +91,11 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
9091
custom_metrics = (
9192
"\n" + text[text.find("# HELP machine_config_load_timestamp") :]
9293
)
94+
# Extract actual file_mtime value from response
95+
import re
96+
97+
mtime_match = re.search(r"user_config_file_mtime ([\d.e+]+)", text)
98+
actual_mtime_str = mtime_match.group(1) if mtime_match else str(file_mtime)
9399
expected = dedent(
94100
"""
95101
# HELP machine_config_load_timestamp The timestamp when the machine config was loaded
@@ -98,6 +104,9 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
98104
# HELP user_config_load_timestamp The timestamp when the users config was loaded
99105
# TYPE user_config_load_timestamp gauge
100106
user_config_load_timestamp 1.689477248e+09
107+
# HELP user_config_file_mtime The modification time of the users config file
108+
# TYPE user_config_file_mtime gauge
109+
user_config_file_mtime __FILE_MTIME__
101110
# HELP app_start_timestamp The timestamp when the server app started
102111
# TYPE app_start_timestamp gauge
103112
app_start_timestamp 1.689477248e+09
@@ -246,7 +255,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
246255
machine_status_led{display_name="always-on-machine",led_attribute="blue",machine_name="always-on-machine"} 0.0
247256
machine_status_led{display_name="always-on-machine",led_attribute="brightness",machine_name="always-on-machine"} 0.0
248257
""" # noqa: E501
249-
)
258+
).replace("__FILE_MTIME__", actual_mtime_str)
250259
assert custom_metrics == expected
251260
assert (
252261
response.headers["Content-Type"] == CONTENT_TYPE_LATEST + "; charset=utf-8"
@@ -258,12 +267,19 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
258267
app: Quart
259268
client: TestClientProtocol
260269
app, client = app_and_client(tmp_path)
270+
uconf: UsersConfig = app.config["USERS"]
271+
file_mtime: float = uconf.file_mtime
261272
response: Response = await client.get("/metrics")
262273
assert response.status_code == 200
263274
text = await response.get_data(True)
264275
custom_metrics = (
265276
"\n" + text[text.find("# HELP machine_config_load_timestamp") :]
266277
)
278+
# Extract actual file_mtime value from response
279+
import re
280+
281+
mtime_match = re.search(r"user_config_file_mtime ([\d.e+]+)", text)
282+
actual_mtime_str = mtime_match.group(1) if mtime_match else str(file_mtime)
267283
expected = dedent(
268284
"""
269285
# HELP machine_config_load_timestamp The timestamp when the machine config was loaded
@@ -272,6 +288,9 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
272288
# HELP user_config_load_timestamp The timestamp when the users config was loaded
273289
# TYPE user_config_load_timestamp gauge
274290
user_config_load_timestamp 1.689477248e+09
291+
# HELP user_config_file_mtime The modification time of the users config file
292+
# TYPE user_config_file_mtime gauge
293+
user_config_file_mtime __FILE_MTIME__
275294
# HELP app_start_timestamp The timestamp when the server app started
276295
# TYPE app_start_timestamp gauge
277296
app_start_timestamp 1.689477248e+09
@@ -420,7 +439,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
420439
machine_status_led{display_name="always-on-machine",led_attribute="blue",machine_name="always-on-machine"} 0.0
421440
machine_status_led{display_name="always-on-machine",led_attribute="brightness",machine_name="always-on-machine"} 0.0
422441
""" # noqa: E501
423-
)
442+
).replace("__FILE_MTIME__", actual_mtime_str)
424443
assert custom_metrics == expected
425444
assert (
426445
response.headers["Content-Type"] == CONTENT_TYPE_LATEST + "; charset=utf-8"

0 commit comments

Comments
 (0)