Repository navigation
fix(detection-engine): only back up state.db when this run will install the binary - #190
Conversation
…ll the binary Steps 3-5 are always-tagged, so a --tags run that never reaches Step 6 (pilot deploy --limit pulls it-core in as a provider host with only the limited hosts' component tags) still took a 'pre-upgrade' backup on every apply while the binary stayed stale. changed_when: true then made the second apply report a change and fail the idempotency check. Step 3c now also requires that the run selects Step 6 (all, or one of detection-engine-C1, detection-engine, observability). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Automated PR review: Draft requiredReviewed HEAD: Blocking issues / verification notes
Next steps: Address blocking findings, rerun affected tests and CI, and request independent review on the updated HEAD/Base before marking Ready. This comment is an automated technical review, not a human approval. |
…a predicate over run tags The ansible_run_tags predicate missed the special 'tagged' selector and --skip-tags, so --tags tagged replaced the binary in Step 6 with no backup to restore. Steps 3-5 now carry Step 6's own tag (detection-engine-C1) rather than always, so Ansible selects them exactly when it selects Step 6. A real-ansible test covers each selection. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…d up The backup (Steps 3-5) now follows Step 6's tag, so the always-tagged rescue can no longer read detection_engine_is_upgrade with a bare default(false) (the always-tag prerequisite lint rejects it). The restore tasks require `detection_engine_binary_result is defined` (this run reached Step 6) and `detection_engine_is_upgrade is defined and ...`. This also stops a failing Step 4 from copying a stale .prev/pre-upgrade.db of an earlier upgrade over the current binary and state.db. Tests: static guard that Steps 3-5 carry Step 6's tags, real ansible-playbook rollback tests (9 selections incl. stale backups and a failing Step 4) and second-apply changed=0 tests. AGENTS.md section 4.4 and the revision log updated. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
|
獨立審查補充:P2,暫不合併( 實測 revision:HEAD 正常完整 target test 通過:syntax-check、fresh check-mode(ok=50 changed=8 failed=0)、apply(ok=62 changed=11 failed=0)、committed spec 12 PASS / 0 FAIL / 0 SKIP,第二次 apply ok=60 changed=0 failed=0。這些結果未覆蓋下面的故障回復路徑。 重現條件:原服務 active/enabled,舊 binary 與輸入 artifact checksum 不同。透過 import wrapper 保留 site.yml 的
原因:C1 被排除後,新的 Step3c tag 不再設定 證據範圍:實際 detection binary、SQLite、systemd;上游採既有 fake-protocol fixtures。兩個 binary 是相同 candidate Go source、不同 version ldflags,沒有宣稱跨版本 DB migration。舊 binary SHA256: 完整本機 evidence: |
Automated PR review: Draft requiredReviewed HEAD: Blocking issues / verification notes
Next steps: Address blocking findings, rerun affected tests and CI, and request independent review on the updated HEAD/Base before marking Ready. This comment is an automated technical review, not a human approval. |
…l state, not from a C1 fact The rescue stops AND disables pilot-detection-engine.service unconditionally, but its restart was gated on detection_engine_is_upgrade, a Step 3c fact that is now C1-tagged and therefore undefined when the selection skips C1 (e.g. --tags detection-engine --skip-tags detection-engine-C1 with a failure in Step 8): the service was left inactive/disabled. It also left a healthy service stopped whenever the binary was up to date. No fact set by a C1-tagged task can serve an `always` rescue. New always-tagged, read-only Step 0 records ActiveState/UnitFileState before anything changes; two always-tagged rescue tasks (re-enable, restart; each failed_when: false) act on it. The restore tasks keep their binary_result / is_upgrade guards. The new whens deliberately have no `is defined` guard, so the always-tag prerequisite lint itself keeps Step 0 `always`. Behaviour change: after a failure in a non-upgrade run the rescue now restarts a service that was active (before, a failure after a config change left it stopped and disabled); the rescue does not restore config/unit files, so a bad new config means the service restarts with it. Also reword the Step 3 comment about the backups (known exceptions: #193, #194). Tests: TestDetectionEngineRescuePutsServiceBack (real ansible-playbook, fake systemctl, real Step 0, Steps 3-6 and rescue; 11 scenarios) and static guards for Step 0 and the service-state rescue tasks. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Automated PR review: Draft requiredReviewed HEAD: Blocking issues / verification notes
Next steps: Address blocking findings, rerun affected tests and CI, and request independent review on the updated HEAD/Base before marking Ready. This comment is an automated technical review, not a human approval. |
…'s original state cannot be read Step 0 sat inside the install block with failed_when: false. A failed `systemctl show` (one-shot rc 1, unreadable output, no systemctl) was swallowed, yet the rescue's first task stops AND disables the unit unconditionally and its re-enable/restart tasks need that query's rc == 0. So with an active/enabled service and a later failing task (e.g. Step 8) the rescue left a healthy service inactive/disabled with nothing to put it back; before this PR's rescue changes the same run restarted it. Step 0 is now two always-tagged tasks BEFORE the block: the read-only probe, and an assert (rc 0, one non-empty ActiveState=, one UnitFileState=) that fails the play before Steps 1-17 and outside the rescue. A first install still passes (`show` exits 0 for a missing unit). The rescue's stop+disable also requires detection_engine_service_before.rc == 0, so even with no snapshot at all it does not touch the service (the `when` on the undefined register fails the rescue first); no `is defined`, so the always-tag lint keeps pinning Step 0. Restore semantics: activating/reloading are restarted like active; enabled-runtime is not re-enabled (a plain `disable` leaves a /run link alone, verified against a systemd user manager, and `enable` would make it persistent). The re-enable/restart tasks use ignore_errors + register instead of failed_when: false and the final message says when either restore FAILED. Tests: TestDetectionEngineServiceStateQueryFailsClosed (10 query-failure cases: one-shot and persistent show failure, empty/garbage/partial output, no systemctl; asserts the run fails with the Step 0 refusal, the unit is unchanged, no mutating systemctl call, nothing backed up or replaced), TestDetectionEngineRescueDoesNotStopWithoutSnapshot, and 5 new restore scenarios (activating, reloading, enabled-runtime, failing re-enable, failing restart). Static test pins Step 0 outside the block and the stop guard. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…e restarts the service Steps 8/9/9b/9c/11 rewrite the feature profile, config.yaml, provider.env and the unit file before Step 10's `config validate` and the health checks can reject them. The rescue restored only the binary and state.db and then restarted the service, so a healthy active service was restarted into the config Step 10 had just rejected (Restart=on-failure unit: crash loop, while a state check still says active; reproduced on a systemd user manager). Three always-tagged read-only tasks before the install block (Step 0b stat, 0c slurp with no_log, 0d unit-existed fact) hold the files' original contents in memory (list: play var detection_engine_rollback_files, unit path: detection_engine_unit_path). No backup files, so nothing goes stale and a second apply stays changed=0; fine under --check. The rescue now runs stop -> binary/db -> restore files (originals back, files that did not exist removed) -> daemon reload -> re-enable -> restart, only when the unit already existed (a failed first install leaves the files for inspection). The restore tasks use ignore_errors + register and re-enable/restart require them not failed: if the old files cannot be put back the service stays stopped and the final message says so, rather than restarting into a config this run wrote. Tests: TestDetectionEngineRescueDoesNotRestartIntoNewConfig (fake unit that crash-loops unless config.yaml has the old content: Step 10 rejects, failure after the unit was rewritten + daemon-reload, provider.env created by the run, first install, files that cannot be put back), TestDetectionEngineStepZeroIsReadOnlyAnd CheckModeSafe (changed=0, ok/skipped counts under apply and --check), Step 0* in the second-apply changed=0 test, a case where `show` exits 1 with valid-looking output (pins the rc assert), static order/gate checks. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…estore files as they were P3 follow-ups from the review of the rollback snapshot: - Step 0b's stat no longer computes a checksum (nothing uses it and -vvv printed the sha1 of provider.env); mime/attributes off too. The slurp and the restore stay no_log. A static test pins no_log on the slurp and get_checksum: false, and a -vvv --diff test asserts the secret, its base64 and the file's sha1 never appear in the output. - The restore uses the original file's mode and numeric owner/group from the stat (not the playbook's defaults), and a file that was a symlink comes back as the same symlink to its lnk_target (the run's copy replaced it with a regular file; symlinks are not slurped). detection_engine_rollback_files no longer carries modes. The re-enable/restart gates include the new links task. - Tests: 0640 provider.env with a secondary group, 0600 config.yaml and 0664 profile come back with the same mode/gid; a symlinked config.yaml comes back as the link with its target untouched; unit file absent with config.yaml and provider.env present and a failure after Step 11 leaves them as the failed run wrote them and the service untouched (the unit-existed guard). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…t cannot read Step 0c slurps only `not islnk` files because slurp follows links: a dangling config.yaml link or a link to a directory would fail the snapshot. The existing symlink test used a link to a regular file, which slurp reads fine, so removing the `not item.stat.islnk` guard went unnoticed. New subtests (dangling link, link to a directory; failure injected after Step 11) assert the snapshot does not fail, the rescue recreates the link with the same target, the target is untouched, the other files are restored and the service ends in its original state. They fail with a slurp that follows links. Test files only. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…r check CI's golangci-lint v2 flags the negated chain of comparisons; write the condition without the negation. No behaviour change. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Refs #193, #194. Includes PR #190 compatibility baseline without modifying its branch. Use private per-apply snapshots, fail closed before Step 6 on backup or validation errors, revalidate rollback sources and stop before SQLite/WAL restoration. Add isolated vm-target fault-injection CI with real SQLite CLI and systemd. Scan: agent-controller retains a similar shared-backup pattern; intentionally outside this detection-engine issue pair.
Automated PR review: Draft requiredReviewed HEAD: Blocking issues / verification notes
Next steps: Address blocking findings, rerun affected tests and CI, and request independent review on the updated HEAD/Base before marking Ready. This comment is an automated technical review, not a human approval. |
1 similar comment
Automated PR review: Draft requiredReviewed HEAD: Blocking issues / verification notes
Next steps: Address blocking findings, rerun affected tests and CI, and request independent review on the updated HEAD/Base before marking Ready. This comment is an automated technical review, not a human approval. |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Candidate ccde655 (merge of origin/main fdb6f03 into the PR head) on two fresh vm-targets: normal chain L1/L3/L4, C1-C12 12/12 and L6 changed=0, first install accepted. Backup selection follows Step 6 (--tags docker twice: changed=0, no backup), the upgrade backups are the old binary and a database that passes db check, an unreadable Step 0 state is a refusal without restart, and four rescue arms put the service and the unit/config/profile/provider.env back as found. The same arms on main reproduce #189, a crash loop, and unrestored unit/config/provider.env. Observation not fixed here: Step 14 reads an undefined register when Step 6 is not selected (same on main). Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
…#189) #189: detection-engine Steps 3-5 were `always`-tagged and Step 4 was `changed_when: true`, so a `pilot deploy --limit` run that only pulled the host in through dependency expansion changed it on every apply and the second-apply changed=0 check failed forever. #190/#203 re-tagged those steps with Step 6's tag, locked by a per-file test. No repo-wide check covers the class: with the pre-#190 tags put back, the five existing tag lints (AlwaysTaggedTasksHaveAllPrerequisitesAlways, RescueRunsWheneverItsBlockDoes, TaggedTasksReadOnlyWhatTheirTagsSet, PreTaskGatesRunUnderApplyTags, NoUntaggedChecksInTaggedPlays) all passed. TestRegression_AlwaysTaggedTasksConverge scans playbooks/apply/*.yml and the task files they include (include_tasks children get only `apply` tags and need an always include line; import_tasks children inherit the line's tags; rescue is skipped, block `always:` sections are checked). An always-run command/shell/raw/script task must declare a changed_when that is not literally true, a tempfile task `changed_when: false`. At HEAD it finds exactly two, in a ratchet allowlist with reasons and a stale-entry check: agent-controller Step 4 (same shape, tracked by #195) and freeipa-realm-replacement's one-off trust snapshot. The walk fails closed on what it cannot follow (roles, import_playbook, action:, merge keys, an unreadable or templated always-run include) and must examine a minimum number of tasks, so it cannot pass by finding nothing. TestFindAlwaysTaggedNonConvergent covers the tag rules on synthetic playbooks; TestAlwaysTaggedConvergenceFlagsThePre190Playbook puts the pre-#190 tags back on the real detection-engine playbook in memory. Also fix three ways TestDetectionEngineSecondApplyChangedZero could not catch #189: the run that does not select Step 6 never asserted changed=0; its "no backup taken" check looked at backups/pre-upgrade.db although Step 4 has written to backups/apply-*/ since #203; and the Step 3-6 task regex missed Steps 3e, 4e, 4f and 5e (now checked against the real task names). No playbook changes. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ite the new lint (#189) that replaces the thing, never `always`) and a changelog row; the #203 merge deleted both. v1.41's row still said detection-engine's Step 3-5 are tagged `always` for the rescue, with no correction. Restore the point as a class-level rule: an `always` task must not report changed on every run, because a `pilot deploy --limit` run runs every `always` task of every imported play on a dependency-expanded host. It describes the rollback mechanism as it is after #203 (the always-reset detection_engine_rollback fact, granted by Step 6's tag after the snapshot validates), not #190's `_binary_result is defined` wording, and cites the new lint TestRegression_AlwaysTaggedTasksConverge with its two allowlist entries (agent-controller Step 4 for #195; freeipa-realm-replacement's one-off trust snapshot). The rescue bullet that says snapshot/upgrade facts must be `always` now points at the exception. v1.41's row gets a correction and v1.58 records the change. No docs/runbooks or docs/verification text said Step 3-5 are `always`; the evidence records are left as written. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Refs #189
問題
pilot deploy --limit的 apply,最後的冪等性檢查會因為 it-core 上 detection-engine 的Step 4: back up state.db before an upgrade在第二次套用又變更而失敗(idempotency failed: 第二次套用仍有 1 個變更),即使所有主機failed=0。實際 run:infra-config 的 Deploy,prod apply,--limit lkvs-b200-u08,p6k-digitree,LKVS-B200-U05(run 40850585606,log 第 24893 行是那一輪唯一的changed:)。原因
pilot 對
--limit的 run 實際執行ansible-playbook site.yml --limit <三台>,freeipa,it-core --tags audit-log-forwarding,dcgm-exporter,docker,freeipa-client,...。it-core 是依賴展開帶進來的提供者主機,detection-engine不在--tags裡,所以真正換 binary 的 Step 6(標detection-engine-C1,並帶 site.yml 給的observability/detection-engine)不會跑。但 Step 3–5 標always(為了讓任何跑到 Step 6 的 tag-scoped run 都先有備份),仍然執行:--limit的完整 preview 會列出 Step 6 要更換它),detection_engine_is_upgrade為真;is_upgrade永遠為真;changed_when: true,每一次套用都有一個變更。做法
Step 3、3b、3c、3d、4、5 的 tag 從
always改成 Step 6 自己的detection-engine-C1(observability、detection-engine由 site.yml 的 import 繼承,與 Step 6 相同)。這樣 Ansible 在所有選擇下(沒有 tags、--tags tagged、--skip-tags、component tag、detection-engine-C1…)都是「選到 Step 6 就選到備份,沒選到就都不跑」。Step 3c 的判斷式維持原樣。rescue(維持
tags: [always])原本用detection_engine_is_upgrade | default(false)讀這個變數,但 setter 不再是always,TestRegression_AlwaysTaggedTasksHaveAllPrerequisitesAlways會(正確地)拒絕這種寫法;也不放寬或加 allowlist。改成:when同時要求detection_engine_binary_result is defined(Step 6 的register,Step 6 失敗也會註冊,沒選到或沒跑到就未定義,即「這個 run 到過 Step 6,binary 可能被換掉、備份是這個 run 自己拍的」)與detection_engine_is_upgrade is defined and detection_engine_is_upgrade;detection_engine_is_upgrade,改成依下面的 Step 0 快照還原 service 與設定檔(見「service 狀態的還原」)。binary_result is defined同時修掉一個原本就有的問題:Step 4 失敗(Step 6 沒跑到)時,rescue 會把更早一次升級留下的.prev/pre-upgrade.db蓋回現在的 binary 與 state.db。(第一版用自己寫的
ansible_run_tags判斷式,沒涵蓋tagged這個特殊選擇器和--skip-tags,被審查指出--tags tagged會升級 binary 卻沒備份、失敗也無法復原;改成直接沿用 Step 6 的 tag,不再自己重現 Ansible 的選擇規則。)驗證
ansible-playbook --syntax-check通過。TestDetectionEngineBackupFollowsStep6TagSelection:用真的ansible-playbook跑這個 play 的 Step 3–6(真實 task、只把 root 擁有者拿掉並用假的 binary),對 9 種選擇各跑一次,檢查「Step 6 換了 binary ⇔ 兩份備份(pre-upgrade.db、舊 binary)都存在」:沒有 tags、--tags tagged、--tags detection-engine-C1、--tags detection-engine、--tags observability會升級並備份;--skip-tags tagged、--tags detection-engine --skip-tags detection-engine-C1、只選其他元件(失敗 run 的情況)、detection-engine-C2不升級也不備份。--tags tagged(升級卻沒備份)和--tags detection-engine --skip-tags detection-engine-C1(沒升級卻備份)失敗,對這一版通過。cmd/pilot/cmd/detection_engine_rollback_tags_test.go:TestDetectionEngineBackupTagsEqualStep6Tags:不需要 ansible 的 YAML 靜態檢查,Step 3、3b、3c、3d、4、5 的 tag 集合必須等於 Step 6 的(且不含always);三個 rescue 還原/重啟 task 要是always並帶is defined守衛(兩個還原 task 要有detection_engine_binary_result is defined)。TestDetectionEngineRollbackFollowsStep6:真ansible-playbook跑真實 Step 3–6 與兩個真實還原 task,中間注入改 state.db 的 task 與 Step 6 之後的失敗。沒有 tags、--tags tagged、--tags detection-engine-C1、--tags detection-engine、--tags observability(含預先放了舊備份的情況)都要還原成舊 binary 與原本的 db(用的是這個 run 拍的備份);沒選到 Step 6 的 run(--tags detection-engine --skip-tags detection-engine-C1加失敗、--tags detection-engine-C4加失敗,皆預放舊備份)與 Step 4 失敗(舊 binary 的db backup退出 1,預放舊備份)什麼都不還原。TestDetectionEngineSecondApplyChangedZero:舊 binary,沒有 tags 與tagged:第一次 apply 變更 Step 4、5、6(changed=3),第二次 changed=0;--tags docker對舊 binary:沒有任何 detection-engine task 執行、沒有備份、binary 不變。--tags detection-engine --skip-tags detection-engine-C1/只選其他元件的備份測試、舊備份的 rollback 測試與--tags docker第二次 apply 測試失敗;對上一個 head(2aad47cd)的 playbook,Step 4 失敗且有舊備份的案例與靜態守衛失敗;對這一版通過。TestRegression_AlwaysTaggedTasksHaveAllPrerequisitesAlways、TestRegression_RescueRunsWheneverItsBlockDoes、TestRegression_TaggedTasksReadOnlyWhatTheirTagsSet、TestRegression_NoUntaggedChecks...未修改且通過。detection_engine_binary_result is defined時還原;「備份 tag == Step 6 tag」由測試鎖住)、新增修訂紀錄 v1.57,並在 v1.41 那列註明 detection-engine 的 Step 3–5 已改。ccde655b,見下方「Actual-run evidence」):--tags docker連跑兩次都是changed=0、沒有備份;同一 snapshot 的main對照組是changed=2再changed=1、每次都備份(detection-engine/agent-controller: always-tagged Step 3-5 run when the component isn't selected, so a limited run's state.db backup is changed every time and the idempotency check fails #189 的症狀)。pilot deploy --limit本身與 prod 沒有實跑,選擇器是直接交給ansible-playbook site.yml。service 狀態的還原(歷經三輪嚴格審查後的最終設計)
問題:rescue 第一個 task 無條件 stop 並 disable service。重啟若依賴 C1 tag 才設的 fact(
detection_engine_is_upgrade),C1 被跳過時重啟就被略過,健康的 service 會被留在 stopped/disabled(BASE 在這個情境會救回)。審查接著又找到三個更深的問題,都已處理並有測試:failed_when: false,查詢失敗後 rescue 仍 stop+disable 卻略過還原。config validate拒絕的新 config(只還原 binary/db,沒還原 config):真 systemd 上重現為 crash loop(NRestarts=5),而「狀態」仍顯示 active/enabled。設計
在 install block 之前(都是
always、唯讀、changed_when: false、check_mode: false):systemctl show --property=ActiveState --property=UnitFileState記為detection_engine_service_before。ActiveState=與UnitFileState=各剛好一行且ActiveState非空;首次安裝(unit 不存在,show仍 rc 0 並印空的UnitFileState=)會通過。任何不符就在 Step 1 之前 fail closed,這個失敗不在 rescue 裡,所以不會觸發 stop/disable。stat(get_checksum/get_mime/get_attributes: false)與slurp(no_log)記下 unit 檔、config.yaml、feature profile、provider.env的原始內容、mode、數字 uid/gid、是否 symlink 與目標,以及 unit 檔原本是否存在。內容只放在記憶體,沒有備份檔,所以第二次 apply 仍是changed=0、--check也能跑。symlink 不會被 slurp(dangling 或連到目錄也不會讓 Step 0c 失敗)。檔案清單是新的 play 變數detection_engine_rollback_files。rescue 順序:stop+disable(需
detection_engine_service_before.rc == 0,沒有有效快照就不碰 service)→ 還原 binary/db(守衛不變)→ 還原四個檔(原樣寫回 mode/uid/gid;原本不存在的移除;原本是 symlink 的用file: state=link還原)→daemon-reload→ 原本UnitFileState=enabled就 re-enable → 原本ActiveState為active/activating/reloading就 restart。fail訊息會明講是哪一步。這是刻意的 fail closed:寧可停著也不重啟進壞 config。enabled-runtime不重新 enable(plaindisable不動/run的連結,enable會把它變永久)。inactive、failed、deactivating維持停止。when刻意沒有is defined與| default(),讓 repo 的 always-tag lint(AGENTS.md §4.4)自己逼 Step 0 維持always(改標 C1 會讓TestRegression_AlwaysTaggedTasksHaveAllPrerequisitesAlways失敗,已實測)。行為變更(明講):失敗後原本 active 的 service 現在會被重啟,而且 config/unit/profile/provider.env 會先被還原。以前(BASE)失敗後會留在 stopped/disabled。rescue 對任何失敗仍會 stop 後再重啟,即使失敗發生在任何變更之前,這是短暫中斷,最終 service 是 active。
驗證
ansible-playbook(ansible-core 2.19.2)+ 假的systemctl:TestDetectionEngineRescuePutsServiceBack(16 個情境,含 activating、reloading、enabled-runtime、re-enable 失敗但 restart 仍執行、restart 失敗但 enable 已完成)、TestDetectionEngineServiceStateQueryFailsClosed(11 個情境:show一次性失敗、持續失敗、rc 0 但輸出空/亂/缺行、show印出合法輸出但 exit 1、systemctl 不存在;都斷言 play 在任何變更前失敗、service 不變、沒有任何 mutating systemctl 呼叫)、TestDetectionEngineRescueDoesNotStopWithoutSnapshot、TestDetectionEngineRescueDoesNotRestartIntoNewConfig(假 unit 在 config 不是舊內容時 crash loop;5 個情境)、TestDetectionEngineStepZeroIsReadOnlyAndCheckModeSafe(apply 與--check、檔案存在與首次安裝,changed=0)、TestDetectionEngineSnapshotDoesNotLeakProviderEnv(-vvv --diff與--check,密碼、檔案 base64、密碼 base64、sha1 都不得出現)、TestDetectionEngineRescueRestoresModeOwnerAndSymlinks、TestDetectionEngineRescueLeavesWhatItWroteWithoutAUnitFile、TestDetectionEngineSnapshotAndRescueHandleUnreadableSymlinks;靜態檢查 rescue 順序、no_log、get_checksum: false、每個 rescue task 都是always。ExecStart=/bin/sh -c 'grep -q good cfg && exec sleep infinity'、Restart=on-failure、Step 10 拒絕新 config:有修正時前後都是active/running/enabled、NRestarts=0、config 回到good;停用還原的對照組是failed、NRestarts=5。另外六種起始狀態(active+enabled、active+enabled 加一次性show失敗、active+enabled-runtime、inactive+disabled、inactive+enabled、active+disabled)失敗後狀態等於失敗前;五種 symlink 情境(連到真檔、dangling、連到目錄、profile 連到 config、unit 檔本身是 symlink)都還原成功。系統層級 systemd(Ubuntu 24.04、systemd 255)的結果見下方「Actual-run evidence」。is not failed守衛、拿掉no_log(slurp、還原)、get_checksum打開、還原 mode/group 改回預設、停用 symlink task、拿掉 unit-existed 守衛、拿掉 stop 守衛、拿掉斷言的各行檢查、拿掉 Step 0 的changed_when/check_mode、讓 slurp 追 symlink 等,都會被測試抓到。還原 owner 只由靜態測試鎖住(非 root 的測試使用者無法 chown 到別的 uid,生產環境是 root)。./internal/spec由 CI 的 Go test job 在目前 head 上跑;本機在 candidate 與目前 head 只跑了相關子集(見下方)。main與 evidence commit 之後,對目前 head53f8a8b5的完整 diff 審查:0 個 P0–P2;以突變確認 F1–F3 的回歸測試會因預期原因失敗;對照原始輸出抽查了 evidence。已知殘留風險
/etc/hosts的 pin 與 Step 2 的目錄/使用者不還原。systemctl link連進來(UnitFileState=linked/alias),會被還原並重啟,但不會重新 enable。pilot deploy --limit本身沒有實跑(vm-target 以site.yml加相同的 tags 重現);要在 prod 重跑一次含 it-core 的--limitapply 才能確認那邊的冪等性檢查通過。.prev/pre-upgrade.db)不在本 PR 範圍。沒處理的
--limit之類的 run 不再誤觸發備份;it-core 上 detection-engine 的 binary 仍是舊版,要完整升級另外處理。agent-controller-apply.yml有同樣always的 Step 3–5 寫法(備份 task 在沒選到替換步驟時照跑),這個 PR 沒動;site.yml沒有 import 它,這次的失敗不會經過它;它的 tag 語意(沒有 site.yml 給的 component tag)也需要另外確認。when讀detection_engine_binary_result is changed沒有 default,任何選到 Step 14 卻沒選到 Step 6 的 run(例如--tags detection-engine --skip-tags detection-engine-C1)都會在 Step 14 失敗。main對照組(C-Fa2)同樣失敗;這個 PR 的 rescue 會把 service 與檔案還原。修法是 §4.4 的| default({}),見 detection-engine: Step 14 reads registered results without a default, so a run that skips C1/C5/C12 fails there #206。AGENTS.md§4.4 新增那一點寫「還原那三個 task 用ignore_errors」,實際是四個(還原檔案、還原 link、移除檔案、daemon-reload);文字誤差,未改。changed_when: true拿掉;這個 PR 沒動,所以用Refs而不是Fixes。--limit時,alwaystask 繞過對依賴展開主機的限制,這個較大的設計問題沒處理。Actual-run evidence
docs/evidence/detection-engine/2026-10-30-ccde655b4d07.md。candidate 是把main(fdb6f03e)合併進來的 merge commitccde655b(treec39e554b),之後只多了 evidence commit53f8a8b5(evidence 檔、runbook 兩行、spec 的## Actual-run evidence說明文字;spec 的 yaml checks 區塊在兩者 byte 相同)。兩台全新 Ubuntu 24.04 vm-target(systemd 255):pr190-de(detection-engine、host-monitoring)、pr190-fx(假的 Thanos Query/Alertmanager)。ok=116 changed=14 failed=0、L4ok=118 changed=23 failed=0、L5 12/12、L6changed=0(同時涵蓋首次安裝)。main做對照:.prev是舊 binary、pre-upgrade.db通過db check、verify 12/12,第二次changed=0。--tags docker)×2:changed=0兩次、沒有備份;main是changed=2再changed=1。NRestarts=0、NeedDaemonReload=no;unit/config/profile/provider.env(root:adm0640)byte、mode、owner 都還原;有升級的情境舊 binary 也回來。main:Fc1 crash loop(failed、NRestarts=5)、Fa2/Fc1 的provider.env不見、Fc2 的 unit 與 config 留在這次 run 寫的版本。systemctl show一次性失敗):在 Step 0 拒絕、不進 rescue;MainPID、ExecMainStartTimestamp不變。本機在目前 head
53f8a8b5:go test -v -run DetectionEngine ./cmd/pilot/cmd/(ansible-core 2.19.2)76 PASS/0 FAIL/0 SKIP;always-tag/rescue/tag-alignment 等 repo lint、spec lint、--syntax-check、golangci-lint 2.12.1(--new-from-rev=origin/main)都通過。CI 涵蓋範圍的限制(請注意)
本 PR 新增的
cmd/pilot/cmd/detection_engine_*_test.go這幾個 real-ansible 測試 會用真的ansible-playbook/logrotate執行,找不到工具時t.Skip。.github/workflows/ci.yml的註解寫 Go test job 沒有 ansible-playbook,所以CI 的 Go test 綠燈不一定涵蓋這些測試(CI log 不是 verbose,沒有確認它們在 CI 是跑了還是 skip,見 #198)。這些測試在本機與獨立 reviewer 的環境都實際跑過(目前 head:0 SKIP),結果寫在上面的驗證段;合併後要靠 #198 補上 CI 才有人守著。靜態(YAML 結構)測試則會在 CI 執行。🤖 Generated with Claude Code