Repository navigation
fix(slm): shared TLS cert on vnc/redis-only nodes + persistent service-restart cap (#16020, #16712) - #17096
fix(slm): shared TLS cert on vnc/redis-only nodes + persistent service-restart cap (#16020, #16712)#17096mrveiss wants to merge 8 commits into
Conversation
…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.
|
Warning Review limit reachedNext included review available in 32 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (11)
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. Comment |
…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.
…p' into issue-16020-16712-tls-restart-cap
|
Carried into vehicle #17119 (member head |
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).
…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.
…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.
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.ymltask, 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 ofroles/backend/tasks/main.ymlover main's own newer consolidation -- caught by diffing againstorigin/mainbefore 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 isvnc(nogroup_namesentry 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 viatasks_from: code_only, which skips it). Fixed with two small, correctly-gated tasks -- no changes toroles/backend/tasks/main.ymlneeded at all, so this never touched the ansible vehicle #17062's files.#16712: re-investigated fresh against current main too.
_create_max_attempts_service_eventalready exists and fires aNodeEventon 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 thatreset_service_remediation_trackerhad 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/distfallback for a barehosts: allplay that never loadsroles/frontend's ownfrontend_dist_dirdefault -- replaced withbase_dir(the existinggroup_vars/all.ymlglobal), 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-pushselectedservices/reconciler_playbook_timeout_14524_test.pyandapi/nodes_test.pytogether (both underautobot-slm-backend/), andrepo_tests/sys_modules_leak_guard.pyfailed the run. Root cause, confirmed empirically by reading_pytest/pathlib.py: pytest.ini's--import-mode=importlibcollects aservices/*_test.pyfile as the dotted moduleservices.<name>, and before it can,_pytest.pathlib._import_module_using_speccheckshasattr(sys.modules["services"], "__path__")to decide whether conftest'sMagicMockstub already "looks like" a real package. It doesn't (__path__isn't a magic methodMagicMockpre-configures), so pytest reimportsservices/__init__.pyfor 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 againstreconciler_remediation_outcome_test.pyanda2a_card_fetcher_test.py-- both untouched by this branch. Fixed at the source inconftest.py, not worked around in a test file: give the stub a real__path__(the same technique the file already uses for itsapistub), plus pin itssetUpModule/setup_module/tearDownModule/teardown_module/pytest_pluginsso pytest's now-triggeredPackage.setup()(which reads those off it) doesn't choke on aMagicMockauto-vivifying them instead of leaving them absent.What Changed
autobot-slm-backend/ansible/playbooks/update-all-nodes.yml-- arole_vnc_active-gated TLS-ensure task in Play 2 (vnc has nogroup_namesentry --services/inventory_builder.py::_ROLE_TO_GROUPS-- so the existing backend/frontendgroup_namesgate pattern can't reach it), and arole_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_GROUPSentry" 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_serviceread/write the tracker viaService.extra_data["remediation"]instead -- the row already read and written every heartbeat._update_existing_serviceclears 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 ofreconciler.py(grandfathered at 2222 lines, no room to grow) rather than added inline.autobot-slm-backend/api/nodes.py--acknowledge-remediationnow 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-writtenSimpleNamespaceServicestand-in neededextra_dataadded, or the new read pathAttributeErrors.autobot-slm-backend/services/reconciler_service_remediation_persistence_16712_test.py(new) -- persistence round-trip, survives a freshReconcilerService()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-blockingsys.modulesleak fix described above (servicesstub: real__path__,setUpModule/setup_module/tearDownModule/teardown_module/pytest_pluginspinned).repo_tests/sys_modules_leak_baseline.txt-- deletedservices/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_namesas an example of what the task does not do, whichservices/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 broaderservices/suite, not just the two files the hook selects.Verification
origin/main(not the stale branch) before writing anything; 5 of 6 were already met independently.pytest(SLM backend +repo_tests, CI-parity venv), each in its own invocation matching CI's actual two-invocation split: 65 passed (services/ -k reconciler), 9 passed (the two nginx cannot start on a node with no TLS cert: five roles consume the shared keypair, four provision it #16020 guard files), 51 passed (the leak guard's own test suite plus the two nginx cannot start on a node with no TLS cert: five roles consume the shared keypair, four provision it #16020 repo_tests files).tools/git-hooks/pre-pushselects for this diff (services/reconciler_playbook_timeout_14524_test.py,services/reconciler_service_remediation_persistence_16712_test.py,api/nodes_test.py): 20 passed, exit 0, no leak warning. Broader sweep for confidence beyond what the hook selects -- the wholeservices/ api/tree together (729 tests): also 0 new leaks, only the 2 pre-existing baseline entries the fix didn't touch (api/monitoring_applogs_test.py,services/reconciler_check_node_health_test.py).models.databaseto a bareMagicMockfor the whole session, which silently madestatus != ServiceStatus.FAILED.valueevaluateTrueunconditionally (aMagicMocknever equals a plain string) -- one of the four assertion tests was passing for the wrong reason until the loader bound the realservice_status.ServiceStatusonto the stub first.reconciler.py,update-all-nodes.ymlandprovision-fleet-roles.ymlto their pre-fixorigin/maincontent in turn and reran the corresponding new test files:update_all_nodes_ensures_shared_tls_cert_16020_test.py-- 3 of 4 correctly fail;reconciler_service_remediation_persistence_16712_test.py-- 6 of 7 correctly fail (mostlyAttributeError, the functions don't exist pre-fix).black --check/isort --check/flake8/bandit: clean on all 7 touched Python files (bandit: only the expectedB101assert-used in test files).yamllinton both touched playbooks: only pre-existing, repo-wide line-length warnings; none land on any changed line.scripts/check_python_file_size.py --audit-ceilings: clean.reconciler.pysits exactly at its grandfathered 2222-line ceiling (trimmed comments and extracted the new tracker helpers to their own module to get there);api/nodes.pyat its 2926-line ceiling.pipeline-scripts/check_baseline_no_growth.shrun directly againstBASE_SHA=$(git rev-parse origin/main): "no key added and no count increased" -- the actual gate script, not just a visual diff.jscpdagainst the SLM scope (autobot_shared autobot-slm-backend autobot-slm-frontend/src, its own 2945-line pin): 2907, unchanged from before this PR.detect-secrets scan(non-destructive, no--baselinewrite): no findings on any touched file.pr-graph whoon every touched file before pushing:update-all-nodes.yml/provision-fleet-roles.ymloverlap fix(deploy): the user-frontend build sites publish through a staged swap (#15603) #16970 (expected -- 66 directed the SSOT fix here);reconciler.pyoverlaps chore(train): batch vehicle — 11 approved independent PRs, one CI run #17086, the batch vehicle carrying my own already-approved fix(reconciler): stop a DB-level failure poisoning the heartbeat session (#17070) #17075/bug(slm): one DB timeout in heartbeat service sync poisons the session — every remaining service fails with PendingRollbackError, and logins time out #17070 -- not a collision with another agent's work, just sequencing between two of my own changes; a routine rebase once either lands.Model Used
Claude Sonnet 5