fix(observer): make world/resolve-requests/ group-writable

world/resolve-requests/ was created via plain mkdir (0o755, masked by
umask), so the SSH operator (group aerbot) could not drop a resolve
flag there — the manual incident-resolve path required docker exec,
defeating its "operator drops a file" design.

Same defect and fix as executor.py's INBOX_DIR_MODE (2026-08-06): an
explicit, idempotent os.chmod(0o775) after mkdir, applied at the same
site the directory is created/used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMn76yx2CNuHKFVMrYcKWA
This commit is contained in:
oskar 2026-08-27 14:43:49 +02:00
parent 419df70e29
commit 89f75c34b0
2 changed files with 29 additions and 0 deletions

View file

@ -141,6 +141,11 @@ FAILED_EVENTS_DIR = STATE_DIR / "observer_failed_events"
# "operator drops a file, the owning process consumes it" pattern the actions # "operator drops a file, the owning process consumes it" pattern the actions
# pending/approved queue already uses. # pending/approved queue already uses.
RESOLVE_REQUESTS_DIR = WORLD_DIR / "resolve-requests" RESOLVE_REQUESTS_DIR = WORLD_DIR / "resolve-requests"
# mkdir(mode=...) is masked by the process umask, so the SSH operator (group
# aerbot) cannot drop a flag file into a dir created at the default 0o755 —
# same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06): an explicit,
# idempotent os.chmod after mkdir, applied on the same path the dir is used.
RESOLVE_REQUESTS_DIR_MODE = 0o775
# Time-based fallback for incidents that can never receive the service_healthy # Time-based fallback for incidents that can never receive the service_healthy
# event _resolve_incident() waits for (service removed/renamed/decommissioned # event _resolve_incident() waits for (service removed/renamed/decommissioned
@ -277,6 +282,13 @@ class Observer:
LOGS_DIR.mkdir(parents=True, exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True)
FAILED_EVENTS_DIR.mkdir(parents=True, exist_ok=True) FAILED_EVENTS_DIR.mkdir(parents=True, exist_ok=True)
RESOLVE_REQUESTS_DIR.mkdir(parents=True, exist_ok=True) RESOLVE_REQUESTS_DIR.mkdir(parents=True, exist_ok=True)
try:
os.chmod(RESOLVE_REQUESTS_DIR, RESOLVE_REQUESTS_DIR_MODE)
except OSError as e:
logger.warning(
f"Could not set mode {oct(RESOLVE_REQUESTS_DIR_MODE)} on "
f"{RESOLVE_REQUESTS_DIR}: {e}"
)
def _quarantine_event_file(self, file_path: str, node_dir: str, exc: Exception) -> None: def _quarantine_event_file(self, file_path: str, node_dir: str, exc: Exception) -> None:
"""Move an unreadable/unprocessable event out of the hot path.""" """Move an unreadable/unprocessable event out of the hot path."""

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@ -915,3 +916,19 @@ def test_resolve_request_flag_for_already_resolved_incident_is_removed(tmp_path)
assert obs.world_state["incidents"][inc_id]["status"] == "resolved" assert obs.world_state["incidents"][inc_id]["status"] == "resolved"
assert obs.world_state["incidents"][inc_id]["resolved_reason"] == "manual_operator" assert obs.world_state["incidents"][inc_id]["resolved_reason"] == "manual_operator"
assert not flag.exists() assert not flag.exists()
def test_resolve_requests_dir_is_group_writable(tmp_path, monkeypatch):
"""world/resolve-requests/ must be group-writable so an SSH operator
(group aerbot, not the observer's own user) can drop a resolve flag file
without docker exec mkdir(mode=...) alone is masked by the process
umask, same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06)."""
old_umask = os.umask(0o022)
try:
obs = _make_observer_simple(tmp_path)
finally:
os.umask(old_umask)
import observer.observer as obs_mod
mode = obs_mod.RESOLVE_REQUESTS_DIR.stat().st_mode & 0o777
assert mode == 0o775