Compare commits
1 commit
75d96956a5
...
a62c36c0b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62c36c0b6 |
|
|
@ -1,3 +1,16 @@
|
||||||
|
# MITYGACJA TYMCZASOWA (M1) — założona 2026-08-04.
|
||||||
|
# NODE_TYPE=lte_node wyłącza run_safe_cleanup() (niefiltrowany
|
||||||
|
# `docker container prune`) na czas backfillu embed (faza mailowa KB).
|
||||||
|
# Incydent: kb/incidents/2026-07-30-ollama-solaria-vanish.md (§7, M1).
|
||||||
|
# Bez tego każdy zatrzymany kontener na SOLARII znika w ≤60 s — również taki
|
||||||
|
# z `restart: unless-stopped`, zatrzymany świadomie przez operatora.
|
||||||
|
# Warunek zdjęcia: R1 (filtrowanie prune po restart policy / labelu compose)
|
||||||
|
# wdrożony na tym nodzie — R1–R3 są w toku po stronie subsystemu A.
|
||||||
|
# Po zdjęciu przywrócić: NODE_TYPE=ai_node.
|
||||||
|
# Zakres wyłączenia: `lte_node` pomija CAŁY cleanup, więc na czas mitygacji
|
||||||
|
# nie są też sprzątane dangling images ani build cache — pilnować miejsca
|
||||||
|
# na dysku. Monitoring, eventy i dispatch akcji działają bez zmian
|
||||||
|
# (self.node_type jest czytane wyłącznie w run_safe_cleanup i dwóch liniach logu).
|
||||||
services:
|
services:
|
||||||
node-agent:
|
node-agent:
|
||||||
# Docker GID on SOLARIA is 996 (not the Debian default 999 the base compose
|
# Docker GID on SOLARIA is 996 (not the Debian default 999 the base compose
|
||||||
|
|
@ -10,11 +23,7 @@ services:
|
||||||
- "996" # host docker gid, verified 2026-07-30 (getent group docker → 996)
|
- "996" # host docker gid, verified 2026-07-30 (getent group docker → 996)
|
||||||
environment:
|
environment:
|
||||||
- NODE_NAME=solaria
|
- NODE_NAME=solaria
|
||||||
# ai_node = dangling images + kontenery + build cache, ale NIGDY
|
- NODE_TYPE=lte_node # M1 (2026-08-04) — było: ai_node; przywrócić po R1
|
||||||
# `image prune -a` (skasowałoby obrazy runtime Ollamy). Ustawione jawnie,
|
|
||||||
# zgodnie z konwencją pozostałych hostów, mimo że solaria jest w AI_NODES
|
|
||||||
# w node_agent.py i default dałby to samo.
|
|
||||||
- NODE_TYPE=ai_node
|
|
||||||
- VPS_EVENTS_HOST=100.95.58.48
|
- VPS_EVENTS_HOST=100.95.58.48
|
||||||
- VPS_EVENTS_USER=oskar
|
- VPS_EVENTS_USER=oskar
|
||||||
- VPS_EVENTS_PATH=/opt/homelab/events
|
- VPS_EVENTS_PATH=/opt/homelab/events
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,18 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_NAME=vps
|
- NODE_NAME=vps
|
||||||
- CHECK_INTERVAL=60
|
- CHECK_INTERVAL=60
|
||||||
# No NODE_TYPE here on purpose: `vps` is in none of node_agent.py's
|
# TEMPORARY mitigation (M1) for the unfiltered-prune incident
|
||||||
# LTE_NODES / SD_CARD_NODES / AI_NODES sets, so _resolve_node_type() falls
|
# (kb/incidents/2026-07-30-ollama-solaria-vanish.md §7). node-agent runs
|
||||||
# through to "standard" — dangling images + stopped containers + build
|
# `docker container prune()` with NO filters every CHECK_INTERVAL, and the
|
||||||
# cache, plus the control-plane filesystem rotation (that one is gated on
|
# Docker API removes EVERY non-running container regardless of restart
|
||||||
# node_name == VPS_NODE_NAME, not on node_type). This is the pre-M1 state.
|
# policy or compose labels — this already destroyed ollama@solaria. On VPS
|
||||||
|
# the loss is worse: humanai-mailer and humanai-landing have no compose
|
||||||
|
# definition in this repo, so a pruned container cannot be recreated.
|
||||||
|
# node_type is read ONLY by run_safe_cleanup() (plus two log lines), so
|
||||||
|
# lte_node disables cleanup and nothing else — monitoring, event shipping
|
||||||
|
# and action dispatch keep working.
|
||||||
|
# REMOVE once R1 (explicit-enumeration prune) is deployed to VPS.
|
||||||
|
- NODE_TYPE=lte_node
|
||||||
# host network mode: node-agent on VPS shares the host's network namespace
|
# host network mode: node-agent on VPS shares the host's network namespace
|
||||||
# so that localhost:18180 resolves to the control-plane's exposed port.
|
# so that localhost:18180 resolves to the control-plane's exposed port.
|
||||||
# Without this, localhost inside the container is the container's own loopback
|
# Without this, localhost inside the container is the container's own loopback
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ def _atomic_write_json(path: Path, data) -> None:
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
# Constants and Paths
|
# Constants and Paths
|
||||||
RUNTIME_PATH = os.getenv("RUNTIME_PATH", "/opt/homelab")
|
RUNTIME_PATH = os.getenv("RUNTIME_PATH", "/opt/homelab")
|
||||||
ACTIONS_DIR = Path(RUNTIME_PATH) / "actions"
|
ACTIONS_DIR = Path(RUNTIME_PATH) / "actions"
|
||||||
|
|
@ -28,17 +27,6 @@ DISPATCH_DIR = ACTIONS_DIR / "dispatch"
|
||||||
# failure-reports every file it finds in its inbox, so a redeploy landing there
|
# failure-reports every file it finds in its inbox, so a redeploy landing there
|
||||||
# would be killed before the runner ever saw it.
|
# would be killed before the runner ever saw it.
|
||||||
DEPLOY_DISPATCH_DIR = ACTIONS_DIR / "deploy"
|
DEPLOY_DISPATCH_DIR = ACTIONS_DIR / "deploy"
|
||||||
|
|
||||||
# Mode for the per-node inboxes under DISPATCH_DIR / DEPLOY_DISPATCH_DIR.
|
|
||||||
# These dirs are written by the executor (as the control-plane user on VPS) but
|
|
||||||
# drained by the target node, whose rsync-pull authenticates as a *different*
|
|
||||||
# user that is only a member of the owning group. --remove-source-files must
|
|
||||||
# unlink the fetched file, and unlink needs write permission on the containing
|
|
||||||
# directory — at 0o755 the group has none, so the source survives, rsync exits
|
|
||||||
# 23, and the node re-pulls the same action every cycle forever, bouncing off
|
|
||||||
# the idempotency gate. Observed on dispatch/lustro 2026-08-06 (session log,
|
|
||||||
# follow-up #1); the older dispatch/piha happened to be 775 and worked.
|
|
||||||
INBOX_DIR_MODE = 0o775
|
|
||||||
# The executor no longer reads the repo at all (the old redeploy path ran a
|
# The executor no longer reads the repo at all (the old redeploy path ran a
|
||||||
# script out of it). Kept only so an operator can still see which checkout the
|
# script out of it). Kept only so an operator can still see which checkout the
|
||||||
# container is wired to; nothing in this module resolves paths against it.
|
# container is wired to; nothing in this module resolves paths against it.
|
||||||
|
|
@ -80,27 +68,6 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
|
||||||
logger = logging.getLogger("executor")
|
logger = logging.getLogger("executor")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_inbox_dir(path: Path) -> None:
|
|
||||||
"""Create (or repair) a per-node inbox so the target node can drain it.
|
|
||||||
|
|
||||||
chmod runs unconditionally rather than only on creation, for two reasons:
|
|
||||||
mkdir(mode=...) is masked by the process umask and so cannot be relied on to
|
|
||||||
produce INBOX_DIR_MODE, and inboxes created by an earlier executor build
|
|
||||||
already exist at 0o755 across the fleet. Repairing here — on the same code
|
|
||||||
path that writes the dispatch file — keeps the fix to one idempotent call
|
|
||||||
and needs no startup scan that could drift out of sync with the writers.
|
|
||||||
"""
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
os.chmod(path, INBOX_DIR_MODE)
|
|
||||||
except OSError as e:
|
|
||||||
# Deliberately not fatal: the dispatch file still gets written and the
|
|
||||||
# node still executes the action. Only the post-fetch source delete
|
|
||||||
# stays broken — and that now surfaces as a WARNING on the node side
|
|
||||||
# (node_agent.pull_dispatched_actions) instead of being swallowed.
|
|
||||||
logger.warning(f"Could not set mode {oct(INBOX_DIR_MODE)} on {path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class Executor:
|
class Executor:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._ensure_dirs()
|
self._ensure_dirs()
|
||||||
|
|
@ -250,7 +217,7 @@ class Executor:
|
||||||
logger.error(f"Action {action_id}: container_restart with no node set")
|
logger.error(f"Action {action_id}: container_restart with no node set")
|
||||||
return
|
return
|
||||||
inbox = DISPATCH_DIR / node
|
inbox = DISPATCH_DIR / node
|
||||||
_ensure_inbox_dir(inbox)
|
inbox.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {
|
payload = {
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
"type": "container_restart",
|
"type": "container_restart",
|
||||||
|
|
@ -282,7 +249,7 @@ class Executor:
|
||||||
Does not resolve the action itself; _reconcile_running_actions() does.
|
Does not resolve the action itself; _reconcile_running_actions() does.
|
||||||
"""
|
"""
|
||||||
inbox = DEPLOY_DISPATCH_DIR / node
|
inbox = DEPLOY_DISPATCH_DIR / node
|
||||||
_ensure_inbox_dir(inbox)
|
inbox.mkdir(parents=True, exist_ok=True)
|
||||||
payload = {
|
payload = {
|
||||||
"action_id": action_id,
|
"action_id": action_id,
|
||||||
"type": "redeploy",
|
"type": "redeploy",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,9 @@ forever.
|
||||||
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
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -241,78 +239,3 @@ def test_alert_only_action_resolves_synchronously_not_via_dispatch(tmp_path, mon
|
||||||
assert _exists(tmp_path, "completed", "alert-1")
|
assert _exists(tmp_path, "completed", "alert-1")
|
||||||
assert not (tmp_path / "actions" / "dispatch").exists() or \
|
assert not (tmp_path / "actions" / "dispatch").exists() or \
|
||||||
not list((tmp_path / "actions" / "dispatch").glob("**/*.json"))
|
not list((tmp_path / "actions" / "dispatch").glob("**/*.json"))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Inbox permissions (dispatch leak, 2026-08-06)
|
|
||||||
#
|
|
||||||
# The per-node inbox is written here but drained by the target node over rsync
|
|
||||||
# --remove-source-files, authenticating as a different user that is only a
|
|
||||||
# group member. Unlinking the fetched file needs write permission on the
|
|
||||||
# containing dir; at 0o755 it silently fails and the node re-pulls the same
|
|
||||||
# action forever.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def restrictive_umask():
|
|
||||||
"""0o022 masks the group-write bit out of mkdir(mode=0o775) — the reason
|
|
||||||
the fix cannot rely on the mode argument alone."""
|
|
||||||
old = os.umask(0o022)
|
|
||||||
yield
|
|
||||||
os.umask(old)
|
|
||||||
|
|
||||||
|
|
||||||
def _mode(path: Path) -> int:
|
|
||||||
return path.stat().st_mode & 0o777
|
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_inbox_is_group_writable(tmp_path, monkeypatch, restrictive_umask):
|
|
||||||
ex = _setup_executor(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
ex._execute_action(_write_approved(tmp_path, "cr-perm", node="piha"))
|
|
||||||
|
|
||||||
inbox = tmp_path / "actions" / "dispatch" / "piha"
|
|
||||||
assert (inbox / "cr-perm.json").exists()
|
|
||||||
assert _mode(inbox) == 0o775
|
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_inbox_is_group_writable(tmp_path, monkeypatch, restrictive_umask):
|
|
||||||
"""Same defect, same rsync-pull drain — the deploy runner's inbox needs it too."""
|
|
||||||
ex = _setup_executor(tmp_path, monkeypatch)
|
|
||||||
|
|
||||||
ex._execute_action(
|
|
||||||
_write_approved(tmp_path, "rd-perm", node="piha", action_type="redeploy")
|
|
||||||
)
|
|
||||||
|
|
||||||
inbox = tmp_path / "actions" / "deploy" / "piha"
|
|
||||||
assert (inbox / "rd-perm.json").exists()
|
|
||||||
assert _mode(inbox) == 0o775
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_inbox_is_repaired_in_place(tmp_path, monkeypatch, restrictive_umask):
|
|
||||||
"""Inboxes already on disk fleet-wide were created at 0o755 by an earlier
|
|
||||||
build; dispatching to one must fix it rather than inherit it."""
|
|
||||||
ex = _setup_executor(tmp_path, monkeypatch)
|
|
||||||
inbox = tmp_path / "actions" / "dispatch" / "piha"
|
|
||||||
inbox.mkdir(parents=True)
|
|
||||||
os.chmod(inbox, 0o755)
|
|
||||||
|
|
||||||
ex._execute_action(_write_approved(tmp_path, "cr-repair", node="piha"))
|
|
||||||
|
|
||||||
assert _mode(inbox) == 0o775
|
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_survives_unsettable_mode(tmp_path, monkeypatch, restrictive_umask):
|
|
||||||
"""A chmod failure (inbox owned by another user) must not cost us the
|
|
||||||
dispatch — the action still executes on the node; only the source delete
|
|
||||||
stays broken, and the node warns about that."""
|
|
||||||
ex = _setup_executor(tmp_path, monkeypatch)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
executor_module.os, "chmod",
|
|
||||||
MagicMock(side_effect=PermissionError("Operation not permitted")),
|
|
||||||
)
|
|
||||||
|
|
||||||
ex._execute_action(_write_approved(tmp_path, "cr-chmod-fail", node="piha"))
|
|
||||||
|
|
||||||
assert (tmp_path / "actions" / "dispatch" / "piha" / "cr-chmod-fail.json").exists()
|
|
||||||
assert _exists(tmp_path, "running", "cr-chmod-fail")
|
|
||||||
|
|
|
||||||
|
|
@ -169,23 +169,6 @@ def _utc_iso() -> str:
|
||||||
_EVENT_TS_RE = re.compile(r"-(\d{9,11})-")
|
_EVENT_TS_RE = re.compile(r"-(\d{9,11})-")
|
||||||
_EVENT_TYPE_RE = re.compile(r"^evt-.+?-\d{9,11}-(.+)$")
|
_EVENT_TYPE_RE = re.compile(r"^evt-.+?-\d{9,11}-(.+)$")
|
||||||
|
|
||||||
# rsync exits 23 for two very different situations and the dispatch pull hits
|
|
||||||
# both routinely, so the stderr has to be read to tell them apart:
|
|
||||||
#
|
|
||||||
# * The remote inbox does not exist yet. The executor creates
|
|
||||||
# actions/dispatch/<node>/ only on its first dispatch to that node, so until
|
|
||||||
# then every pull reports `change_dir "..." failed: No such file or
|
|
||||||
# directory`. Expected, and warning about it would mean one line a minute on
|
|
||||||
# every node that has never been sent an action.
|
|
||||||
# * The files WERE fetched but rsync could not unlink the source
|
|
||||||
# (`sender failed to remove <file>: Permission denied`) — the dispatch leak
|
|
||||||
# of 2026-08-06. That one has to be loud: it means the same actions come
|
|
||||||
# back on every single cycle until someone fixes the directory mode on VPS.
|
|
||||||
#
|
|
||||||
# (An empty-but-existing remote inbox — by far the most common case — is a plain
|
|
||||||
# rc=0 and never reaches here.)
|
|
||||||
_RSYNC_MISSING_SRC_RE = re.compile(r"change_dir .* failed: No such file or directory")
|
|
||||||
|
|
||||||
|
|
||||||
def _event_ts_from_filename(name: str):
|
def _event_ts_from_filename(name: str):
|
||||||
"""Return the embedded <unixts> from an event filename, or None if absent."""
|
"""Return the embedded <unixts> from an event filename, or None if absent."""
|
||||||
|
|
@ -968,45 +951,14 @@ class NodeAgent:
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
self._log_dispatch_pull_result(result.returncode, result.stderr or "")
|
# rsync returns 23/24 ("partial transfer"/"vanished source files")
|
||||||
|
# when the remote dispatch dir is simply empty — the common case,
|
||||||
|
# not an error worth logging every cycle.
|
||||||
|
if result.returncode not in (0, 23, 24):
|
||||||
|
logger.warning(f"Dispatch pull failed: {result.stderr.strip()}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Dispatch pull error: {exc}")
|
logger.warning(f"Dispatch pull error: {exc}")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _log_dispatch_pull_result(returncode: int, stderr: str) -> None:
|
|
||||||
"""Classify an rsync exit code from the dispatch pull.
|
|
||||||
|
|
||||||
Visibility only — the caller retries on the next cycle either way, and
|
|
||||||
an action that was fetched is executed regardless of what the source
|
|
||||||
side did. Codes:
|
|
||||||
|
|
||||||
0 — clean, including "remote inbox exists and is empty".
|
|
||||||
24 — a source file vanished between the file list and the transfer.
|
|
||||||
A benign race with the executor writing the inbox concurrently.
|
|
||||||
23 — "some files were not transferred", which covers two situations
|
|
||||||
that must NOT be logged alike (see _RSYNC_MISSING_SRC_RE).
|
|
||||||
* — real transport failure (ssh down, auth, timeout).
|
|
||||||
|
|
||||||
Before 2026-08-06 every one of these was benign-listed, which is how the
|
|
||||||
dispatch leak stayed invisible: the node re-pulled the same actions
|
|
||||||
every 60 s for days, silently bouncing them off the idempotency gate.
|
|
||||||
"""
|
|
||||||
stderr = stderr.strip()
|
|
||||||
if returncode in (0, 24):
|
|
||||||
return
|
|
||||||
if returncode == 23:
|
|
||||||
if _RSYNC_MISSING_SRC_RE.search(stderr) and "failed to remove" not in stderr:
|
|
||||||
logger.debug(f"Dispatch inbox not present on VPS yet: {stderr}")
|
|
||||||
return
|
|
||||||
logger.warning(
|
|
||||||
"Dispatch pull incomplete (rsync rc=23): action files were "
|
|
||||||
"fetched but their source copy on VPS was NOT removed, so they "
|
|
||||||
"will be re-pulled every cycle. Check the mode of "
|
|
||||||
f"actions/dispatch/<node>/ on VPS (needs group write). {stderr}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
logger.error(f"Dispatch pull failed (rsync rc={returncode}): {stderr}")
|
|
||||||
|
|
||||||
def process_dispatched_actions(self):
|
def process_dispatched_actions(self):
|
||||||
"""Execute every action currently sitting in this node's dispatch inbox."""
|
"""Execute every action currently sitting in this node's dispatch inbox."""
|
||||||
inbox = self._dispatch_inbox_dir()
|
inbox = self._dispatch_inbox_dir()
|
||||||
|
|
|
||||||
|
|
@ -238,77 +238,13 @@ def test_pull_invokes_rsync_pull_direction(agent, monkeypatch):
|
||||||
assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/"
|
assert cmd[-1] == str(agent._dispatch_inbox_dir()) + "/"
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
def test_pull_treats_empty_source_returncodes_as_non_error(agent, monkeypatch, caplog):
|
||||||
# rsync exit-code classification (dispatch leak, 2026-08-06)
|
def fake_run(cmd, **kwargs):
|
||||||
#
|
return MagicMock(returncode=23, stderr="rsync: some vanished-source message")
|
||||||
# rc=23 used to be benign-listed together with 0 and 24, which is why the
|
|
||||||
# leak — files fetched but never removed from VPS, so re-pulled every 60 s —
|
|
||||||
# produced no log line at all for days. These pin the four outcomes.
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Verbatim rsync 3.4.1 stderr for the two distinct rc=23 causes.
|
monkeypatch.setattr(node_agent.subprocess, "run", fake_run)
|
||||||
_STDERR_UNDELETABLE_SOURCE = (
|
|
||||||
"rsync: [sender] sender failed to remove act-123.json: Permission denied (13)\n"
|
|
||||||
"rsync error: some files/attrs were not transferred "
|
|
||||||
"(see previous errors) (code 23) at main.c(1356) [sender=3.4.1]"
|
|
||||||
)
|
|
||||||
_STDERR_MISSING_INBOX = (
|
|
||||||
'rsync: [sender] change_dir "/opt/homelab/actions/dispatch/test-node" '
|
|
||||||
"failed: No such file or directory (2)\n"
|
|
||||||
"rsync error: some files/attrs were not transferred "
|
|
||||||
"(see previous errors) (code 23) at main.c(1356) [sender=3.4.1]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pull_with(agent, monkeypatch, returncode, stderr=""):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
node_agent.subprocess, "run",
|
|
||||||
lambda cmd, **kwargs: MagicMock(returncode=returncode, stderr=stderr),
|
|
||||||
)
|
|
||||||
agent.pull_dispatched_actions()
|
|
||||||
|
|
||||||
|
|
||||||
def test_pull_rc0_logs_nothing(agent, monkeypatch, caplog):
|
|
||||||
with caplog.at_level("DEBUG"):
|
|
||||||
_pull_with(agent, monkeypatch, 0)
|
|
||||||
|
|
||||||
assert "Dispatch pull" not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_pull_rc24_vanished_source_stays_benign(agent, monkeypatch, caplog):
|
|
||||||
"""A file removed between file-list and transfer is a race with the
|
|
||||||
executor writing the inbox, not a fault."""
|
|
||||||
with caplog.at_level("WARNING"):
|
with caplog.at_level("WARNING"):
|
||||||
_pull_with(agent, monkeypatch, 24, "rsync warning: some files vanished")
|
agent.pull_dispatched_actions()
|
||||||
|
|
||||||
assert caplog.text == ""
|
assert "Dispatch pull failed" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_pull_rc23_undeletable_source_warns_with_stderr(agent, monkeypatch, caplog):
|
|
||||||
"""The leak itself: loud, and carrying the rsync stderr that names it."""
|
|
||||||
with caplog.at_level("WARNING"):
|
|
||||||
_pull_with(agent, monkeypatch, 23, _STDERR_UNDELETABLE_SOURCE)
|
|
||||||
|
|
||||||
assert "WARNING" in caplog.text
|
|
||||||
assert "rc=23" in caplog.text
|
|
||||||
# Full stderr forwarded, so the operator sees which file and why.
|
|
||||||
assert "sender failed to remove act-123.json: Permission denied" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_pull_rc23_missing_remote_inbox_is_quiet(agent, monkeypatch, caplog):
|
|
||||||
"""A node that has never been dispatched to gets rc=23 on every cycle
|
|
||||||
because the executor has not created its inbox yet. Warning here would be
|
|
||||||
a line a minute on most of the fleet — and would bury the case above."""
|
|
||||||
with caplog.at_level("WARNING"):
|
|
||||||
_pull_with(agent, monkeypatch, 23, _STDERR_MISSING_INBOX)
|
|
||||||
|
|
||||||
assert caplog.text == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_pull_other_returncode_logs_error(agent, monkeypatch, caplog):
|
|
||||||
with caplog.at_level("WARNING"):
|
|
||||||
_pull_with(agent, monkeypatch, 255, "ssh: connect to host vps port 22: No route to host")
|
|
||||||
|
|
||||||
assert "ERROR" in caplog.text
|
|
||||||
assert "rc=255" in caplog.text
|
|
||||||
assert "No route to host" in caplog.text
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue