Skip to content

fix(slm): shared TLS cert on vnc/redis-only nodes + persistent service-restart cap (#16020, #16712) - #17096

Closed
mrveiss wants to merge 8 commits into
mainfrom
issue-16020-16712-tls-restart-cap
Closed

mrveiss wants to merge 8 commits into
mainfrom
issue-16020-16712-tls-restart-cap

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Closes #16020, Closes #16712, Refs #16970

Thinking Path

66 handed me a stale local branch covering #16020 and #16712, with instructions to verify against CURRENT main first rather than revive the branch. Doing that changed the shape of both:

#16020: 5 of the issue's 6 acceptance criteria were already satisfied independently on current main (the shared ensure_node_tls_cert.yml task, every provisioning role including it, nginx not starting before the cert exists, the role-sweep guard test, the diagnosable failure mode). The stale branch's cherry-pick would have reintroduced an OLDER, already-superseded version of roles/backend/tasks/main.yml over main's own newer consolidation -- caught by diffing against origin/main before committing anything, not by re-reading the issue. The one real remaining gap: update-all-nodes.yml (the only path a GUI/self-update reaches) never ensures the shared cert for a node whose only cert-consuming role is vnc (no group_names entry to gate on) or for a database node reached only through Play 2b (redis's role task file has the shared task, but this playbook applies it via tasks_from: code_only, which skips it). Fixed with two small, correctly-gated tasks -- no changes to roles/backend/tasks/main.yml needed at all, so this never touched the ansible vehicle #17062's files.

#16712: re-investigated fresh against current main too. _create_max_attempts_service_event already exists and fires a NodeEvent on exhaustion, so the "surfaced to an operator" criterion turned out to already be satisfied -- not something I needed to add. The real gap was narrower than the stale branch assumed: the tracker itself (self._service_remediation_tracker, process memory) and the fact that reset_service_remediation_tracker had zero callers anywhere in the codebase, so "a service that recovers resets its count" was never actually true even though the method existed.

#16970: 66 asked only for the narrow SSOT Configuration Compliance fix to ride in this PR, not #16970's full staged-swap-publish scope (which stays on its own PR). provision-fleet-roles.yml's co-location detection had a hardcoded /opt/autobot/autobot-frontend/dist fallback for a bare hosts: all play that never loads roles/frontend's own frontend_dist_dir default -- replaced with base_dir (the existing group_vars/all.yml global), same resolved path, no baseline entry added. Verified against the actual gate script (check_baseline_no_growth.sh), not just eyeballed.

The push itself was blocked by a pre-existing, general infrastructure defect, not by anything in the three issues above: tools/git-hooks/pre-push selected services/reconciler_playbook_timeout_14524_test.py and api/nodes_test.py together (both under autobot-slm-backend/), and repo_tests/sys_modules_leak_guard.py failed the run. Root cause, confirmed empirically by reading _pytest/pathlib.py: pytest.ini's --import-mode=importlib collects a services/*_test.py file as the dotted module services.<name>, and before it can, _pytest.pathlib._import_module_using_spec checks hasattr(sys.modules["services"], "__path__") to decide whether conftest's MagicMock stub already "looks like" a real package. It doesn't (__path__ isn't a magic method MagicMock pre-configures), so pytest reimports services/__init__.py for real, permanently replacing the stub for the rest of the process. Confirmed this is general, not specific to the touched file, by reproducing the identical leak against reconciler_remediation_outcome_test.py and a2a_card_fetcher_test.py -- both untouched by this branch. Fixed at the source in conftest.py, not worked around in a test file: give the stub a real __path__ (the same technique the file already uses for its api stub), plus pin its setUpModule/setup_module/tearDownModule/teardown_module/pytest_plugins so pytest's now-triggered Package.setup() (which reads those off it) doesn't choke on a MagicMock auto-vivifying them instead of leaving them absent.

