Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/source/admin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ An example response from the metrics endpoint is:
# HELP user_config_load_timestamp The timestamp when the users config was loaded
# TYPE user_config_load_timestamp gauge
user_config_load_timestamp 1.689477248e+09
# HELP user_config_file_mtime The modification time of the users config file
# TYPE user_config_file_mtime gauge
user_config_file_mtime 1.689477248e+09
# HELP app_start_timestamp The timestamp when the server app started
# TYPE app_start_timestamp gauge
app_start_timestamp 1.689477248e+09
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine_access_control"
version = "0.5.0"
version = "0.6.0"
description = "Decatur Makers Machine Access Control package"
authors = ["Jason Antman <[email protected]>"]
license = "MIT"
Expand Down
10 changes: 10 additions & 0 deletions src/dm_mac/models/users.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Models for users and tools for loading users config."""

import logging
import os
from time import time
from typing import Any
from typing import Dict
Expand Down Expand Up @@ -127,11 +128,19 @@ def __init__(self) -> None:
for fob in user.fob_codes:
self.users_by_fob[fob] = user
self.load_time: float = time()
self.file_mtime: float = os.path.getmtime(self._get_config_path())

def _get_config_path(self) -> str:
"""Get the path to the users config file."""
if "USERS_CONFIG" in os.environ:
return os.environ["USERS_CONFIG"]
return "users.json"

def _load_and_validate_config(self) -> List[Dict[str, Any]]:
"""Load and validate the config file."""
config: List[Dict[str, Any]] = cast(
List[Dict[str, Any]],
# if changing, be sure to also update _get_config_path()
load_json_config("USERS_CONFIG", "users.json"),
)
UsersConfig.validate_config(config)
Expand Down Expand Up @@ -211,4 +220,5 @@ def reload(self) -> Tuple[int, int, int]:
added += 1
logger.info("Done reloading users config.")
self.load_time = time()
self.file_mtime = os.path.getmtime(self._get_config_path())
return removed, updated, added
11 changes: 10 additions & 1 deletion src/dm_mac/slack_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from dm_mac.models.machine import Machine
from dm_mac.models.machine import MachinesConfig
from dm_mac.models.users import UsersConfig


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

async def machine_status(self, say: AsyncSay) -> None:
"""Respond with machine status."""
resp: str = ""
server_uptime: str = naturaldelta(time.time() - self.quart.config["START_TIME"])
uconf: UsersConfig = self.quart.config["USERS"]
users_config_age: str = naturaldelta(time.time() - uconf.file_mtime)
num_users: int = len(uconf.users)
num_fobs: int = len(uconf.users_by_fob)
resp: str = (
f"Server uptime: {server_uptime}\n"
f"Users config: {users_config_age} old, {num_users} users, {num_fobs} fobs\n\n"
)
mconf: MachinesConfig = self.quart.config["MACHINES"]
mname: str
mach: Machine
Expand Down
6 changes: 6 additions & 0 deletions src/dm_mac/views/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ def collect(self) -> Generator[LabeledGaugeMetricFamily, None, None]:
"The timestamp when the users config was loaded",
)
uconf_load.add_metric({}, uconf.load_time)
uconf_mtime: LabeledGaugeMetricFamily = LabeledGaugeMetricFamily(
"user_config_file_mtime",
"The modification time of the users config file",
)
uconf_mtime.add_metric({}, uconf.file_mtime)
stime: LabeledGaugeMetricFamily = LabeledGaugeMetricFamily(
"app_start_timestamp", "The timestamp when the server app started"
)
Expand Down Expand Up @@ -167,6 +172,7 @@ def collect(self) -> Generator[LabeledGaugeMetricFamily, None, None]:
)
yield mconf_load
yield uconf_load
yield uconf_mtime
yield stime
yield numu
yield numf
Expand Down
50 changes: 49 additions & 1 deletion tests/models/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ def test_default_config(self, fixtures_path: str, tmp_path: Path) -> None:
conf_path: str = os.path.join(
fixtures_path, "test_neongetter", "users-happy.json"
)
shutil.copy(conf_path, os.path.join(tmp_path, "users.json"))
upath: str = os.path.join(tmp_path, "users.json")
shutil.copy(conf_path, upath)
os.chdir(tmp_path)
cls: UsersConfig = UsersConfig()
assert len(cls.users) == 594
assert len(cls.users_by_fob) == 600
assert cls.load_time == 1689477248.0
# Check that file_mtime is set to the actual file's mtime
assert isinstance(cls.file_mtime, float)
assert cls.file_mtime == os.path.getmtime(upath)

@freeze_time("2023-07-16 03:14:08", tz_offset=0)
def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None:
Expand Down Expand Up @@ -82,6 +86,50 @@ def test_config_path(self, fixtures_path: str, tmp_path: Path) -> None:
assert isinstance(cls.users[x], User)
assert cls.users[x].as_dict == conf[x]
assert cls.load_time == 1689477248.0
# Check that file_mtime is set to the actual file's mtime
assert isinstance(cls.file_mtime, float)
assert cls.file_mtime == os.path.getmtime(cpath)

@freeze_time("2023-07-16 03:14:08", tz_offset=0)
def test_reload_updates_file_mtime(self, tmp_path: Path) -> None:
"""Test that reload() updates file_mtime."""
conf: List[Dict[str, Any]] = [
{
"account_id": "410",
"authorizations": ["Dimensioning Tools"],
"email": "[email protected]",
"expiration_ymd": "2024-08-27",
"fob_codes": ["0725858614"],
"full_name": "Test User",
"first_name": "Test",
"preferred_name": "PTest",
}
]
cpath: str = str(os.path.join(tmp_path, "users.json"))
with open(cpath, "w") as fh:
json.dump(conf, fh, sort_keys=True, indent=4)
with patch.dict(os.environ, {"USERS_CONFIG": cpath}):
cls: UsersConfig = UsersConfig()
assert cls.load_time == 1689477248.0
initial_mtime = cls.file_mtime
assert isinstance(initial_mtime, float)
assert initial_mtime == os.path.getmtime(cpath)
# Simulate time passing and file being modified
import time

time.sleep(0.1)
# Touch the file to update its mtime
Path(cpath).touch()
new_mtime = os.path.getmtime(cpath)
assert new_mtime > initial_mtime
# Reload the config
removed, updated, added = cls.reload()
assert removed == 0
assert updated == 0
assert added == 0
# Check that file_mtime was updated
assert cls.file_mtime == new_mtime
assert cls.file_mtime > initial_mtime

def test_invalid_config(self, fixtures_path: str, tmp_path: Path) -> None:
"""Test using default config file path."""
Expand Down
10 changes: 9 additions & 1 deletion tests/test_slack_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ async def test_handle_command_status_admin_channel(self, tmp_path) -> None:
say = AsyncMock()
await self.cls.handle_command(msg, say)
expected = (
"Server uptime: a moment\n"
"Users config: a moment old, 4 users, 4 fobs\n\n"
"always-on-machine: Idle \n"
"esp32test: Idle \n"
"hammer: Idle (last contact a minute ago; last update a minute ago;"
Expand Down Expand Up @@ -324,6 +326,8 @@ async def test_handle_command_status_oops_channel(self, tmp_path) -> None:
say = AsyncMock()
await self.cls.handle_command(msg, say)
expected = (
"Server uptime: a moment\n"
"Users config: a moment old, 4 users, 4 fobs\n\n"
"always-on-machine: Idle \n"
"esp32test: Idle \n"
"hammer: Idle (last contact a minute ago; "
Expand Down Expand Up @@ -727,8 +731,12 @@ def setup_machines(fixture_dir: Path, test_class: TestSlackHandler) -> None:
"MACHINE_STATE_DIR": str(os.path.join(fixture_dir, "machine_state")),
},
):
uconf = UsersConfig()
# Set file_mtime to frozen time for consistent test output
uconf.file_mtime = 1689477248.0
type(test_class.quart_app).config = {
"MACHINES": MachinesConfig(),
"USERS": UsersConfig(),
"USERS": uconf,
"SLACK_HANDLER": test_class.cls,
"START_TIME": 1689477248.0,
}
23 changes: 21 additions & 2 deletions tests/views/test_prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
app, client = app_and_client(tmp_path)
now: float = time()
uconf: UsersConfig = app.config["USERS"]
file_mtime: float = uconf.file_mtime
jantman: User = uconf.users_by_fob["0014916441"]
mconf: MachinesConfig = app.config["MACHINES"]
mill: Machine = mconf.machines_by_name["metal-mill"]
Expand Down Expand Up @@ -90,6 +91,11 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
custom_metrics = (
"\n" + text[text.find("# HELP machine_config_load_timestamp") :]
)
# Extract actual file_mtime value from response
import re

mtime_match = re.search(r"user_config_file_mtime ([\d.e+]+)", text)
actual_mtime_str = mtime_match.group(1) if mtime_match else str(file_mtime)
expected = dedent(
"""
# HELP machine_config_load_timestamp The timestamp when the machine config was loaded
Expand All @@ -98,6 +104,9 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
# HELP user_config_load_timestamp The timestamp when the users config was loaded
# TYPE user_config_load_timestamp gauge
user_config_load_timestamp 1.689477248e+09
# HELP user_config_file_mtime The modification time of the users config file
# TYPE user_config_file_mtime gauge
user_config_file_mtime __FILE_MTIME__
# HELP app_start_timestamp The timestamp when the server app started
# TYPE app_start_timestamp gauge
app_start_timestamp 1.689477248e+09
Expand Down Expand Up @@ -246,7 +255,7 @@ async def test_metrics_nondefaults(self, tmp_path: Path) -> None:
machine_status_led{display_name="always-on-machine",led_attribute="blue",machine_name="always-on-machine"} 0.0
machine_status_led{display_name="always-on-machine",led_attribute="brightness",machine_name="always-on-machine"} 0.0
""" # noqa: E501
)
).replace("__FILE_MTIME__", actual_mtime_str)
assert custom_metrics == expected
assert (
response.headers["Content-Type"] == CONTENT_TYPE_LATEST + "; charset=utf-8"
Expand All @@ -258,12 +267,19 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
app: Quart
client: TestClientProtocol
app, client = app_and_client(tmp_path)
uconf: UsersConfig = app.config["USERS"]
file_mtime: float = uconf.file_mtime
response: Response = await client.get("/metrics")
assert response.status_code == 200
text = await response.get_data(True)
custom_metrics = (
"\n" + text[text.find("# HELP machine_config_load_timestamp") :]
)
# Extract actual file_mtime value from response
import re

mtime_match = re.search(r"user_config_file_mtime ([\d.e+]+)", text)
actual_mtime_str = mtime_match.group(1) if mtime_match else str(file_mtime)
expected = dedent(
"""
# HELP machine_config_load_timestamp The timestamp when the machine config was loaded
Expand All @@ -272,6 +288,9 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
# HELP user_config_load_timestamp The timestamp when the users config was loaded
# TYPE user_config_load_timestamp gauge
user_config_load_timestamp 1.689477248e+09
# HELP user_config_file_mtime The modification time of the users config file
# TYPE user_config_file_mtime gauge
user_config_file_mtime __FILE_MTIME__
# HELP app_start_timestamp The timestamp when the server app started
# TYPE app_start_timestamp gauge
app_start_timestamp 1.689477248e+09
Expand Down Expand Up @@ -420,7 +439,7 @@ async def test_metrics_defaults(self, tmp_path: Path) -> None:
machine_status_led{display_name="always-on-machine",led_attribute="blue",machine_name="always-on-machine"} 0.0
machine_status_led{display_name="always-on-machine",led_attribute="brightness",machine_name="always-on-machine"} 0.0
""" # noqa: E501
)
).replace("__FILE_MTIME__", actual_mtime_str)
assert custom_metrics == expected
assert (
response.headers["Content-Type"] == CONTENT_TYPE_LATEST + "; charset=utf-8"
Expand Down
Loading