What Changed

  • autobot-slm-backend/ansible/playbooks/update-all-nodes.yml -- a role_vnc_active-gated TLS-ensure task in Play 2 (vnc has no group_names entry -- services/inventory_builder.py::_ROLE_TO_GROUPS -- so the existing backend/frontend group_names gate pattern can't reach it), and a role_redis_active-gated one in Play 2b, both before any task that could restart the service reading the cert.
  • autobot-slm-backend/ansible/playbooks/provision-fleet-roles.yml -- the SSOT hardcode fix described above.
  • repo_tests/update_all_nodes_ensures_shared_tls_cert_16020_test.py (new) -- reads the playbook as text (no live inventory to parse it as a real playbook) and asserts both new tasks exist, are correctly gated, and run before any task that could restart the reading service. Includes the "vnc has no _ROLE_TO_GROUPS entry" pin so the guard can tell "gated correctly" from "gated by luck."
  • autobot-slm-backend/services/reconciler.py -- _service_remediation_tracker (process memory) removed. _remediate_failed_service, reset_service_remediation_tracker (now DB-backed, async) and _update_existing_service read/write the tracker via Service.extra_data["remediation"] instead -- the row already read and written every heartbeat. _update_existing_service clears it the moment a heartbeat observes the service as no longer FAILED.
  • autobot-slm-backend/services/service_remediation_tracker.py (new) -- read_service_remediation/write_service_remediation/clear_service_remediation, extracted out of reconciler.py (grandfathered at 2222 lines, no room to grow) rather than added inline.
  • autobot-slm-backend/api/nodes.py -- acknowledge-remediation now also calls the (now-wired) service-level reset, since it previously had zero callers.
  • autobot-slm-backend/services/reconciler_playbook_timeout_14524_test.py -- its hand-written SimpleNamespace Service stand-in needed extra_data added, or the new read path AttributeErrors.
  • autobot-slm-backend/services/reconciler_service_remediation_persistence_16712_test.py (new) -- persistence round-trip, survives a fresh ReconcilerService() instance (simulating a restart), auto-reset on recovery with a same-heartbeat-keeps-it-while-still-failed control, and the operator-reset path with its own no-op control.
  • autobot-slm-backend/conftest.py -- the pre-push-blocking sys.modules leak fix described above (services stub: real __path__, setUpModule/setup_module/tearDownModule/teardown_module/pytest_plugins pinned).
  • repo_tests/sys_modules_leak_baseline.txt -- deleted services/a2a_card_fetcher_test.py's line: the same fix incidentally stopped it leaking too, and the baseline is shrink-only by the guard's own rule.
  • autobot-slm-backend/ansible/playbooks/update-all-nodes.yml -- also rewords the nginx cannot start on a node with no TLS cert: five roles consume the shared keypair, four provision it #16020 VNC-task comment (prose only, no logic change): it contained the literal text 'vnc' in group_names as an example of what the task does not do, which services/inventory_deploy_groups_test.py's regex-over-raw-text gate derivation matched as if it were a real Jinja gate -- a self-inflicted false-positive test failure, caught by running the broader services/ suite, not just the two files the hook selects.

Verification

Model Used

Claude Sonnet 5

…sist the reconciler's service-restart cap, fix a hardcoded frontend path (#16020, #16712, #15603)
…ist the reconciler's service-restart cap (#16020, #16712)

#16020: update-all-nodes.yml -- the only path a GUI/self-update reaches --
never ensured the shared TLS keypair for a node whose only cert-consuming
role is vnc (no group_names entry to gate on, unlike backend/frontend) or
for a database node maintained only through Play 2b (redis's role.
tasks/main.yml has the shared task, but this playbook applies it via
tasks_from: code_only, which skips it). Both roles/backend's and
roles/redis's own tasks/main.yml already ensure the cert on full
provisioning -- that half of #16020 already landed independently. This adds
the two remaining gaps: a VNC-gated task in Play 2, a redis-gated one in
Play 2b, both before any task that could restart the service reading the
cert.

#16712: the reconciler's per-(node,service) restart-attempt count and
exhausted flag lived in self._service_remediation_tracker, process memory
that every SLM backend restart silently reset -- and every update-all
deploy restarts the backend in its first play, so a permanently-failing
service got three fresh attempts after every single deploy, forever. The
tracker now lives on Service.extra_data["remediation"], the row already
read/written every heartbeat. A service that recovers on its own has its
tracker cleared the next heartbeat that observes it as no longer FAILED
(_update_existing_service); an operator's acknowledge-remediation action
clears it explicitly (reset_service_remediation_tracker, now DB-backed and
wired into that endpoint -- it had no caller before this fix).
…reconciler.py back under its ratchet ceiling

reconciler.py is grandfathered at 2222 lines; the #16712 fix pushed it
over. read/write/clear_service_remediation move to a new
services/service_remediation_tracker.py -- a clean extraction, not just
line-count pressure relief: three small, pure, single-purpose functions
with no reason to live inside a 2200-line file.
…ckage into sys.modules (#16020, #16712)

pytest.ini's --import-mode=importlib collects a services/*_test.py file
as the dotted module services.<name>. Before it can, _pytest.pathlib.
_import_module_using_spec checks hasattr(sys.modules["services"],
"__path__") to decide whether the conftest stub already "looks like" a
real package -- a bare MagicMock has none, so pytest reimports
services/__init__.py for real, permanently replacing the stub for the
rest of the session (repo_tests/sys_modules_leak_guard.py's exact
failure shape). Any services/*_test.py file triggers it, not just the
one that happens to be collected first -- confirmed by reproducing it
against reconciler_remediation_outcome_test.py and
a2a_card_fetcher_test.py, both untouched by this branch.

Giving the stub a real __path__ (the same technique already used for
the "api" stub) satisfies that check without a reimport. That in turn
makes pytest treat services/ as a genuine Package node, whose setup()
re-imports services/__init__.py to read its xunit-style
setUpModule/setup_module/tearDownModule/teardown_module and its
pytest_plugins -- a MagicMock auto-vivifies all of those instead of
leaving them absent, which _pytest.python._call_with_optional_argument
then chokes on reading __code__ off. Pinned to None/() so pytest reads
them the same way it would the real, nearly-empty services/__init__.py.

Fixing this also resolved a's2a_card_fetcher_test.py's pre-existing
baseline entry; deleted per the baseline's own shrink-only rule.

Also rewords a #16020 comment in update-all-nodes.yml whose prose
("not `'vnc' in group_names`") was matched by
inventory_deploy_groups_test.py's group_names-gate regex as if it were
an actual gate, a self-inflicted false positive with no logic change.
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 32 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6d53825b-a897-4916-9306-001e1bee85e6

📥 Commits

Reviewing files that changed from the base of the PR and between 928573e and c5962cf.

⛔ Files ignored due to path filters (3)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • repo_tests/sys_modules_leak_baseline.txt is excluded by !**/*_baseline.txt
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (11)
  • autobot-slm-backend/ansible/playbooks/provision-fleet-roles.yml
  • autobot-slm-backend/ansible/playbooks/update-all-nodes.yml
  • autobot-slm-backend/api/nodes.py
  • autobot-slm-backend/conftest.py
  • autobot-slm-backend/services/reconciler.py
  • autobot-slm-backend/services/reconciler_playbook_timeout_14524_test.py
  • autobot-slm-backend/services/reconciler_remediation_tracker_expiry_14465_test.py
  • autobot-slm-backend/services/reconciler_service_remediation_persistence_16712_test.py
  • autobot-slm-backend/services/service_remediation_tracker.py
  • repo_tests/provision_fleet_roles_ssot_default_16970_test.py
  • repo_tests/update_all_nodes_ensures_shared_tls_cert_16020_test.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

…e race, malformed-tracker robustness, exhaustion cause (#16020, #16712, #16970)

1. BLOCKING: provision-fleet-roles.yml's co-location stat task fell back
   to a bare `base_dir`, which is undefined — the SSOT key is nested
   under `autobot:` (group_vars/all.yml). Jinja's default() evaluates
   its argument eagerly, so this raised on every render where
   frontend_dist_dir is unset, the normal case. Fixed to autobot.base_dir;
   added a rendering test with the negative control proving the bug.

2. A concurrent heartbeat for the same service row (a different session)
   can clear or edit the remediation tracker while _remediate_failed_
   service awaits its (possibly minutes-long) ansible restart. Writing
   the post-restart tracker from the pre-await ORM snapshot silently
   undid that write. Fixed by db.refresh(service) before the write, and
   rebuilding from the fresh read.

3. read_service_remediation assumed extra_data["remediation"] was a
   dict; a malformed value raised AttributeError out of a bare .get(),
   and _remediate_failed_services' loop had no per-service guard, so one
   bad row halted remediation for every other failing service that
   cycle. Fixed: reset-and-log on a malformed value, plus a try/except
   per service in the loop.

4. #16712's own AC says exhaustion must name "the node, the service AND
   the captured cause" — the exhaustion event had no cause field.
   _restart_service_via_ansible now returns (success, cause); the cause
   persists on the tracker (last_cause) across attempts and lands on the
   NodeEvent's details when MAX_SERVICE_RESTART_ATTEMPTS is reached.

_log_restart_result extracted to service_remediation_tracker.py (pure,
no `self` dependency) to stay under reconciler.py's ratchet ceiling
after these fixes — the ceiling itself is lowered from 2222 to 2219
lines to match, per the ratchet's own shrink-only rule.
@mrveiss

mrveiss commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Carried into vehicle #17119 (member head c5962cfb4 merged server-side, vehicle head 29a976e286). Closing here; acceptance-criteria evidence and CI ride with the vehicle.

@mrveiss mrveiss closed this Sep 19, 2026
mrveiss added a commit that referenced this pull request Sep 19, 2026
1. Shard 5: test_services_stub_is_a_package_16722 -- an old #17096 block
   (MagicMock + manual pytest_plugins/xunit neutering) ran AFTER #16722's
   later, cleaner hollow-ModuleType fix and re-added the exact invented
   attribute #16722 proves must stay absent. Removed the superseded block;
   the hollow module needs none of it.
2. Shard 10 (item 8): fixed_sleep_then_call_assertion_test.py named 6 real
   sites in agent_channels_test.py/idle_notice_test.py. Converted 3 to
   await eventually(...) on the actual observable (concurrency counters,
   handler/backlog depth, a fired event); the 4th (proving an event never
   fires) has no observable to wait on, so it keeps its marked, registered
   exemption.
3. Shard 10: update_deps_detection_window_test.py assumed exactly one
   include_tasks call site for the shared SLM frontend build. #16970 added
   a second, legitimate one for the user-frontend's own staged publish.
   Fixed to assert every call site resolves to the SAME shared file
   (the actual property) instead of counting call sites.
4. Shard 12: two new bypasses recorded in python_filter_uncovered_reads.py
   (README.md, a synthetic tmp_path fixture literal in
   pre_push_open_pr_cap_17006_test.py; GITHUB_FILING_CREDENTIAL_ROTATION.md,
   a baseline-reasons dict key never opened) -- same shape as the existing
   CLAUDE.md entry. MAX_UNCOVERED_READS 39 -> 41.
5. Shard 12: re-pinned both reach floors to population minus the unchanged
   growth allowance, measured, not guessed -- hooks-path-override
   6412 -> 6421 (population 6821, growth 400), prompt-injection-detector-
   strict-mode 2912 -> 2925 (population 3225, growth 300).
mrveiss added a commit that referenced this pull request Sep 20, 2026
…filter (#17173)

The new SPECIFIC_REASONS entry this branch added names
autobot-slm-frontend/src/locales/en.json as a concrete literal --
python_filter_covers_its_guards_test.py correctly flagged it as a
newly-uncovered read, since the python-suite path filter had no entry
for that tree. Widened the filter rather than raising
MAX_UNCOVERED_READS: the guard's own module docstring says why --
"headroom under a ceiling is room for a new bypass to appear with
nothing failing" -- and #17096's docker/.env.docker entry right above
is the exact same shape already accepted as the correct fix, not the
exception.

MAX_UNCOVERED_READS itself is untouched and doesn't need to be: the
newly-covered path was never a member of UNCOVERED_READS (it was a
fresh gap, not a previously-accepted one), so closing the filter gap
removes it from the uncovered set entirely rather than requiring the
ceiling to grow to match.
mrveiss added a commit that referenced this pull request Sep 20, 2026
…d contract ratchet (#17174)

* fix(security): baseline the en.json 'secret' i18n label false positive (#17173)

en.json:3407's "secret": "Secret" -- a UI display label #17040's Data
Hygiene page batch introduced (#17157) -- trips detect-secrets' Secret
Keyword denylist on the KEY name; the VALUE is the literal word "Secret",
not a credential. Landed unbaselined on main because Secret Detection is
gated by a path filter #17157's frontend/locale-only diff never triggered,
so the whole-tree scan never ran against it -- #17173 tracks that structural
mismatch. #17149 was the first PR to touch a path inside the filter since,
and inherited the block for a finding it never introduced.

Computed the hashed_secret independently rather than trusting it blind:
read detect_secrets/core/potential_secret.py's hash_secret() (plain
hashlib.sha1, no salt) and detect_secrets/plugins/keyword.py's extraction
regex to confirm the captured literal is exactly "Secret" (no quotes, no
trailing comma), then reproduced the same SHA1 via stdlib hashlib. Cross-
checked against 7 existing baseline entries for autobot-frontend's own
i18n/locales/*.json files carrying the identical hash for the identical
literal -- independent confirmation the computation is right, not just
internally consistent.

Added the (filename, type, hashed_secret) triple to both .secrets.baseline
and SPECIFIC_REASONS in repo_tests/secrets_baseline_reason_entries.py, per
repo_tests/secrets_baseline_reasons_guard_test.py's requirement that every
baseline entry carry either a real reason or membership in the frozen
legacy-keys snapshot (which this entry cannot join -- that file is frozen
to what it held at #16299's introduction). Did not run detect-secrets-hook
or the guard test locally -- reasoned both the hash and the guard's five
assertions from their own source instead, per this session's standing rule
against executing repo/tooling code locally.

* fix(security): stop the new baseline reason quoting its own trigger shape

The previous commit's SPECIFIC_REASONS entry explained the en.json false
positive by literally quoting `"secret": "Secret"` -- which is itself a
keyword-colon-quoted-value shape the Secret Keyword denylist matches,
just inside this .py file instead of the .json one. CI caught it:
"potential secret not in the audited baseline:
repo_tests/secrets_baseline_reason_entries.py:535".

Rewords the reason to describe the same key/value pair without
reproducing the colon-immediately-followed-by-quote pattern that trips
the detector, rather than adding a second baseline entry for a
self-referential finding.

* fix(ci): re-pin autobot-slm-frontend's hand-typed responses ratchet 29 -> 34 (#17040)

#17157's Data Hygiene page added 5 hand-typed response interfaces to
useAutobotApi.ts (OrphanStorageListResponse, ApprovalGateResponse,
OrphanListResponse, OrphanRepairResponse, AuditQueryResponse) for its
new orphan-storage/orphan-repair/audit calls -- none has a generated
contract yet to derive from. main has been red on this ratchet since
#17157 merged; #17153 and #17149 both inherited it by merging main,
neither added a new violation of their own.

Verified by independently re-implementing the test's own five regexes
(client classes, raw fetch, axios imports, hand-typed response
interfaces, inline generic assertions) against every non-test .ts/.vue
file under autobot-slm-frontend/src on current origin/main, not by
running the test: clients=1, raw_fetch=3, axios=1, inline_generics=87
all still match the pinned baseline exactly; responses=34, confirmed
against the pinned interface names via `git show 3febb78` (#17157's
merge commit) rather than assumed from the count alone.

* fix(ci): cover autobot-slm-frontend/src/locales/ in the python-suite filter (#17173)

The new SPECIFIC_REASONS entry this branch added names
autobot-slm-frontend/src/locales/en.json as a concrete literal --
python_filter_covers_its_guards_test.py correctly flagged it as a
newly-uncovered read, since the python-suite path filter had no entry
for that tree. Widened the filter rather than raising
MAX_UNCOVERED_READS: the guard's own module docstring says why --
"headroom under a ceiling is room for a new bypass to appear with
nothing failing" -- and #17096's docker/.env.docker entry right above
is the exact same shape already accepted as the correct fix, not the
exception.

MAX_UNCOVERED_READS itself is untouched and doesn't need to be: the
newly-covered path was never a member of UNCOVERED_READS (it was a
fresh gap, not a previously-accepted one), so closing the filter gap
removes it from the uncovered set entirely rather than requiring the
ceiling to grow to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant