39 lines
191 KiB
JSON
39 lines
191 KiB
JSON
{
|
||
"schema_version": 1,
|
||
"channel": "homelab-runtime",
|
||
"command_id": "NEWFI-260907-A-PORTAL200-APPLY4DESIGN5",
|
||
"status": "OK",
|
||
"rc": 0,
|
||
"host": "pve01",
|
||
"mode": "read-only",
|
||
"component": "newfi-portal200-apply4-local-carrier-recovery5",
|
||
"started_at_utc": "2026-09-07T20:27:18Z",
|
||
"finished_at_utc": "2026-09-07T20:27:18Z",
|
||
"reference_register_checked": true,
|
||
"reference_sha256": "5a3d8e5154c41cb582a4e0aca68090be1f0138918bf82131a948df326f9d8d66",
|
||
"error_register_checked": true,
|
||
"error_register_sha256": "3b09a553ec0f527ed3afeed4753f52a74ee3036045bbfb3c685e9f8af4ba7ba0",
|
||
"command_sha256": "d8972287e5a04bda3fc17acba988b2651b92ce4193aa3038f19cf27372093f65",
|
||
"duplicate_failed_command_blocked": false,
|
||
"block_reason": null,
|
||
"execution_started": true,
|
||
"change_declared": false,
|
||
"result_contract_valid": true,
|
||
"result_contract_status": null,
|
||
"result_contract_error": null,
|
||
"command_rc": 0,
|
||
"changes_made": false,
|
||
"rollback_started": false,
|
||
"rollback_restored": null,
|
||
"mutation_outcome": "NO_MUTATION",
|
||
"sanitized": true,
|
||
"secrets_included": false,
|
||
"private_addresses_included": false,
|
||
"raw_evidence_retained_locally": true,
|
||
"raw_evidence_sha256": "58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f",
|
||
"sanitized_output_sha256": "58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f",
|
||
"output_truncated_in_json": false,
|
||
"full_sanitized_output_url": "https://git.gram1.ru/.well-known/homelab-runtime/latest.txt",
|
||
"output": "LOCAL_APPLY3_CARRIER_RECOVERY5_BEGIN=true\nCOMMAND_ID=NEWFI-260907-A-PORTAL200-APPLY4DESIGN5\nMUTATION_SCOPE=NONE_READ_ONLY\nLOCAL_MUTATIONS_PERFORMED=NO\nTARGET_OLD_COMMAND_ID=NEWFI-260907-PORTAL200-APPLY3\nSEED_COUNT=41\nSEED_BASENAMES=NEWFI.txt,NEWFI-260907-A-PORTAL200-APPLY4PREP1.stdout.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT2_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT2_STDOUT.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT4_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT4_STDOUT.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT5_REMOTE.txt,NEWFI-260907-B-PORTAL200-RUNNERCONTRACT5_STDOUT.txt,newfi-38-portal200-apply.cmd,newfi-39-portal200-rca1.cmd,newfi-40-plink-portal200-rca.cmd,newfi-41-plink-portal200-rca3.cmd,newfi-45-pageant-portal200-rca4.cmd,newfi-46-portal200-http-rca.cmd,newfi-48-portal200-apply2.cmd,newfi-49-portal200-preflight-rca.cmd,newfi-50-portal200-preflight-tail-rca.cmd,newfi-51-portal200-prod-http-network-rca.cmd,newfi-52-prod-public-http-rca.cmd,newfi-53-public-css-regex-rca.cmd,newfi-54-portal200-apply3.cmd,newfi-55-portal200-apply3-rca.cmd,newfi-56-portal200-apply3-failure-contract-rca.cmd,newfi-57-portal200-contract-rule-rca.cmd,newfi-58-portal200-exact-contract-rca.cmd,newfi-59-portal200-contract-ast-rca.cmd,newfi-60-portal200-import-contract-discovery.cmd,newfi-61-portal200-import-compact-discovery.cmd,newfi-62-xf-addon-rebuild-help.cmd,NEWFI45_PORTAL200_RCA4_REMOTE.txt,NEWFI45_PORTAL200_RCA4_REPORT.txt,NEWFI46_PORTAL200_HTTP_RCA_REMOTE.txt,NEWFI46_PORTAL200_HTTP_RCA_REPORT.txt,NEWFI46_PORTAL200_HTTP_RCA_STDOUT.txt,NEWFI48_PORTAL200_APPLY2_REMOTE.txt,NEWFI54_PORTAL200_APPLY3_REMOTE.txt,NEWFI55_REMOTE.txt,NEWFI55_STDOUT.txt,NEWFI56_REMOTE.txt,NEWFI56_STDOUT.txt,NEWFI_PARALLEL_FULL_HANDOFF_2026-09-07.txt\nSEED_SHA256=869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195,240d0fad482deb05c93289edd7ac58a84e34b98fc620d1e924b561aace8630bb,92255a714b86966ab5a15a86d18c6257c6c131b20520a6d2d2443b8fe969e93a,9bffe2728a05e52f03ec52fddfcce229588b6f6cff59e8932d6c8b5ae97c592b,e92d1a64266be86cc8e5441ff9f51e2161e35e00ffd12dce1eb049a4b6437dd0,53c8ea2dc91c33b2872da3974ac5a682cbdc907c9d232a29921b674d490d474e,0b4ba8d2b1eef91c627884071bbcdeea8ad9b4f6c04c8650e44eb468253d15f6,a59a4b7a482a0a39a51e08553749c1f15bcf74d4ba449f6d50caf7a6ba60f9b5,8481b33b162a78f0e1398b69fda50f581ace72f87cdfb2a16fb66ceb67fa4987,34245e3a7c02145e7f11e3c7919385ff955e739a5b278ede66f8ec91499a31e4,a398e89eec09cca84a907876dfe46cf491d8b61c402c1dc4e4ec36eb92167845,11ea567ce0485bd87c5da8292c4d01d55e741a85b75fe0bf5073ca33133055f7,96b2de593d982ef7a5cd09c5618e6aabaac259f896a70cc8f1b476b0c3b16a58,2ce247a8dae973819605d246273b294bf23ce1d118c3e8b92517696caff927c9,f0ad32266492990e58a90991f2d6ae808c3e57c1ad53244bfcd26100e163cd73,2d5a55fbb00e63a280680b312d3eff3e28d524e29e8acf9ee8cae77b7c7d1f2e,fae07cfd5e9196871cd8bec8305262c95ecd09654994675e9a4ac4617d761f99,9aca123af4a9674c2bc10f308a65b622dce616cac17fbfe9b58db3782fbd2d0e,c8e9abf642daba2118f3419a00ceaeb0a0cc9a54ae75575e5e903a8db8fd1c1d,92b919e1b4e0b910da5b8357619d853c5791dc73fb8a70d097563128d7c818e7,573c5c94fe59ed072e094281668ce9847dc4a9882019a2b89d8ba5f190d92176,6c98cb81713e91d9866d264e59c513b8a968c4f52bfbad55450957524600bb10,69d7a46640fd020c0daab39c3195ca77ff777242c22b419f66dfcb69be3a8a23,1f99ab8666c4d51844b8e95bf3768cb1b017921824e9ba8857a6579dfaafb6af,e10dcbd211b75626199ed50dda79f34ae169caef24ba747c74b9c79ddaab509e,3621712ba5fecff93abca54c601f4f176d976a67344a506a92d6de79918f05a5,07d2b88b41b30e90058fa1d389cc52d1ea3b20b557a962f9004db720bfc8d2b0,88b9f92828c1ee19537526a1eea93a52c59bc2f32a7034e45508e53cca2e3667,c8827d2f8b0ddc34771c1c71ffbf517d1bd117dcf36b4d3e5439ae262bc86c32,7fc78755edbeee8c49d3d47e26a723c01ee0e1e6b022b8fd2f70ccacdf8f4338,edba9db8c9ddedb0fe3e727c7ed5a255d1da6de82da722431f3db053abff2f38,3ea9e7e83411127caf13af674570f0ef9ac968b13393b0a173ce1344d6a29cf0,1c91f522bb94d4ce292bb451dd847f394362c0a8f4745ae8823a5eed33e2a10b,cfd23052413cad9d435f9aba290956e93d9191312d568cd1cc69437fc602ad03,474b251535372a61e8be1acf215c1cc2240f8414bdd1c5e03309fd5faa2f8f99,71da2a66224075527d8aa04c1281fdb7a2b9f078d76c349fc05b48a4b803a4c3,f4600697557109030fc188376f1407fc02e4ec2eff2c635d81e2438f8a325fe3,65ff188b6b7bd06f59feca719f4fad6e07ff8304ef157425b162b3928f674d31,5f2eb373b3b37d29dff72b8b7d5cc1346283f7b2de02ae2eb95a229e542c5468,b2968ec8823fa4bb7b3d6fd4a25f25d6a486eb9740ecc6ef362d1b31e5f439b2,869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195\nDECODED_LAYER_COUNT=64\nSTRONG_LAYER_COUNT=3\nSOURCE_LAYER_COUNT=9\nCHOSEN_LAYER_COUNT=9\nCANDIDATE_LAYER_1_SOURCE=file:NEWFI.txt\nCANDIDATE_LAYER_1_SHA256=869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195\nCANDIDATE_LAYER_1_BYTES=57343\nCANDIDATE_LAYER_1_TEXT_BEGIN\n\nПОЛНЫЙ ПАРАЛЛЕЛЬНЫЙ HANDOFF: NEWFI / PORTAL200\nДата сборки: 2026-09-07\nНазначение: начать работу сразу в двух чатах без повторного восстановления контекста.\n\n===============================================================================\n0. КРИТИЧЕСКОЕ РАЗДЕЛЕНИЕ ПРОЕКТОВ\n===============================================================================\n\nПриложенный handoff второго чата относится к KINGOFWOLK CSP.\n\nKINGOFWOLK:\n- agreed safe CSP stage полностью закрыт;\n- shared counter = 0;\n- final accepted checkpoint = KOW-260907-A-FINAL1;\n- его product-state НЕ переносить в NEWFI.\n\nИз KINGOFWOLK для NEWFI переиспользуются только проверенные operational rules:\n- true-parallel read-only direct bridge;\n- один writer для любых изменений;\n- Pageant -> Plink -> pve01 transport;\n- authority checks;\n- homelab-chat-run contracts;\n- immutable Gitea readback;\n- unique COMMAND_ID;\n- fail-closed drift handling;\n- destructive Git / raw SQL mutation prohibition.\n\nПолный KINGOFWOLK handoff приложен ниже как APPENDIX A.\n\n===============================================================================\n1. АКТИВНЫЙ ПРОЕКТ\n===============================================================================\n\nПроект: NEWFI.\nАктивный участок: Portal200.\n\nСейчас НЕ продолжаем KingOfWolk и НЕ занимаемся Linux.\n\nТекущая рабочая проблема:\nпредыдущая change-команда Portal200 APPLY3 не является accepted checkpoint.\nПосле неё выполнены read-only RCA/discovery шаги.\n\n===============================================================================\n2. AUTHORITATIVE GITEA RUNTIME\n===============================================================================\n\nRuntime repo:\nhomelab-admin/homelab-public-context\n\nBranch:\nassistant-runtime\n\nAuthoritative exact readback:\nruntime/history/<COMMAND_ID>.txt\n\nНе полагаться на generic latest без точного COMMAND_ID.\n\nКлючевые текущие NEWFI checkpoints:\n\nA) NEWFI-260907-PORTAL200-APPLY3\n\nSTATUS=FAIL\nRC=76\nHOST=pve01\nMODE=change\nCOMPONENT=newfi-portal200-deploy\n\nКритично:\nRESULT_CONTRACT_VALID=false\nRESULT_CONTRACT_STATUS=UNKNOWN\nRESULT_CONTRACT_ERROR=INVALID_CONTRACT:ValueError\nCOMMAND_RC=1\nCHANGES_MADE=unknown\nROLLBACK_STARTED=unknown\nROLLBACK_RESTORED=null\nMUTATION_OUTCOME=UNKNOWN\n\nСледствие:\nAPPLY3 НЕЛЬЗЯ считать ни успешным deploy, ни доказанным rollback.\nНе повторять этот COMMAND_ID.\nНе строить следующий change на предположении, что production/staging точно остались\nв старом состоянии.\n\nB) NEWFI-260907-PORTAL200-CONTRACTAST1\n\nSTATUS=OK\nRC=0\nMODE=read-only\nCOMPONENT=newfi-portal200-contract-ast-rca\nCHANGES_MADE=false\nMUTATION_OUTCOME=NO_MUTATION\n\nНазначение:\nread-only RCA runner result-contract / AST после APPLY3.\n\nЭто accepted diagnostic checkpoint.\n\nC) NEWFI-260907-PORTAL200-IMPORTDISC2\n\nSTATUS=OK\nRC=0\nMODE=read-only\nCOMPONENT=newfi-portal200-import-compact-discovery\nCHANGES_MADE=false\nMUTATION_OUTCOME=NO_MUTATION\n\nНазначение:\ncompact read-only discovery import path + runner-contract requirements.\n\nИз опубликованного evidence видно, что runner contract жёстко валидирует как минимум:\n- поддерживаемую version;\n- command_id exact match;\n- status только OK / BLOCKED / FAIL;\n- changes_made обязан быть boolean.\n\nПри APPLY4 не импровизировать contract JSON:\nсначала перечитать CONTRACTAST1/IMPORTDISC2 и runner source.\n\nD) NEWFI-260907-PORTAL200-REBUILDHELP1\n\nSTATUS=OK\nRC=0\nMODE=read-only\nCOMPONENT=newfi-portal200-rebuild-help\nCHANGES_MADE=false\nMUTATION_OUTCOME=NO_MUTATION\n\nКлючевой вывод:\n\nXenForo CLI поддерживает штатный:\nxf:addon-rebuild [options] [--] <id>\n\nArguments:\nid = Add-On ID\n\nOption:\n-f, --force\nSkip verifying that the add-on is rebuildable\n\nСледствие:\nстарый ручной PHP/import loop не должен быть механизмом следующего deploy.\nСледующий candidate должен опираться на штатный XenForo add-on rebuild,\nНО exact Add-On ID сначала должен быть подтверждён read-only evidence.\nНе hardcode Newfi/Portal только по памяти.\n\n===============================================================================\n3. ГЛАВНЫЙ ВЫВОД ПО APPLY3\n===============================================================================\n\nAPPLY3 имеет ДВЕ независимые проблемы, которые нельзя смешивать:\n\n1. RUNNER / PUBLICATION CONTRACT FAILURE\n Runner отклонил result contract:\n INVALID_CONTRACT:ValueError.\n\n2. PRODUCT MUTATION STATE НЕ ДОКАЗАН\n Outer runtime показывает:\n CHANGES_MADE=unknown\n ROLLBACK_STARTED=unknown\n MUTATION_OUTCOME=UNKNOWN\n\nПоэтому правильная последовательность сейчас:\n\nНЕ:\n\"сразу повторить deploy как APPLY4\"\n\nА:\n- параллельно восстановить current prestate;\n- параллельно построить корректный APPLY4 design;\n- change запускать только после read-only PASS prestate.\n\n===============================================================================\n4. ПАРАЛЛЕЛЬНАЯ МОДЕЛЬ ДЛЯ NEWFI\n===============================================================================\n\nИспользовать проверенную KOW concurrency model.\n\nLANE A = SINGLE WRITER / DEPLOY DESIGN\nLANE B = TRUE PARALLEL READ-ONLY AUDITOR\n\nРазрешено одновременно:\n- Lane A читает Git/Gitea/reference и строит новый carrier;\n- Lane B выполняет direct read-only audit;\n- оба читают immutable Gitea history.\n\nЗапрещено:\n- два change одновременно;\n- два git commit/push writer;\n- два application/XenForo mutation path;\n- повтор failed COMMAND_ID;\n- Lane B mutation.\n\nНа момент фактического APPLY4:\nLane A — единственный writer.\nLane B не должен запускать новый state-sensitive snapshot в середине mutation,\nкроме специально спроектированного concurrent monitor с drift guards.\n\n===============================================================================\n5. COMMAND_ID FAMILY ДЛЯ НОВОЙ ПАРАЛЛЕЛЬНОЙ СЕССИИ\n===============================================================================\n\nЧтобы не путать старую историю:\n\nLane A:\nNEWFI-260907-A-PORTAL200-...\n\nLane B:\nNEWFI-260907-B-PORTAL200-...\n\nКаждый attempt:\nновый COMMAND_ID.\n\nНе reuse:\nNEWFI-260907-PORTAL200-APPLY3\nили любой другой failed ID.\n\n===============================================================================\n6. LANE B — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ЗАПУСКАТЬ СРАЗУ\n===============================================================================\n\nЦель:\nPOST-APPLY3 CURRENT-STATE RECOVERY AUDIT.\n\nТолько read-only.\n\nLane B должен доказать:\n\n1. Authority:\n /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md\n /etc/pve/31_HOMELAB_REFERENCE.md\n readable + nonempty + прочитаны.\n\n2. Runner:\n current /usr/local/sbin/homelab-chat-run SHA;\n exact result-contract schema;\n duplicate command semantics.\n\n3. Git:\n authoritative NEWFI repo/worktree;\n branch;\n local HEAD;\n remote HEAD;\n clean/dirty;\n relevant Portal200 files + hashes;\n no unknown local mutation.\n\n4. XenForo environment:\n exact production/staging VM/CT IDs;\n exact docroots;\n exact XenForo versions;\n service health;\n current addon list.\n\n5. Exact Portal Add-On ID:\n resolve from add-on metadata/live XF state.\n Do not mutate.\n Candidate methods:\n - xf:addon-list;\n - addon.json;\n - xf_addon read-only SELECT;\n - existing deployment metadata.\n\n6. Current Portal add-on state:\n installed/active/version;\n pending rebuild/install state if observable read-only;\n relevant cache/code-cache presence;\n current route behavior.\n\n7. APPLY3 aftermath:\n whether product files differ from accepted pre-APPLY3 baseline;\n whether DB/install state changed;\n whether staging changed;\n whether production changed;\n whether rollback evidence exists.\n\n8. Health:\n Nginx / PHP-FPM / MariaDB / related app services;\n HTTP codes for important NEWFI routes.\n\n9. DB:\n SELECT only.\n No UPDATE/INSERT/DELETE/ALTER.\n\n10. Final decision:\n ELIGIBLE_FOR_PORTAL200_APPLY4_PREP\n or exact HOLD_...\n\nLane B output should be bounded and sanitized.\nNo secrets/private credentials.\n\n===============================================================================\n7. LANE A — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ДЕЛАТЬ ПАРАЛЛЕЛЬНО\n===============================================================================\n\nLane A НЕ должен ждать Lane B, чтобы начать design.\n\nОн может сразу:\n\n1. перечитать:\n APPLY3\n CONTRACTAST1\n IMPORTDISC2\n REBUILDHELP1\n\n2. перечитать:\n /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md\n /etc/pve/31_HOMELAB_REFERENCE.md\n\n3. найти authoritative NEWFI Git worktree / branch / Portal200 deployment source.\n\n4. построить APPLY4 carrier OFFLINE / PREP ONLY.\n\n5. В APPLY4 заранее исправить:\n - result contract;\n - official XenForo rebuild path;\n - fail-closed prestate;\n - explicit rollback semantics;\n - exact mutation accounting;\n - exact COMMAND_ID;\n - publication/readback.\n\n6. Не запускать APPLY4 mutation, пока Lane B не подтвердил current-state eligibility.\n\n===============================================================================\n8. APPLY4 — ЧТО ДОЛЖНО БЫТЬ В ДИЗАЙНЕ\n===============================================================================\n\nAPPLY4 не должен быть слепым rerun APPLY3.\n\nОбязательные свойства:\n\nA. PRESTATE\n- authority checks;\n- runner SHA;\n- exact Git local/remote/clean;\n- exact expected source bytes/hashes;\n- exact current Portal add-on state;\n- services healthy;\n- target environment identity;\n- no unexplained post-APPLY3 drift.\n\nB. ADD-ON IMPORT / REBUILD\n- use native XenForo command:\n xf:addon-rebuild <CONFIRMED_ADDON_ID>\n- use -f only if evidence/design shows it is needed;\n- do not return to manual PHP import loop.\n\nC. RESULT CONTRACT\nMust match runner schema exactly.\nAt minimum:\n- supported version;\n- exact command_id;\n- status in OK/BLOCKED/FAIL;\n- changes_made boolean;\n- rollback fields with correct types.\nDo not emit unknown where boolean is required.\n\nD. FAIL CLOSED\nIf prestate is not exact:\nBLOCKED / no mutation.\n\nE. ROLLBACK\nBefore mutation:\n- prove rollback target/material exists;\n- preserve previous product/app state;\n- rollback on any failed verification;\n- verify rollback result.\n\nF. VERIFICATION\nAfter candidate:\n- XenForo CLI exit;\n- add-on version/state;\n- relevant DB rows read-only;\n- routes;\n- services;\n- XenForo error log;\n- no unexpected file drift.\n\nG. GIT\nIf Git promotion belongs in this Portal200 stage:\n- one writer;\n- explicit path staging;\n- git diff --check;\n- no git add .;\n- no reset --hard;\n- no git clean -fd;\n- no force push;\n- verify local == remote after push.\n\nH. PUBLICATION\nExact runtime/history/<CID>.txt must be readable and match COMMAND_ID.\n\n===============================================================================\n9. ЧТО НЕЛЬЗЯ ДЕЛАТЬ\n===============================================================================\n\n- Не считать APPLY3 успешным.\n- Не считать APPLY3 rollback успешным.\n- Не reuse APPLY3 ID.\n- Не запускать raw SQL mutation.\n- Не запускать manual PHP import loop, пока native rebuild доказан.\n- Не hardcode add-on ID без live/source evidence.\n- Не делать git reset --hard.\n- Не делать git clean -fd.\n- Не force push.\n- Не запускать два writer.\n- Не принимать generic latest как authoritative без exact COMMAND_ID.\n- Не использовать KOW VM/IP/path как NEWFI environment.\n- Не переносить KOW product state в NEWFI.\n\n===============================================================================\n10. WINDOWS / SSH TRANSPORT — ПЕРЕИСПОЛЬЗУЕМ ИЗ KOW\n===============================================================================\n\nPlink:\nC:\\Program Files\\PuTTY\\plink.exe\n\nPageant:\nC:\\Program Files\\PuTTY\\pageant.exe\n\nSSH key:\n<USERPROFILE>\\Documents\\ssh\\homelab_ed25519.ppk\n\nEncrypted PPK.\n<REDACTED_SENSITIVE_LINE>\n\nNormal transport:\n\nCMD\n -> PowerShell 5.1\n -> Start-Process\n -> Plink -batch -agent -ssh -P 22 -m <remotePath>\n -> root@<PRIVATE_IPV4>\n -> pve01\n -> direct read-only bridge OR homelab-chat-run\n -> Gitea runtime publication\n -> exact immutable readback\n\nDo not use:\n- MobaXterm;\n- -i in normal carrier;\n- pause;\n- Notepad;\n- blind host-key acceptance.\n\nPowerShell:\nseparate stdout/stderr;\n-NoNewWindow;\n-Wait;\n-PassThru;\nreal ExitCode.\n\nRemote command file:\nUTF-8 no BOM;\none physical line;\nSHA verified.\n\nLarge payload:\nzlib + base64;\nSHA256;\npython3 -I -B.\n\n===============================================================================\n11. AUTHORITY CHECKS\n===============================================================================\n\nПеред серьёзной server command:\n\n/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md\n/etc/pve/31_HOMELAB_REFERENCE.md\n\nОба:\nreadable;\nnonempty;\nпрочитать.\n\nVisibly emit:\n\nERROR_REGISTER_CHECK=OK\nREFERENCE_CHECK=OK\nAUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED\n\nFail -> STOP.\n\n===============================================================================\n12. GITEA READBACK\n===============================================================================\n\nAuthoritative:\n\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.txt\n\nJSON, если нужен:\n\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.json\n\nНельзя делать вывод по runtime/latest.txt,\nесли COMMAND_ID не совпадает.\n\n===============================================================================\n13. КАК СИНХРОНИЗИРОВАТЬ ДВА ЧАТА\n===============================================================================\n\nПосле каждого accepted checkpoint:\n\n1. чат публикует exact COMMAND_ID;\n2. второй чат читает immutable Gitea history;\n3. оба обновляют общий current-state;\n4. FAIL/RCA не считать product progress;\n5. PASS read-only может закрывать audit/preflight;\n6. mutation checkpoint только Lane A.\n\nЕсли Lane A сделал change:\nLane B перед следующим audit обязан заново прочитать этот exact checkpoint.\n\nЕсли Lane B обнаружил HOLD:\nLane A не запускает mutation до разбора HOLD.\n\n===============================================================================\n14. PROMPT ДЛЯ ЧАТА A — SINGLE WRITER\n===============================================================================\n\nТы LANE A / SINGLE WRITER для NEWFI Portal200.\n\nИспользуй Gitea assistant-runtime как machine truth.\nНе занимайся KingOfWolk: KOW CSP agreed stage уже CLOSED.\n\nCurrent NEWFI checkpoints:\n- APPLY3 FAIL, invalid result contract, mutation outcome UNKNOWN.\n- CONTRACTAST1 OK read-only.\n- IMPORTDISC2 OK read-only.\n- REBUILDHELP1 OK read-only.\n- native XenForo command xf:addon-rebuild [options] <id> подтверждён.\n\nТвоя задача:\nпараллельно с Lane B спроектировать NEWFI Portal200 APPLY4,\nно НЕ запускать mutation, пока Lane B не вернёт\nELIGIBLE_FOR_PORTAL200_APPLY4_PREP.\n\nAPPLY4:\n- новый unique COMMAND_ID family NEWFI-260907-A-PORTAL200-...\n- exact authority checks;\n- current-state preconditions;\n- correct homelab-chat-run result contract;\n- native xf:addon-rebuild with exact live-confirmed add-on ID;\n- fail closed;\n- rollback proof;\n- verification;\n- single writer;\n- exact Gitea publication/readback.\n\nПеред каждой реальной mutation заново прочитай последние Lane A + Lane B checkpoints.\n\n===============================================================================\n15. PROMPT ДЛЯ ЧАТА B — READ-ONLY AUDITOR\n===============================================================================\n\nТы LANE B / STRICT READ-ONLY AUDITOR для NEWFI Portal200.\n\nНикаких Git/application/DB/production mutations.\n\nИспользуй proven bridge:\ndirect read-only phase outside homelab-chat-run;\nserialized sanitized publication through runner.\n\nCOMMAND_ID family:\nNEWFI-260907-B-PORTAL200-...\n\nСразу выполни post-APPLY3 current-state recovery audit:\n\n- authority checks;\n- runner SHA + contract;\n- authoritative Git local/remote/clean;\n- exact NEWFI staging/prod identity;\n- exact XenForo docroot/version;\n- exact Portal Add-On ID;\n- installed/version/rebuildable state;\n- APPLY3 aftermath;\n- file/source hashes;\n- service health;\n- relevant HTTP routes;\n- XF error log state;\n- read-only DB evidence;\n- no mutation.\n\nFinal decision:\nELIGIBLE_FOR_PORTAL200_APPLY4_PREP\nor exact HOLD_...\n\nПубликуй bounded sanitized evidence в immutable Gitea history.\nПосле публикации сообщи exact COMMAND_ID.\n\n===============================================================================\n16. РЕКОМЕНДУЕМЫЙ СТАРТ СЕЙЧАС\n===============================================================================\n\nЛучший быстрый порядок:\n\nNOW:\n- текущий чат = Lane A;\n- новый второй чат = Lane B.\n\nОдновременно:\nLane A готовит APPLY4 design.\nLane B снимает current-state recovery audit.\n\nКогда Lane B PASS:\nLane A сверяет immutable evidence и запускает единственный change APPLY4.\n\nПосле APPLY4:\nLane B выполняет независимый read-only post-change verification,\nесли это реально добавляет evidence и не дублирует уже встроенный rollback/canary.\n\n===============================================================================\n17. COUNTER / ROADMAP\n===============================================================================\n\nНе использовать KingOfWolk counter.\nУ KOW counter = 0 и scope закрыт.\n\nДля NEWFI ранее использовалась рабочая оценка оставшихся команд,\nно её нельзя считать authoritative runtime state без восстановления NEWFI roadmap.\n\nПервый Lane B recovery audit должен также найти/проверить текущую NEWFI roadmap/\naccepted-stage count, если он хранится в repo/reference.\n\nДо этого не печатать ложный точный countdown.\n\n===============================================================================\n18. SOURCE-OF-TRUTH PRIORITY\n===============================================================================\n\nПри конфликте:\n\n1. /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md\n2. /etc/pve/31_HOMELAB_REFERENCE.md\n3. exact immutable Gitea runtime/history/<CID>.txt\n4. authoritative Git source/current live state\n5. этот handoff\n\nЭтот handoff ускоряет старт, но не перекрывает более свежий live/Gitea evidence.\n\n===============================================================================\nAPPENDIX A\nПОЛНЫЙ HANDOFF ИЗ ВТОРОГО ЧАТА: KINGOFWOLK CSP\n(сохранён целиком; используется для operational/concurrency rules, не как NEWFI product-state)\n===============================================================================\n\n\nПОЛНЫЙ HANDOFF: KINGOFWOLK CSP\nДата контекста: 2026-09-07\n\n1. СТАТУС ПРОЕКТА НА МОМЕНТ HANDOFF\n\nПроект: KingOfWolk forum CSP.\n\nShared counter:\n0 команд до полного финиша.\n\nВажно: 0 означает, что согласованный безопасный CSP-stage полностью завершён и подтверждён evidence. Это не означает, что CSP доведён до теоретически максимальной жёсткости.\n\nОтдельно оставлены будущие hardening-задачи:\n- удаление style-src 'unsafe-inline';\n- рефакторинг существующего javascript: URL;\n- перевод script-src-elem из Report-Only в enforcing после достаточного периода наблюдения.\n\nФинальный accepted checkpoint:\n\nKOW-260907-A-FINAL1\nSTATUS=OK\nRC=0\nDECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED\n\nФинальный canary не внёс изменений в production.\n\n\n2. CANONICAL KINGOFWOLK ENVIRONMENT\n\nФорум:\n\nDomain:\nhttps://kingofwolk.ru\n\nForum VM:\nVM160\nhost: pve02\nIP: <PRIVATE_IPV4>\n\nXenForo:\n2.3.12\nXF id: 2031270\n\nDatabase:\nxf_kingofwolk\n\nDocument root:\n/var/www/forums/kingofwolk/public\n\nXF source:\n/var/www/forums/kingofwolk/src/XF\n\nИз ранее принятого baseline:\n\nboardActive = 1\nboard title = KingOfWolk\nstyle id = 2\nstyle name = KingOfWolk Dark\n\nCAPTCHA = Cloudflare Turnstile\nfriendly URLs = enabled\nthird-party add-ons = none\n\nusers = 2\nadmins = 1\nnodes = 13\nthreads = 73\nposts = 73\nattachments = 0\nXF errors = 0\n\nOrigin-side services были healthy:\n\nNginx\nMariaDB\nphp8.3-fpm\n\nCSP реализуется на edge, а не origin XenForo.\n\n\n3. EDGE / NPMPLUS\n\nEdge находится на:\n\nVM130\nhost: pve03\n\nNPMplus:\n\ncontainer: npmplus\nimage: zoeyvid/npmplus:2026-06-17-b1\n\nLive KingOfWolk proxy config:\n\n/opt/npmplus/nginx/proxy_host/996.conf\n\nMetadata:\n\nowner: root:root\nmode: 0644\n\nCSP collector log:\n\n/opt/npmplus/csp-reports/kingofwolk-csp-report.log\n\nLog metadata:\n\nroot:root\n0600\n\nDirectory:\n\n0700\n\nCollector endpoint:\n\n/__csp-report-v1\n\nSemantics:\n\nGET -> 405\nAllow -> POST\n\nCollector поддерживает:\n\napplication/csp-report\napplication/reports+json\napplication/json\n\nCollector sanitizes:\n- script-sample;\n- query/fragment у URI.\n\nRotation threshold:\n\n>= 5 MiB\n\nФинальный canary снова подтвердил:\n\ncollector GET = 405\nAllow = POST\n\n\n4. GIT — AUTHORITATIVE SOURCE\n\nИспользуемый repo:\n\nhomelab-admin/homelab-ops\n\nИзолированный CSP worktree:\n\n/root/kingofwolk-csp-baseline-20260907\n\nBranch:\n\nforum/kingofwolk-csp-baseline-20260907\n\nНе использовать /srv/homelab-ops для изменений KingOfWolk CSP.\nИсторически он был dirty/diverged и использовался только как reference.\n\nClone-local Git identity:\n\nuser.name = homelab-ops-bootstrap\nuser.email = root@pve01.gram1.ru\n\nТекущий финальный accepted HEAD:\n\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\n\nParent:\n\n3e5d8584ae6887c292cc910c5796e73d8d20aecd\n\nCommit message:\n\ncsp(kingofwolk): stage script nonce and enforce font media\n\nТекущий source file:\n\ndeploy/forums/kingofwolk/edge/996.conf\n\nТекущий source SHA / production edge SHA:\n\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nФинальный canary подтвердил:\n\nGit local HEAD = 6f584c5faa76d0067b08977aeb583fa0e4de14aa\nGit remote HEAD = 6f584c5faa76d0067b08977aeb583fa0e4de14aa\nworktree clean = true\n\nИ edge до/после финального canary оставался:\n\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\n\n5. ИСТОРИЯ ACCEPTED GIT CSP COMMITS\n\nПоследовательность основных accepted commits:\n\n6908d4ebf2552244a68e3c0990fdc3a219c05d2c\ninitial CSP baseline docs\n\nf8d1ae6981df35f25d09a41392bc0a26742b7f38\nJoyPixels Report-Only allowance\n\n34f9c76c5cbf81f9cdf7f50d1ac501cf870abc9c\nminimal structural enforcement source\n\nc7a8a68a74fd3eb74cfb708b6bd3b866159b4899\ncsp(kingofwolk): enforce same-origin form submissions\n\nЕго source SHA:\n\n834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3\n\nСледующий:\n\ne4635c9997801a0ea65aad727270c88ee3bdb817\ncsp(kingofwolk): enforce PWA self sources\n\nSource SHA:\n\n28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b\n\nСледующий:\n\n3e5d8584ae6887c292cc910c5796e73d8d20aecd\ncsp(kingofwolk): enforce image and network sources\n\nSource SHA:\n\nfc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5\n\nФинальный текущий commit:\n\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\ncsp(kingofwolk): stage script nonce and enforce font media\n\nSource SHA:\n\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\n\n6. ТЕКУЩИЙ PRODUCTION ENFORCING CSP\n\nФинальный enforcing CSP:\n\nbase-uri 'self';\nobject-src 'none';\nframe-ancestors 'self';\nform-action 'self';\nworker-src 'self';\nmanifest-src 'self';\nimg-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;\nconnect-src 'self' https://challenges.cloudflare.com;\nframe-src 'self' https://challenges.cloudflare.com;\nfont-src 'self' data:;\nmedia-src 'self';\n\nВ одной строке:\n\nbase-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; worker-src 'self'; manifest-src 'self'; img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; font-src 'self' data:; media-src 'self';\n\nЧто намеренно НЕ enforcing:\n\ndefault-src\nscript-src\nscript-src-elem\nscript-src-attr\nstyle-src\nstyle-src-elem\nstyle-src-attr\n\nТакже enforcing policy не содержит:\n\n'unsafe-inline'\n'unsafe-eval'\n\nЭто намеренно: script/style hardening ещё не переведён в production enforcement.\n\n\n7. ТЕКУЩИЙ PRODUCTION REPORT-ONLY CSP\n\nSource-template на edge содержит Nginx variable $request_id.\n\nЛогическая policy:\n\ndefault-src 'self';\nbase-uri 'self';\nobject-src 'none';\nframe-ancestors 'self';\nform-action 'self';\n\nscript-src 'self' 'unsafe-inline' https://challenges.cloudflare.com;\n\nscript-src-elem\n 'self'\n 'nonce-$request_id'\n https://challenges.cloudflare.com;\n\nstyle-src 'self' 'unsafe-inline';\n\nimg-src\n 'self'\n data:\n blob:\n https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;\n\nfont-src 'self' data:;\n\nconnect-src\n 'self'\n https://challenges.cloudflare.com;\n\nframe-src\n 'self'\n https://challenges.cloudflare.com;\n\nworker-src 'self';\nmanifest-src 'self';\nmedia-src 'self';\n\nreport-uri /__csp-report-v1;\n\nТочная source-строка:\n\ndefault-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; script-src-elem 'self' 'nonce-$request_id' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/; font-src 'self' data:; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; worker-src 'self'; manifest-src 'self'; media-src 'self'; report-uri /__csp-report-v1;\n\nПри HTTP response $request_id уже заменён Nginx на фактический per-request nonce.\n\n\n8. NGINX NONCE TRANSPORT\n\nТекущий source также содержит transport для nonce-инъекции.\n\nКлючевые строки:\n\nproxy_set_header Accept-Encoding \"\";\nsub_filter_once off;\nsub_filter '<script' '<script nonce=\"$request_id\"';\n\nЗачем отключено upstream compression:\n\nsub_filter должен видеть HTML body в несжатом виде.\n\nДо production promotion был отдельно доказан forwarded-origin request:\n\nHTTP = 200\nContent-Encoding = NONE\n\nNonce source:\n\n$request_id\n\nВ Report-Only header используется:\n\n'nonce-$request_id'\n\nВ HTML тот же $request_id инъектируется в каждый <script> tag.\n\n\n9. ФИНАЛЬНЫЙ NONCE EVIDENCE\n\nФинальный checkpoint:\n\nKOW-260907-A-FINAL1\n\nпроверил HTML маршруты дважды:\n\n/\n /login/\n /register/\n /help/smilies/\n /threads/dobro-pozhalovat-na-ofitsial-nyi-forum-kingofwolk.1/\n\nДля каждого запроса проверялось:\n\nHTTP = 200\nровно один nonce в CSP Report-Only\nnonce не содержит literal $\nnonce имеет допустимый формат\nкаждый <script> имеет ровно один nonce\nnonce каждого <script> == nonce из response CSP header\nraw nonce count == parsed script count\n\nДополнительно:\n\nnonce не должен повторяться между двумя запросами одного route\nnonce не должен совпадать ни с одним nonce других canary requests\n\nФинальный результат:\n\nKOWAFINAL1_NONCE_UNIQUE_ACROSS_ALL_REQUESTS=true\nKOWAFINAL1_NONCE_VALUES_EXPORTED=NO\n\nТо есть сами nonce наружу не публиковались.\n\n\n10. CSP REPORT LOG BASELINE\n\nПринятый baseline:\n\nSHA256:\n2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143\n\nlines:\n2\n\nИсторически в нём:\n\nline 1 = synthetic test\nline 2 = historical real JoyPixels violation до allow-list\n\nЭтот baseline не менялся через:\n\n- PWA stage;\n- img/connect/frame stage;\n- nonce design;\n- font/media promotion;\n- production Stage2;\n- финальный browser/evidence canary.\n\nФинальный результат:\n\nKOWAFINAL1_NEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0\n\nNPMplus:\n\nbefore = healthy\nafter = healthy\n\n\n11. ДРУГИЕ SECURITY HEADERS\n\nОни не менялись и финальный canary проверил их exact match.\n\nX-Frame-Options:\nSAMEORIGIN\n\nX-Content-Type-Options:\nnosniff\n\nReferrer-Policy:\nstrict-origin-when-cross-origin\n\nStrict-Transport-Security:\nmax-age=31536000\n\nPermissions-Policy:\ngeolocation=(), microphone=(), camera=()\n\nРанее жёлтые browser console warnings были связаны с Permissions-Policy, а не с CSP.\n\n\n12. JOYPIXELS\n\nРазрешён только path-scoped источник:\n\nhttps://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/\n\nНе разрешён весь:\n\nhttps://cdn.jsdelivr.net\n\nРанее dedicated evidence:\n- реальный historical CSP report указывал JoyPixels;\n- /help/smilies/ реально использует этот path;\n- bad JoyPixels path count = 0.\n\nФинальный canary:\n\nKOWAFINAL1_JOYPIXELS_RENDERED_REF_COUNT=32\nKOWAFINAL1_JOYPIXELS_SAMPLE_HTTP=200\n\n\n13. CLOUDFLARE TURNSTILE\n\nTurnstile остаётся обязательной runtime зависимостью для регистрации.\n\nНужные разрешения сохранены:\n\nscript:\nhttps://challenges.cloudflare.com\n\nconnect:\nhttps://challenges.cloudflare.com\n\nframe:\nhttps://challenges.cloudflare.com\n\nВ текущем enforcing уже есть:\n\nconnect-src 'self' https://challenges.cloudflare.com;\nframe-src 'self' https://challenges.cloudflare.com;\n\nВ Report-Only script policies также Cloudflare разрешён.\n\nКритически важный урок:\n\nне требовать статический <script src=\"https://challenges.cloudflare.com/...\"> в исходном HTML.\n\nTurnstile может быть динамически instantiated.\n\nИменно из-за неправильного static-script требования ранее упал:\n\nKOW-260907-A-ICF5\n\nс:\n\nTURNSTILE_SCRIPT_REF_MISSING\n\nПосле исправления модели:\n\nKOW-260907-A-ICF6\n\nбыл accepted.\n\nФинальный canary проверял наличие Turnstile/challenges runtime tokens на /register/, а не статический script tag.\n\n\n14. FORM-ACTION\n\nЭтап полностью закрыт ранее.\n\nEnforcing:\n\nform-action 'self'\n\nПеред promotion были проверены:\n\nexternal public form origins = 0\npayment profiles = 0\nuser upgrades = 0\nprovider/options related external forms = 0\n\nProduction apply был с rollback proof и browser/server canary.\n\nНе пересматривать без новой фактической причины.\n\n\n15. PWA DIRECTIVES\n\nПолностью accepted:\n\nworker-src 'self'\nmanifest-src 'self'\n\nЗакрыты checkpoints:\n\nPWA3 audit\nPWA4 preflight\nPWA5 Git promotion\nPWA6 production + rollback proof\nPWA9 final recovery/readback\n\nИсторический transport bug:\n\nPWA7 browser canary сам по себе был PASS,\nно outer publication сломался.\n\nPWA8 RCA:\npublisher ошибочно ожидал evidence path в sys.argv[1].\n\nПосле этого правило:\n\npublisher должен использовать literal evidence path, а не рассчитывать, что sys.argv[1] содержит путь.\n\n\n16. IMG/CONNECT/FRAME STAGE\n\nПолностью закрыт.\n\nТекущие enforcing directives:\n\nimg-src 'self' data: blob: https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/;\n\nconnect-src 'self' https://challenges.cloudflare.com;\n\nframe-src 'self' https://challenges.cloudflare.com;\n\nКлючевые accepted checkpoints:\n\nKOW-260907-A-ICF2\nSTATUS=OK\nDECISION=ELIGIBLE_FOR_IMG_CONNECT_FRAME_SOURCE_PREP\n\nEvidence:\n\nCSS bytes scanned = 297724\nstylesheets = 9\nCSS url refs = 245\norigins = DATA / SELF\n\nGit promotion:\n\nKOW-260907-A-ICF3\n\nCommit:\n\n3e5d8584ae6887c292cc910c5796e73d8d20aecd\n\nProduction apply:\n\nKOW-260907-A-ICF4\n\nRollback proof passed.\n\nBrowser recovery:\n\nKOW-260907-A-ICF6\nDECISION=IMG_CONNECT_FRAME_ENFORCEMENT_POST_BROWSER_CANARY_CLEAN\n\n\n17. INLINE SCRIPT/STYLE AUDIT — ИСХОДНЫЕ ВЫВОДЫ\n\nДо nonce stage:\n\nrendered nonce attrs = 0\nnative nonce source signals = 0\n\nKOW-260907-A-INLINE1 показал по 5 guest routes:\n\nexternal_scripts = 35\n\ninline executable scripts = 5\ninline non-executable scripts = 3\ninline script tags total = 8\n\nstyle attributes = 31\n\njavascript: URLs = 6 occurrences\ndistinct javascript: values later narrowed to 1\n\nevent-handler attrs = 0\n\nscript nonce attrs = 0\nstyle nonce attrs = 0\nstyle tags = 0\n\nОсновной decision:\n\nHOLD_UNSAFE_INLINE_APPLICATION_NONCE_HASH_DESIGN_REQUIRED\n\n\n18. ПОЧЕМУ СТАТИЧЕСКИЕ HASHES ДЛЯ INLINE SCRIPTS БЫЛИ ОТКЛОНЕНЫ\n\nKOW-260907-A-INLINE2:\n\nSTATUS=OK\nRC=0\n\nDECISION=\nAPPLICATION_TEMPLATE_REFACTOR_DESIGN_REQUIRED\n\nБыло установлено:\n\nEXEC_SCRIPTS_WITH_DYNAMIC_MARKERS=5\nGLOBAL_DISTINCT_EXEC_SCRIPT_BODIES=5\nGLOBAL_DISTINCT_STYLE_ATTR_VALUES=7\nGLOBAL_DISTINCT_JAVASCRIPT_URLS=1\nGLOBAL_DISTINCT_EVENT_HANDLER_VALUES=0\n\nТо есть все 5 executable inline scripts имеют динамические признаки.\n\nПоэтому:\n- хеширование уже rendered script bodies не использовать;\n- не публиковать и не хешировать dynamic rendered inline values;\n- nonce оказался более подходящим механизмом для <script> elements.\n\n\n19. STYLE ATTRIBUTES\n\nБыло обнаружено:\n\n31 style attributes\n7 distinct style attribute values\n\nОни оказались стабильными между guest renders и не сработал secretish gate.\n\nНо removal style-src 'unsafe-inline' не включён в текущий завершённый scope.\n\nПричина:\n\nnonce работает для <style> / <script> elements, но не решает обычные style=\"\" attributes.\n\nПотенциальные будущие пути:\n- template/application refactor style attrs;\n- либо отдельный строго доказанный style-src-attr / 'unsafe-hashes' design.\n\nНа текущем production:\n\nReport-Only:\nstyle-src 'self' 'unsafe-inline';\n\nEnforcing style-src отсутствует.\n\n\n20. JAVASCRIPT: URLS\n\nИсходно:\n\n6 occurrences\n1 distinct value\nevent handlers = 0\n\nВ процессе анализа было решено:\n\nJAVASCRIPT_URL_DESIGN=\nAPPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST\n\nТо есть javascript: URL не закрывается текущей CSP-stage через hash allowlist.\n\nБыло выполнено source mapping на VM160 по:\n\n/var/www/forums/kingofwolk/internal_data/code_cache/templates\n/var/www/forums/kingofwolk/src/XF\n/var/www/forums/kingofwolk/public/js\n\nRaw javascript: value наружу не публиковался.\n\nТекущий статус:\n\ndeferred application refactor.\n\n\n21. EDGE NONCE DESIGN SEQUENCE И HARNESS FAILURES\n\nВо время разработки nonce-stage было несколько не product failures, которые важно не путать с production regressions.\n\nKOW-260907-A-INLINE3\n\nSTATUS=FAIL\nRC=23\n\nFailed step:\n\norigin_compression\n\nError:\n\norigin_compression:RC_2\n\nПричина:\n\nprobe зависел от Python внутри guest/runtime path\n\nProduct mutation:\n\nNO\n\n\nKOW-260907-A-INLINE4\n\nSTATUS=FAIL\nRC=23\n\nFailed step:\n\ntransform_and_hash_candidates\n\nError:\n\n'str' object has no attribute 'decode'\n\nПричина — чистый harness bug:\n- transformed HTML уже был str;\n- helper повторно вызывал .decode().\n\nДо падения подтвердилось:\n\nGit clean\nNPMplus healthy\nnginx sub module compiled = true\nnginx version = 1.31.2\n\nproxy_pass count = 1\norigin gzip = gzip\norigin identity = NONE\nHTTP = 200\n\n\nKOW-260907-A-INLINE5\n\nSTATUS=FAIL\nRC=23\n\nFailed step:\n\nnginx_nonce_syntax_probe_tempfile\n\nError:\n\nEPHEMERAL_PROBE_FILE_NOT_CLEANED\n\nЭто был слишком строгий/неправильный cleanup harness.\n\nProduct mutation не было.\n\n\nKOW-260907-A-INLINE6\n\nSTATUS=FAIL\nRC=23\n\nFailed step:\n\nvm160_jsurl_source_map\n\nError:\n\nqout() got an unexpected keyword argument 'timeout'\n\nОпять чистый harness bug.\n\nДо падения уже было подтверждено:\n\ndistinct javascript URLs = 1\ndistinct style attrs = 7\nevent handlers = 0\nGit clean\nproduction exact\nstale probe files = 0\n\n\nKOW-260907-A-INLINE7\n\nRecovery без повторения всего INLINE6.\n\nSTATUS=OK\nRC=0\n\nОн прочитал evidence INLINE6 и выполнил только недостающий VM160 source-map.\n\nDecision:\n\nHOLD_NONCE_STYLE_HASH_DESIGN\n\nНо единственный HOLD был:\n\nUPSTREAM_IDENTITY_PROBE_NOT_CLEAN\n\nПочему:\n\ndirect upstream ответил:\n\nHTTP=301\nContent-Encoding=NONE\n\nТо есть compression уже был в порядке; harness ошибочно требовал именно HTTP==200.\n\n\nKOW-260907-A-INLINE8\n\nИсправил probe с правильными forwarded headers.\n\nРезультат:\n\nSTATUS=OK\nRC=0\n\nFORWARDED_IDENTITY_200_OK=true\nFORWARDED_IDENTITY_TRANSPORT_OK=true\n\nHOLD_REASONS=[]\n\nDECISION=\nELIGIBLE_FOR_STAGED_SCRIPT_ELEM_NONCE_SOURCE_PREP\n\nЭто финально закрыло nonce design.\n\n\n22. LANE B — FONT/MEDIA\n\nLane B выполнил независимый read-only audit.\n\nAccepted checkpoint:\n\nKOW-260907-B-FONTMEDIA4\nSTATUS=OK\nRC=0\n\nDECISION=\nELIGIBLE_FOR_FONT_MEDIA_SOURCE_PREP\n\nПодтверждено:\n\nnative media attachments = 0\nXFMG media = 0\n[MEDIA] content = 0\nboard URL = https://kingofwolk.ru\n\nПри этом наличие media-site capability не считалось само по себе runtime requirement.\n\nLane B после этого прекратил новые checkpoints.\n\nLane A стал единственным writer.\n\n\n23. FONT/MEDIA FINAL POLICY\n\nПосле независимого Lane B audit и дополнительной precommit verification были enforcing добавлены:\n\nfont-src 'self' data:;\nmedia-src 'self';\n\nНа финальном canary:\n\nFONT_ORIGINS=[]\n\nдля непосредственно проверенных текущих страниц, то есть никаких новых внешних font runtime origins выявлено не было.\n\nРанее dedicated audit уже доказал допустимость SELF/DATA.\n\nRendered media origins также не потребовали дополнительного allow-list.\n\n\n24. KOW-260907-A-STAGE1 — GIT PROMOTION\n\nКоманда:\n\nKOW-260907-A-STAGE1\nMODE=change\nCOMPONENT=kingofwolk-csp-font-media-nonce-stage-git-promotion\n\nВыполнила перед mutation:\n\n- exact Git branch/head/remote/clean checks;\n- production old edge SHA check;\n- CSP log baseline;\n- NPMplus health;\n- font/media audit;\n- route checks;\n- DB read-only media checks;\n- repository validation;\n- unit tests.\n\nПосле этого построила exact candidate:\n\n1. enforcement:\n\n+ font-src 'self' data:;\n+ media-src 'self';\n\n2. Report-Only:\n\n+ script-src-elem 'self' 'nonce-$request_id' https://challenges.cloudflare.com;\n\n3. transport:\n\nproxy_set_header Accept-Encoding \"\";\nsub_filter_once off;\nsub_filter '<script' '<script nonce=\"$request_id\"';\n\nНе менялись:\n\nstyle-src 'unsafe-inline'\nbase script-src 'unsafe-inline'\njavascript: behavior\n\nRepository validation:\n\nPASS\n\nTests:\n\nPASS\n\nНовый Git HEAD:\n\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\n\nНовый source SHA:\n\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nLocal == remote.\n\nВо время Stage1 production оставался на старом SHA.\n\n\n25. KOW-260907-A-STAGE2 — PRODUCTION APPLY\n\nKOW-260907-A-STAGE2\nSTATUS=OK\nRC=0\nMODE=change\nRESULT_CONTRACT_STATUS=OK\nCHANGES_MADE=true\n\nПеред apply были проверены:\n\nrunner SHA\nGit branch/head/parent/message\nsource exact bytes\nproduction old SHA\nlive file owner/mode\nCSP log baseline\nNPMplus health\nnginx -t\npublic old CSP exact\n\nRunner baseline SHA:\n\nb248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d\n\nStage2 sequence:\n\nPhase 1 — APPLY1\n\nУстановлен новый candidate.\n\nПроверены:\n- nginx -t;\n- reload;\n- routes;\n- exact enforcing CSP;\n- dynamic Report-Only nonce;\n- каждый HTML <script> получил nonce;\n- nonce == header nonce;\n- CSP log baseline;\n- NPMplus health.\n\nPhase 2 — ROLLBACK PROOF\n\nProduction вернули к старому:\n\nfc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5\n\nПроверены old policies, routes, отсутствие nonce injection, health/log.\n\nRollback proof:\n\nPASS\n\nPhase 3 — FINAL REAPPLY\n\nCandidate применён повторно.\n\nФинальный edge:\n\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nGit не drifted.\n\nИтоговая reason:\n\nFONT_MEDIA_ENFORCED_AND_SCRIPT_ELEM_NONCE_REPORT_ONLY_STAGED_WITH_ROLLBACK_PROOF\n\n\n26. PRODUCTION ROLLBACK ARTIFACT\n\nStage2 создал manual rollback backup в NPMplus VM/container filesystem:\n\n/opt/npmplus/manual-backups/kingofwolk-csp-font-media-nonce-stage-20260907-6f584c5/\n\nOld config:\n\n996.conf.before\n\nOld SHA:\n\nfc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5\n\nТакже stage directory использовался для candidate copy.\n\nЕсли когда-либо потребуется ручной emergency rollback, сначала всё равно заново проверить текущий live state и не предполагать, что этот backup всё ещё является правильной rollback target после будущих изменений.\n\n\n27. KOW-260907-A-FINAL1 — ФИНАЛЬНЫЙ EVIDENCE CLOSURE\n\nSTATUS=OK\nRC=0\nMODE=read-only\nCHANGES_MADE=false\n\nDecision:\n\nKINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED\n\nClosure scope:\n\nAGREED_SAFE_CSP_STAGE\n\nПроверено:\n\n- Git exact local/remote/clean;\n- source SHA exact;\n- production edge SHA exact;\n- edge metadata 0:0:644;\n- CSP log baseline before;\n- NPMplus healthy;\n- nginx -t;\n- пять HTML routes по два запроса;\n- /service_worker.js;\n- /webmanifest.php;\n- exact security headers;\n- exact enforcing CSP;\n- exact dynamic Report-Only CSP;\n- unique nonce across requests;\n- nonce coherence для всех <script>;\n- JoyPixels rendered refs + HTTP sample;\n- fonts;\n- media origins;\n- Turnstile signal;\n- collector 405/POST;\n- CSP log baseline after;\n- production SHA after;\n- Git state after.\n\nФинал:\n\nNEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0\nNONCE_UNIQUE_ACROSS_ALL_REQUESTS=true\nNPMPLUS_HEALTH_AFTER=healthy\n\n\n28. CURRENT FINAL HASHES / IDENTIFIERS — БЫСТРЫЙ БЛОК\n\nGit repo:\n/root/kingofwolk-csp-baseline-20260907\n\nBranch:\nforum/kingofwolk-csp-baseline-20260907\n\nCurrent HEAD:\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\n\nParent:\n3e5d8584ae6887c292cc910c5796e73d8d20aecd\n\nCurrent source:\ndeploy/forums/kingofwolk/edge/996.conf\n\nCurrent source / production edge SHA:\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nPrevious production SHA:\nfc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5\n\nCSP report log SHA:\n2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143\n\nCSP report log lines:\n2\n\nRunner SHA:\nb248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d\n\n\n29. OPERATIONAL COMMAND CONTRACT\n\nЛюбая новая server command по этому проекту должна сначала читать:\n\n/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md\n/etc/pve/31_HOMELAB_REFERENCE.md\n\nИ visibly emit:\n\nERROR_REGISTER_CHECK=OK\nREFERENCE_CHECK=OK\nAUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED\n\nОбычно shell:\n\nroot@pve01:~#\n\nGit discipline:\n\nПеред любым change:\n\ngit status\nbranch\nlocal HEAD\nremote HEAD\n\nFail closed при неизвестном drift.\n\nДо commit:\n\ngit diff --check\n\nStaging:\n\ngit add <explicit path>\n\nНе использовать:\n\ngit add .\n\nПосле staging:\n\ngit diff --cached --check\ngit diff --cached\n\nПосле push:\n\ngit fetch\nLOCAL_HEAD == REMOTE_HEAD\n\nЗапрещённый destructive workflow:\n\ngit reset --hard\ngit clean -fd\nforce push\n\nТакже:\n\nне мутировать XenForo state raw SQL.\n\nRead-only SQL допустим.\n\n\n30. CONCURRENCY / LANE MODEL\n\nБыла доказана настоящая параллельность read-only lanes через direct bridge.\n\nCheckpoint:\n\nKOW-260907-PBR2\n\nDecision:\n\nALLOW_TRUE_PARALLEL_READ_ONLY_LANES\n\nПри этом обычные full runner carriers сериализуются из-за flock.\n\nCheckpoint:\n\nKOW-260907-CONC1\n\nПоэтому правила:\n\nread-only direct bridge:\nможет работать параллельно\n\nGit commit/push:\nровно один writer\n\nedge production apply:\nровно один writer\n\nXenForo/app mutation:\nровно один writer\n\nСейчас Lane B закончен.\n\nЕсли CSP scope снова открыть:\n\nтекущий чат/следующий оператор должен оставаться единственным writer.\n\n\n31. WINDOWS TRANSPORT\n\nСтандартный transport:\n\n.CMD\n -> Windows PowerShell 5.1\n -> Plink\n -> Pageant\n -> SSH pve01\n -> direct bridge или homelab-chat-run\n -> Gitea publication\n -> readback\n\nPuTTY:\n\nC:\\Program Files\\PuTTY\\plink.exe\nC:\\Program Files\\PuTTY\\pageant.exe\n\nPPK:\n\n<USERPROFILE>\\Documents\\ssh\\homelab_ed25519.ppk\n\nPPK encrypted:\naes256-cbc\n\n<REDACTED_SENSITIVE_LINE>\n\n<REDACTED_SENSITIVE_LINE>\n\nНормальный carrier:\n\nplink\n-batch\n-agent\n-ssh\n-P 22\n-m <remotePath>\nroot@<PRIVATE_IPV4>\n\nНе использовать обычным carrier:\n\n-i\n\nPageant должен уже держать unlocked key.\n\nEndpoint:\n\npve01\nroot@<PRIVATE_IPV4>:22\n\nPuTTY registry host key:\n\nHKCU:\\Software\\SimonTatham\\PuTTY\\SshHostKeys\n\nssh-ed25519@22:<PRIVATE_IPV4>\n\n\n32. CARRIER IMPLEMENTATION RULES\n\nPowerShell запускает Plink через:\n\nStart-Process\n\nОбязательно:\n\nseparate stdout\nseparate stderr\nreal ExitCode\n-NoNewWindow\n\nНе использовать:\n\npause\nnotepad\nMobaXterm\n\nTemp diagnostics:\n\n%TEMP%\n\nRemote command file:\n\nUTF-8\nno BOM\none physical line\nSHA verified\n\nБольшие payloads:\n\nzlib + base64\nSHA256 verification\n/usr/bin/python3 -I -B\n\nКаждый attempt:\n\nновый COMMAND_ID\n\nНе reuse failed ID.\n\nRunner:\n\n/usr/local/sbin/homelab-chat-run <CID> read-only|change <component> -- <command>\n\nChange result contract:\n\nOK\nBLOCKED\nFAIL\n\n\n33. GITEA RUNTIME PUBLICATION / READBACK\n\nRuntime repo:\n\nhomelab-admin/homelab-public-context\n\nBranch:\n\nassistant-runtime\n\nAuthoritative history path:\n\nruntime/history/<COMMAND_ID>.txt\n\nГлавное правило:\n\nне доверять generic latest без проверки COMMAND_ID.\n\nExact history конкретного command ID — authoritative.\n\nFast latest может вернуть unrelated/stale result.\n\n\n34. НЕДАВНИЕ .CMD ARTIFACTS ИЗ ЭТОГО ЧАТА\n\nПоследовательность последних команд:\n\nKOW-260907-A-INLINE2\nKOW_CSP_INLINE_HASH_FEASIBILITY_AUDIT_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE3\nKOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE4\nKOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY2_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE5\nKOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FINAL_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE6\nKOW_CSP_EDGE_NONCE_STYLE_HASH_JSURL_DESIGN_AUDIT_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE7\nKOW_CSP_INLINE6_RECOVERY_JSURL_SOURCE_MAP_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-INLINE8\nKOW_CSP_SCRIPT_ELEM_NONCE_DESIGN_CLOSURE_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-STAGE1\nKOW_CSP_FONT_MEDIA_NONCE_STAGE_GIT_PROMOTION_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-STAGE2\nKOW_CSP_FONT_MEDIA_NONCE_STAGE_PRODUCTION_PLINK_GITEA_MOSCOW.cmd\n\nKOW-260907-A-FINAL1\nKOW_CSP_FINAL_BROWSER_EVIDENCE_CLOSURE_PLINK_GITEA_MOSCOW.cmd\n\nНе нужно повторно запускать их для «проверки», если нет новой причины.\nПри новом scope создавать новые IDs.\n\n\n35. ЧТО НЕ НАДО ПОВТОРЯТЬ СЛЕДУЮЩЕМУ ОПЕРАТОРУ\n\nУже закрыто, не делать заново без новой фактической причины:\n\nform-action audit\nPWA worker/manifest audit\nimg-src audit\nconnect-src audit\nframe-src audit\nJoyPixels path audit\nTurnstile static-script search\nfont-src/media-src discovery\ninline hash feasibility\nnonce feasibility\nupstream identity/compression feasibility\nfont/media Git promotion\nfont/media production promotion\nrollback proof\nfinal browser/evidence closure\n\nОсобенно:\n\nне повторять KOW-260907-B-FONTMEDIA4.\n\nLane B audit уже accepted.\n\n\n36. ЧТО ОСТАЁТСЯ ТОЛЬКО КАК FUTURE HARDENING\n\nЭто не незавершённые команды текущего counter.\n\nТекущий counter:\n\n0\n\nFuture scope можно открыть отдельно.\n\n\nA. script-src-elem enforcement\n\nСейчас:\n\nReport-Only only\n\nNonce транспорт уже работает и финально доказан.\n\nСледующий возможный будущий шаг — только после периода наблюдения CSP report log:\n\nперенести script-src-elem nonce policy в enforcing\n\nНо до этого надо заново проверить:\n\n- log не накопил реальные violations;\n- authenticated flows;\n- Turnstile;\n- admin/Account overlays, если входят в scope;\n- все важные XenForo page types.\n\n\nB. style-src 'unsafe-inline'\n\nСейчас всё ещё Report-Only:\n\nstyle-src 'self' 'unsafe-inline'\n\nПричина — style attributes.\n\nИсходное evidence:\n\n31 occurrences\n7 distinct values\n\nНужен отдельный application/template design.\n\n\nC. javascript: refactor\n\nЕсть:\n\n1 distinct javascript: value\nнесколько rendered occurrences\n\nНе разрешать это через сомнительный hash workaround.\n\nПринятый design:\n\nAPPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST\n\n\n37. ТОЧКА СТАРТА ДЛЯ НОВОГО ЧАТА\n\nНовый чат должен считать canonical current state следующим:\n\nKINGOFWOLK CSP AGREED SAFE STAGE = CLOSED\n\nshared counter = 0\n\ncurrent Git HEAD:\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\n\ncurrent edge/source SHA:\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nCSP log SHA:\n2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143\n\nCSP log lines:\n2\n\nNPMplus:\nhealthy\n\nfinal accepted checkpoint:\nKOW-260907-A-FINAL1\n\nfinal decision:\nKINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED\n\nЕсли пользователь просит «продолжить CSP», сначала надо считать, что открывается новый hardening scope, потому что прежний shared counter уже завершён.\n\nНаиболее логичный новый порядок:\n\n1. read-only observation / authenticated coverage для script-src-elem Report-Only\n2. javascript: application refactor design\n3. style attribute refactor/hash design\n4. только затем новый enforcement promotion с отдельным rollback proof\n\nНо ничего из этого не является долгом старого плана.\n\n\n38. КОРОТКАЯ ИТОГОВАЯ ФОРМУЛИРОВКА ДЛЯ ВСТАВКИ В НОВЫЙ ЧАТ\n\nKingOfWolk CSP agreed safe stage полностью закрыт. Shared counter = 0.\nФинальный accepted checkpoint KOW-260907-A-FINAL1, STATUS=OK, RC=0,\nDECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED.\n\nТекущий Git HEAD:\n6f584c5faa76d0067b08977aeb583fa0e4de14aa\n\nBranch:\nforum/kingofwolk-csp-baseline-20260907\n\nWorktree:\n/root/kingofwolk-csp-baseline-20260907\n\nSource:\ndeploy/forums/kingofwolk/edge/996.conf\n\nSource/production edge SHA:\n458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e\n\nCSP log baseline SHA:\n2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143\n\nCSP log:\n2 строки\n\nНовых reports после финального browser-canary:\n0\n\nNPMplus:\nhealthy\n\nEnforcing сейчас закрывает:\nbase-uri\nobject-src\nframe-ancestors\nform-action\nworker-src\nmanifest-src\nimg-src\nconnect-src\nframe-src\nfont-src\nmedia-src\n\nscript-src-elem с per-request $request_id nonce находится только в Report-Only.\nNginx добавляет тот же nonce каждому <script> через sub_filter.\nФинальный canary доказал unique nonce per request и полную header↔script coherence.\n\nstyle-src 'unsafe-inline', существующий javascript: URL и перевод script-src-elem в enforcing оставлены как отдельный будущий hardening scope.\n\nLane B KOW-260907-B-FONTMEDIA4 accepted и не должен повторяться.\n\nЛюбой новый change:\n- один writer;\n- authority checks;\n- exact Git/source/edge/log prestate;\n- explicit staging;\n- rollback proof;\n- новый COMMAND_ID.\n\n\n39. ОСНОВНЫЕ AUTHORITATIVE EVIDENCE URLS\n\nФинальный evidence:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-FINAL1.txt\n\nProduction apply:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE2.txt\n\nGit promotion:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE1.txt\n\nLane B font/media:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-B-FONTMEDIA4.txt\n\nNonce design closure:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE8.txt\n\nInline feasibility:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE2.txt\n\nImg/connect/frame final recovery:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF6.txt\n\nImg/connect/frame production:\nhttps://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF4.txt\n\nCANDIDATE_LAYER_1_TEXT_END\nCANDIDATE_LAYER_2_SOURCE=file:newfi-54-portal200-apply3.cmd::b64::zlib\nCANDIDATE_LAYER_2_SHA256=97a1807a275e5d309591f7a7e044d7d56ba5b2f4c6a068df6353bae147707184\nCANDIDATE_LAYER_2_BYTES=34155\nCANDIDATE_LAYER_2_TEXT_BEGIN\nimport hashlib\nimport json\nimport pathlib\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nimport xml.etree.ElementTree as ET\n\nCID = \"NEWFI-260907-PORTAL200-APPLY3\"\nBRANCH = \"implementation/newfi-closed-mvp-20260906\"\nOLD_HEAD = \"06bab5acd124e768f70305b3d7eca2f595871890\"\nWT = pathlib.Path(\"/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906\")\nCAND_ROOT = pathlib.Path(\"/root/newfi-portal200-candidate/src/addons/Newfi/Portal\")\nPORTAL_REL = pathlib.Path(\"src/addons/Newfi/Portal\")\nGIT_PORTAL = WT / PORTAL_REL\nEXPECTED_NEW = {\n \"Pub/Controller/Portal.php\": (363, \"619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d\"),\n \"Service/PortalData.php\": (5213, \"4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d\"),\n \"_data/templates.xml\": (10977, \"1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861\"),\n}\nEXPECTED_OLD = {\n \"Pub/Controller/Portal.php\": (247, \"53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3\"),\n \"_data/templates.xml\": (1461, \"c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\"),\n}\nEXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]\nBACKUP = f\"/root/newfi-portal200-backup-{CID}\"\n\n\nclass PortalError(RuntimeError):\n pass\n\n\ndef emit(key, value=None):\n if value is None:\n print(key, flush=True)\n elif isinstance(value, (dict, list, tuple, bool, int, float)):\n print(\n key + \"=\" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\")),\n flush=True,\n )\n else:\n print(key + \"=\" + str(value), flush=True)\n\n\ndef fail(code):\n raise PortalError(code)\n\n\ndef sha_bytes(data):\n return hashlib.sha256(data).hexdigest()\n\n\ndef file_meta(path):\n data = pathlib.Path(path).read_bytes()\n return len(data), sha_bytes(data)\n\n\ndef run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):\n try:\n p = subprocess.run(\n cmd,\n input=input_bytes,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n timeout=timeout,\n check=False,\n )\n except subprocess.TimeoutExpired:\n fail(\"SUBPROCESS_TIMEOUT\")\n if p.returncode not in ok:\n fail(\"SUBPROCESS_RC\")\n return p\n\n\ndef git(*args, ok=(0,), timeout=120):\n p = run([\"/usr/bin/git\", \"-C\", str(WT), *args], timeout=timeout, ok=ok)\n return p.stdout.decode(\"utf-8\", \"replace\").strip()\n\n\ndef qga(target, argv, *, input_bytes=None, timeout=180):\n if target == \"prod\":\n cmd = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"210\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n cmd += [\"--pass-stdin\", \"1\"]\n cmd += [\"--\", *argv]\n elif target == \"staging\":\n q = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"211\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n q += [\"--pass-stdin\", \"1\"]\n q += [\"--\", *argv]\n remote = (\n \"test \\\"$(id -u):$(hostname -s)\\\" = 0:pve03 || exit 72; \"\n \"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do \"\n \"test -r \\\"$f\\\" && test -s \\\"$f\\\" && cat -- \\\"$f\\\" >/dev/null || exit 73; done; exec \"\n + \" \".join(shlex.quote(x) for x in q)\n )\n cmd = [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"-o\",\n \"ConnectionAttempts=1\",\n \"root@pve03\",\n remote,\n ]\n else:\n fail(\"BAD_TARGET\")\n\n p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)\n try:\n data = json.loads(p.stdout.decode(\"utf-8\"))\n except Exception:\n fail(\"QGA_JSON\")\n if data.get(\"exited\") not in (1, True) or data.get(\"exitcode\") != 0:\n fail(\"QGA_GUEST_RC\")\n if data.get(\"out-truncated\") or data.get(\"err-truncated\"):\n fail(\"QGA_TRUNCATED\")\n return data.get(\"out-data\", \"\")\n\n\nINSPECT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$db = $app->db();\nfunction srcMeta(string $path): ?array {\n if (!is_file($path)) { return null; }\n return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];\n}\n$portal = $root . '/src/addons/Newfi/Portal';\n$templates = $db->fetchAll(\n \"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, \" .\n \"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, \" .\n \"LOCATE('--newfi-portal200', template) > 0 AS css_marker \" .\n \"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title\"\n);\n$result = [\n 'hostname' => gethostname(),\n 'application' => [\n 'boardActive' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardActive'\"),\n 'boardUrl' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardUrl'\"),\n 'indexRoute' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='indexRoute'\"),\n 'defaultStyleId' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'\"),\n 'error_count' => (int)$db->fetchOne(\"SELECT COUNT(*) FROM xf_error_log\"),\n 'error_max_id' => (int)$db->fetchOne(\"SELECT COALESCE(MAX(error_id),0) FROM xf_error_log\")\n ],\n 'addon' => $db->fetchRow(\n \"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action \" .\n \"FROM xf_addon WHERE addon_id='Newfi/Portal'\"\n ),\n 'source' => [\n 'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),\n 'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),\n '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')\n ],\n 'templates' => $templates\n];\necho \"PORTAL200_INSPECT_JSON_BEGIN\\n\";\necho json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\necho \"\\nPORTAL200_INSPECT_JSON_END\\n\";\n?>'''\n\n\nIMPORT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$addOn = $app->addOnManager()->getById('Newfi/Portal');\nif (!$addOn || !$addOn->isInstalled()) {\n fwrite(STDERR, \"ADDON_NOT_INSTALLED\\n\");\n exit(30);\n}\n$addOn->preRebuild();\n$dataManager = $app->addOnDataManager();\n$imported = [];\nforeach ($dataManager->getDataTypeClasses() as $dataType) {\n $handler = $dataManager->getDataTypeHandler($dataType);\n $xml = $handler->openTypeFile($addOn->getDataDirectory());\n if (!$xml || !$xml->count()) {\n $handler->deleteAddOnData($addOn->addon_id);\n continue;\n }\n $start = 0;\n $guard = 0;\n while (true) {\n $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);\n if (!$last) { break; }\n if ($last <= $start || ++$guard > 100000) {\n fwrite(STDERR, \"IMPORT_PROGRESS_INVALID\\n\");\n exit(31);\n }\n $start = $last;\n }\n $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);\n \\XF::triggerRunOnce();\n $imported[] = $handler->getContainerTag();\n}\n$addOn->postDataImport();\n$repo = $app->repository(\\XF\\Repository\\StyleRepository::class);\n$repo->updateAllStylesLastModifiedDateLater();\n$addOn->postRebuild();\necho \"PORTAL200_IMPORT_JSON=\" . json_encode(\n ['result'=>'PASS','containers'=>$imported],\n JSON_UNESCAPED_SLASHES\n) . \"\\n\";\n?>'''\n\n\nERRORS_PHP_TEMPLATE = r'''<?php\n$root='/var/www/forums/newfi/public';\nrequire $root.'/src/XF.php';\n\\XF::start($root);\n$app=\\XF::setupApp('XF\\\\Pub\\\\App');\n$db=$app->db();\n$b=%d;\n$rows=$db->fetchAll(\n \"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id\",\n [$b]\n);\necho json_encode(\n $rows,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\n?>'''\n\n\nRUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php\n$state = '__STATE__';\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$result = ['state'=>$state];\nif ($state === 'new') {\n $service = new \\Newfi\\Portal\\Service\\PortalData($app);\n $portal = $service->build();\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);\n $result['sections'] = [];\n foreach (['forums','resources','media','events','dogs'] as $key) {\n $section = $portal[$key] ?? null;\n if (!is_array($section)) { fwrite(STDERR, \"SECTION_MISSING\\n\"); exit(60); }\n $result['sections'][$key] = [\n 'available'=>(bool)($section['available'] ?? false),\n 'hasItems'=>(bool)($section['hasItems'] ?? false),\n 'status'=>(string)($section['status'] ?? '')\n ];\n }\n $result['html_marker'] = strpos($html, 'data-newfi-portal200=\"1\"') !== false;\n $result['locked_resources'] = strpos($html, 'data-portal-locked=\"resources\"') !== false;\n $result['locked_media'] = strpos($html, 'data-portal-locked=\"media\"') !== false;\n $result['html_bytes'] = strlen($html);\n if (!$result['html_marker']) { fwrite(STDERR, \"HTML_MARKER_MISSING\\n\"); exit(61); }\n if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {\n fwrite(STDERR, \"GUEST_PERMISSION_CONTRACT_CHANGED\\n\"); exit(62);\n }\n if (!$result['locked_resources'] || !$result['locked_media']) {\n fwrite(STDERR, \"LOCKED_COPY_MISSING\\n\"); exit(63);\n }\n if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {\n fwrite(STDERR, \"PUBLIC_SECTION_UNAVAILABLE\\n\"); exit(64);\n }\n} else if ($state === 'old') {\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);\n $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;\n $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;\n $result['html_bytes'] = strlen($html);\n if ($result['html_marker'] || !$result['old_copy']) {\n fwrite(STDERR, \"OLD_RENDER_CONTRACT\\n\"); exit(65);\n }\n} else {\n exit(66);\n}\necho \"PORTAL200_RUNTIME_JSON=\" . json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n) . \"\\n\";\n?>'''\n\n\nHTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse\nmode=sys.argv[1]\nstate=sys.argv[2]\nif mode=='staging':\n host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503\nelif mode=='prod':\n host='newfi.ru'; scheme='https'; port='443'; expected_status=200\nelse:\n raise SystemExit(40)\ndef fetch(url):\n cmd=[\n '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10'\n ]\n if mode=='staging':\n cmd += ['--resolve',f'{host}:{port}:127.0.0.1']\n cmd += [\n '-o','-','-w','\\n__PORTAL_STATUS__%{http_code}',url\n ]\n p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)\n if p.returncode!=0: raise SystemExit(41)\n text=p.stdout.decode('utf-8','replace')\n marker='\\n__PORTAL_STATUS__'\n if marker not in text: raise SystemExit(42)\n body,status=text.rsplit(marker,1)\n return int(status.strip()),body\nroot=f'{scheme}://{host}/'\nstatus,body=fetch(root)\nhrefs=[]\nfor raw in re.findall(r\"href=[\\\"']([^\\\"']*css\\.php[^\\\"']*)[\\\"']\",body,re.I):\n raw=html.unescape(raw)\n hrefs.append(urllib.parse.urljoin(root,raw))\nnormal_css=[]\nnormal_status=[]\nfor u in hrefs:\n s,b=fetch(u)\n normal_status.append(s)\n normal_css.append(b)\nnormal_css_text='\\n'.join(normal_css)\nforced_css_marker=None\nforced_css_status=None\nif mode=='staging' and state=='new' and hrefs:\n u=urllib.parse.urlsplit(hrefs[0])\n pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)\n out=[]\n found=False\n for k,v in pairs:\n if k=='css':\n found=True\n parts=[x for x in v.split(',') if x]\n if 'public:newfi_portal.less' not in parts:\n parts.append('public:newfi_portal.less')\n v=','.join(parts)\n out.append((k,v))\n if found:\n forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))\n forced_css_status,forced_body=fetch(forced)\n forced_css_marker='--newfi-portal200' in forced_body\nresult={\n 'status':status,\n 'bytes':len(body.encode()),\n 'html_marker':'data-newfi-portal200=\"1\"' in body,\n 'css_marker':'--newfi-portal200' in normal_css_text,\n 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,\n 'css_count':len(hrefs),\n 'css_status':normal_status,\n 'forced_css_status':forced_css_status,\n 'forced_css_marker':forced_css_marker,\n}\nprint('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))\nif status!=expected_status: raise SystemExit(45)\nif mode=='prod':\n if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)\nelif mode=='staging':\n if any(x!=200 for x in normal_status): raise SystemExit(46)\nelse:\n raise SystemExit(50)\nif mode=='prod' and state=='new':\n if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)\nelif mode=='prod' and state=='old':\n if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)\nelif mode=='staging' and state=='new':\n if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)\nelif mode=='staging' and state=='old':\n pass\nelse:\n raise SystemExit(50)\n'''\n\n\ndef parse_inspect(text):\n b = \"PORTAL200_INSPECT_JSON_BEGIN\\n\"\n e = \"\\nPORTAL200_INSPECT_JSON_END\"\n if b not in text or e not in text:\n fail(\"INSPECT_MARKERS\")\n try:\n return json.loads(text.split(b, 1)[1].split(e, 1)[0])\n except Exception:\n fail(\"INSPECT_JSON\")\n\n\ndef inspect(target):\n return parse_inspect(\n qga(target, [\"/usr/bin/php8.3\"], input_bytes=INSPECT_PHP.encode(), timeout=120)\n )\n\n\ndef source_is_old(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_OLD.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return src.get(\"Service/PortalData.php\") is None\n\n\ndef source_is_new(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_NEW.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return True\n\n\ndef template_state_new(data):\n rows = data.get(\"templates\", [])\n rows0 = [r for r in rows if int(r.get(\"style_id\", -1)) == 0]\n titles = [r.get(\"title\") for r in rows0]\n by = {r.get(\"title\"): r for r in rows0}\n return (\n titles == [\"newfi_portal\", \"newfi_portal.less\"]\n and int(by[\"newfi_portal\"].get(\"html_marker\", 0)) == 1\n and int(by[\"newfi_portal.less\"].get(\"css_marker\", 0)) == 1\n )\n\n\ndef app_contract(data, target):\n a = data.get(\"application\", {})\n addon = data.get(\"addon\") or {}\n expected = {\n \"staging\": (\"0\", \"http://newfi-staging.gram1.ru\"),\n \"prod\": (\"1\", \"https://newfi.ru\"),\n }[target]\n return (\n a.get(\"boardActive\") == expected[0]\n and a.get(\"boardUrl\") == expected[1]\n and a.get(\"indexRoute\") == \"portal/\"\n and a.get(\"defaultStyleId\") == \"4\"\n and addon.get(\"addon_id\") == \"Newfi/Portal\"\n and int(addon.get(\"version_id\", 0)) == 1000010\n and addon.get(\"version_string\") == \"1.0.0 Alpha 1\"\n and int(addon.get(\"active\", 0)) == 1\n and int(addon.get(\"is_processing\", 1)) == 0\n )\n\n\ndef new_errors(target, baseline):\n raw = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),\n timeout=90,\n )\n try:\n return json.loads(raw)\n except Exception:\n fail(\"ERROR_LOG_JSON\")\n\n\ndef assert_no_new_errors(target, baseline, phase):\n rows = new_errors(target, baseline)\n if rows:\n emit(\n \"PORTAL200_NEW_XF_ERRORS\",\n {\n \"target\": target,\n \"phase\": phase,\n \"count\": len(rows),\n \"ids\": [r.get(\"error_id\") for r in rows],\n },\n )\n fail(\"NEW_XF_ERRORS\")\n\n\ndef guest_backup(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest ! -e \"$B\"\ninstall -d -m 700 \"$B/Pub/Controller\" \"$B/_data\"\ncp -p -- \"$P/Pub/Controller/Portal.php\" \"$B/Pub/Controller/Portal.php\"\ncp -p -- \"$P/_data/templates.xml\" \"$B/_data/templates.xml\"\ntest ! -e \"$P/Service/PortalData.php\"\nprintf 'PORTAL200_BACKUP=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_BACKUP=PASS\" not in out:\n fail(\"BACKUP_FAILED\")\n\n\ndef guest_write(target, rel, data):\n dest = f\"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}\"\n parent = str(pathlib.PurePosixPath(dest).parent)\n script = (\n \"set -eu; umask 022; \"\n + \"install -d -m 755 \"\n + shlex.quote(parent)\n + \"; T=$(mktemp \"\n + shlex.quote(parent + \"/.portal200.XXXXXX\")\n + \"); cat >\\\"$T\\\"; chmod 0644 \\\"$T\\\"; chown root:root \\\"$T\\\"; \"\n + \"mv -f -- \\\"$T\\\" \"\n + shlex.quote(dest)\n + \"; printf 'PORTAL200_WRITE=PASS\\\\n'\"\n )\n out = qga(\n target,\n [\"/bin/bash\", \"-lc\", script],\n input_bytes=data,\n timeout=90,\n )\n if \"PORTAL200_WRITE=PASS\" not in out:\n fail(\"WRITE_FAILED\")\n\n\ndef install_candidate(target):\n for rel in [\n \"Pub/Controller/Portal.php\",\n \"Service/PortalData.php\",\n \"_data/templates.xml\",\n ]:\n guest_write(target, rel, (CAND_ROOT / rel).read_bytes())\n\n\ndef restore_old(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest -f \"$B/Pub/Controller/Portal.php\"\ntest -f \"$B/_data/templates.xml\"\ncp -p -- \"$B/Pub/Controller/Portal.php\" \"$P/Pub/Controller/Portal.php\"\ncp -p -- \"$B/_data/templates.xml\" \"$P/_data/templates.xml\"\nrm -f -- \"$P/Service/PortalData.php\"\nrmdir -- \"$P/Service\" 2>/dev/null || true\nprintf 'PORTAL200_SOURCE_RESTORE=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_SOURCE_RESTORE=PASS\" not in out:\n fail(\"SOURCE_RESTORE_FAILED\")\n\n\ndef import_addon(target):\n out = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=IMPORT_PHP.encode(),\n timeout=240,\n )\n if \"PORTAL200_IMPORT_JSON=\" not in out:\n fail(\"IMPORT_FAILED\")\n\n\ndef runtime_verify(target, state):\n src = RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", state)\n out = qga(target, [\"/usr/bin/php8.3\"], input_bytes=src.encode(), timeout=120)\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_RUNTIME_JSON=\")),\n None,\n )\n if not line:\n fail(\"RUNTIME_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"RUNTIME_JSON\")\n emit(\"PORTAL200_RUNTIME\", {\"target\": target, **data})\n return data\n\n\ndef http_verify(target, state):\n argv = [\"/usr/bin/python3\", \"-\", target, state]\n if target == \"prod\":\n try:\n p = subprocess.run(\n argv,\n input=HTTP_VERIFY_PY.encode(),\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n timeout=180,\n check=False,\n )\n except subprocess.TimeoutExpired:\n fail(\"HTTP_PROD_TIMEOUT\")\n out = p.stdout.decode(\"utf-8\", \"replace\")\n err = p.stderr.decode(\"utf-8\", \"replace\")\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_HTTP_JSON=\")),\n None,\n )\n data = None\n if line:\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n emit(\"PORTAL200_HTTP\", {\"target\": target, \"state\": state, **data})\n except Exception:\n fail(\"HTTP_JSON\")\n if p.returncode != 0:\n emit(\n \"PORTAL200_HTTP_EXEC\",\n {\n \"target\": target,\n \"state\": state,\n \"rc\": p.returncode,\n \"stderr\": err[:800],\n },\n )\n fail(\"HTTP_PROD_RC\")\n if data is None:\n fail(\"HTTP_MARKER_MISSING\")\n return data\n\n out = qga(\n target,\n argv,\n input_bytes=HTTP_VERIFY_PY.encode(),\n timeout=180,\n )\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_HTTP_JSON=\")),\n None,\n )\n if not line:\n fail(\"HTTP_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"HTTP_JSON\")\n emit(\"PORTAL200_HTTP\", {\"target\": target, \"state\": state, **data})\n return data\n\n\ndef verify_new(target, baseline):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_NEW\")\n if not source_is_new(data):\n fail(\"SOURCE_NEW_MISMATCH\")\n if not template_state_new(data):\n fail(\"TEMPLATE_NEW_MISMATCH\")\n runtime_verify(target, \"new\")\n http_verify(target, \"new\")\n assert_no_new_errors(target, baseline, \"verify-new\")\n return inspect(target)\n\n\ndef verify_old(target, baseline, old_templates, strict_errors=True):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_OLD\")\n if not source_is_old(data):\n fail(\"SOURCE_OLD_MISMATCH\")\n if data.get(\"templates\") != old_templates:\n fail(\"TEMPLATE_ROLLBACK_MISMATCH\")\n if target == \"prod\":\n runtime_verify(target, \"old\")\n http_verify(target, \"old\")\n if strict_errors:\n assert_no_new_errors(target, baseline, \"verify-old\")\n return inspect(target)\n\n\ndef lint_php(target, source, label):\n out = qga(\n target,\n [\"/usr/bin/php8.3\", \"-l\"],\n input_bytes=source.encode(),\n timeout=90,\n )\n if \"No syntax errors detected\" not in out:\n fail(\"PHP_LINT_\" + label)\n\n\ndef cluster_guard():\n p = run([\"/usr/sbin/qm\", \"status\", \"210\"], timeout=30)\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM210_NOT_RUNNING\")\n p = run(\n [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"root@pve03\",\n \"/usr/sbin/qm status 211\",\n ],\n timeout=40,\n )\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM211_NOT_RUNNING\")\n emit(\"PORTAL200_CLUSTER\", \"PASS\")\n\n\ndef result_contract(status, changes, rollback_started, rollback_restored):\n emit(\n \"HOMELAB_RESULT_CONTRACT\",\n {\n \"version\": 1,\n \"command_id\": CID,\n \"status\": status,\n \"changes_made\": bool(changes),\n \"rollback_started\": bool(rollback_started),\n \"rollback_restored\": rollback_restored,\n },\n )\n\n\nemit(\"NEWFI200_BEGIN\")\nemit(\"COMMAND_ID\", CID)\nemit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE\", 22)\nemit(\n \"PORTAL200_FLOW\",\n \"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production\",\n)\n\nstaging_backup = False\nstaging_mutated = False\nstaging_final_new = False\nprod_backup = False\nprod_mutated = False\ngit_files_changed = False\ncommit_created = False\npush_confirmed = False\nfailure_rollback_started = False\nfailure_rollback_restored = None\nold_git_bytes = {}\nnew_head = None\ns_error = None\np_error = None\ns_old_templates = None\np_old_templates = None\n\ntry:\n compile(HTTP_VERIFY_PY, \"<portal200-http-verify>\", \"exec\")\n for rel, (size, sha) in EXPECTED_NEW.items():\n p = CAND_ROOT / rel\n if not p.is_file() or file_meta(p) != (size, sha):\n fail(\"CANDIDATE_IDENTITY\")\n tree = ET.parse(CAND_ROOT / \"_data/templates.xml\")\n titles = sorted(x.attrib.get(\"title\") for x in tree.getroot().findall(\"template\"))\n if titles != [\"newfi_portal\", \"newfi_portal.less\"]:\n fail(\"CANDIDATE_TEMPLATE_SET\")\n service = (CAND_ROOT / \"Service/PortalData.php\").read_text(\"utf-8\")\n if any(\n token in service\n for token in [\n \"\\\\XF::db(\",\n \"->save(\",\n \"->delete(\",\n \"INSERT \",\n \"UPDATE \",\n \"DELETE \",\n \"REPLACE \",\n ]\n ):\n fail(\"CANDIDATE_MUTATION_SCAN\")\n emit(\"PORTAL200_CANDIDATE_IDENTITY\", \"PASS\")\n emit(\n \"PORTAL200_ORCHESTRATION_FIXES\",\n {\n \"staging_503_without_css_links\": True,\n \"failure_status\": \"FAIL\",\n \"prod_http_via_pve01_public_route\": True,\n \"css_href_regex_fixed\": True,\n },\n )\n\n branch = git(\"branch\", \"--show-current\")\n head = git(\"rev-parse\", \"HEAD\")\n status = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote = remote_out.split()[0] if remote_out else \"\"\n if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:\n fail(\"GIT_PRESTATE\")\n emit(\n \"PORTAL200_GIT_PRESTATE\",\n {\"branch\": branch, \"head\": head, \"remote\": remote, \"clean\": True},\n )\n cluster_guard()\n\n lint_php(\"staging\", INSPECT_PHP, \"INSPECT_STAGING\")\n lint_php(\"staging\", IMPORT_PHP, \"IMPORT_STAGING\")\n lint_php(\"staging\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_STAGING\")\n lint_php(\"prod\", INSPECT_PHP, \"INSPECT_PROD\")\n lint_php(\"prod\", IMPORT_PHP, \"IMPORT_PROD\")\n lint_php(\"prod\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_PROD\")\n emit(\"PORTAL200_RUNTIME_HELPER_LINT\", \"PASS\")\n\n s0 = inspect(\"staging\")\n p0 = inspect(\"prod\")\n if not app_contract(s0, \"staging\") or not app_contract(p0, \"prod\"):\n fail(\"APP_PRESTATE\")\n if not source_is_old(s0) or not source_is_old(p0):\n fail(\"SOURCE_PRESTATE\")\n if s0.get(\"templates\") != p0.get(\"templates\"):\n fail(\"TEMPLATE_PRESTATE_PARITY\")\n s_error = int(s0[\"application\"][\"error_max_id\"])\n p_error = int(p0[\"application\"][\"error_max_id\"])\n s_old_templates = s0[\"templates\"]\n p_old_templates = p0[\"templates\"]\n emit(\"PORTAL200_ERROR_BASELINE\", {\"staging\": s_error, \"prod\": p_error})\n http_verify(\"staging\", \"old\")\n runtime_verify(\"prod\", \"old\")\n http_verify(\"prod\", \"old\")\n assert_no_new_errors(\"staging\", s_error, \"preflight\")\n assert_no_new_errors(\"prod\", p_error, \"preflight\")\n emit(\"PORTAL200_PREFLIGHT\", \"PASS\")\n\n guest_backup(\"staging\")\n staging_backup = True\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n emit(\"PORTAL200_STAGING_FIRST_APPLY\", \"PASS\")\n\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates)\n staging_mutated = False\n emit(\"PORTAL200_STAGING_ROLLBACK_PROOF\", \"PASS\")\n\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n staging_final_new = True\n emit(\"PORTAL200_STAGING_FINAL\", \"PASS\")\n\n for rel in EXPECTED_NEW:\n dest = GIT_PORTAL / rel\n old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None\n dest.parent.mkdir(parents=True, exist_ok=True)\n shutil.copyfile(CAND_ROOT / rel, dest)\n git_files_changed = True\n\n if git(\"diff\", \"--check\"):\n fail(\"GIT_DIFF_CHECK\")\n status_lines = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\").splitlines()\n actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)\n if actual_paths != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_SCOPE\")\n git(\"add\", \"--\", *EXPECTED_GIT_PATHS)\n staged = sorted(x for x in git(\"diff\", \"--cached\", \"--name-only\").splitlines() if x)\n if staged != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_STAGED_SCOPE\")\n git(\"commit\", \"-m\", f\"feat: build data-backed Newfi portal [{CID}]\")\n commit_created = True\n new_head = git(\"rev-parse\", \"HEAD\")\n if new_head == OLD_HEAD:\n fail(\"GIT_COMMIT_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"GIT_POSTCOMMIT_DIRTY\")\n git(\"push\", \"origin\", f\"HEAD:refs/heads/{BRANCH}\", timeout=180)\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote_head = remote_out.split()[0] if remote_out else \"\"\n if remote_head != new_head:\n fail(\"GIT_REMOTE_HEAD\")\n push_confirmed = True\n emit(\n \"PORTAL200_GIT_PROMOTION\",\n {\n \"old_head\": OLD_HEAD,\n \"new_head\": new_head,\n \"remote_head\": remote_head,\n \"result\": \"PASS\",\n },\n )\n\n p_pre = inspect(\"prod\")\n if not source_is_old(p_pre) or p_pre.get(\"templates\") != p_old_templates:\n fail(\"PROD_DRIFT_BEFORE_APPLY\")\n if int(p_pre[\"application\"][\"error_max_id\"]) != p_error:\n fail(\"PROD_ERROR_DRIFT\")\n guest_backup(\"prod\")\n prod_backup = True\n prod_mutated = True\n install_candidate(\"prod\")\n import_addon(\"prod\")\n verify_new(\"prod\", p_error)\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n\n sf = verify_new(\"staging\", s_error)\n pf = verify_new(\"prod\", p_error)\n if sf[\"source\"] != pf[\"source\"]:\n fail(\"FINAL_SOURCE_PARITY\")\n if sf[\"templates\"] != pf[\"templates\"]:\n fail(\"FINAL_TEMPLATE_PARITY\")\n if git(\"rev-parse\", \"HEAD\") != new_head:\n fail(\"FINAL_LOCAL_HEAD\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n if not remote_out or remote_out.split()[0] != new_head:\n fail(\"FINAL_REMOTE_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"FINAL_GIT_DIRTY\")\n cluster_guard()\n emit(\n \"PORTAL200_FINAL_EVIDENCE\",\n {\n \"commit\": new_head,\n \"source_files\": 3,\n \"templates\": 2,\n \"staging_error_baseline\": s_error,\n \"prod_error_baseline\": p_error,\n },\n )\n emit(\"PORTAL200_PORTAL\", \"PASS\")\n emit(\"PORTAL200_STAGING\", \"PASS\")\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n emit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS\", 21)\n result_contract(\"OK\", True, False, None)\n emit(\"NEWFI200_END\")\n raise SystemExit(0)\n\nexcept PortalError as exc:\n code = str(exc)\n emit(\"PORTAL200_FAILURE\", code)\n\n if push_confirmed:\n if prod_mutated and prod_backup:\n failure_rollback_started = True\n try:\n restore_old(\"prod\")\n import_addon(\"prod\")\n verify_old(\"prod\", p_error, p_old_templates, strict_errors=False)\n prod_mutated = False\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"PASS_PARTIAL_OVERALL_STATE_CHANGED\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"FAIL\")\n else:\n if staging_mutated and staging_backup:\n failure_rollback_started = True\n try:\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates, strict_errors=False)\n staging_mutated = False\n staging_final_new = False\n failure_rollback_restored = True\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"PASS\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"FAIL\")\n\n if git_files_changed and not commit_created:\n try:\n for rel, old in old_git_bytes.items():\n dest = GIT_PORTAL / rel\n if old is None:\n if dest.exists():\n dest.unlink()\n else:\n dest.write_bytes(old)\n service_dir = GIT_PORTAL / \"Service\"\n try:\n service_dir.rmdir()\n except OSError:\n pass\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"PASS\")\n except Exception:\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"FAIL\")\n\n if commit_created:\n emit(\n \"PORTAL200_FAILURE_GIT_HISTORY\",\n \"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED\",\n )\n\n persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)\n if failure_rollback_started and failure_rollback_restored is None:\n failure_rollback_restored = False\n result_contract(\n \"FAIL\",\n persisted,\n failure_rollback_started,\n failure_rollback_restored,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nexcept Exception:\n emit(\"PORTAL200_FAILURE\", \"UNEXPECTED_EXCEPTION\")\n result_contract(\n \"FAIL\",\n bool(push_confirmed or commit_created or staging_final_new or prod_mutated),\n False,\n None,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nCANDIDATE_LAYER_2_TEXT_END\nCANDIDATE_LAYER_3_SOURCE=file:newfi-48-portal200-apply2.cmd::b64::zlib\nCANDIDATE_LAYER_3_SHA256=5e3aa56505b7d7ef5b7f0cf588f89d5e1c5f6b8bd9bffc13b8debdbdc5be26c7\nCANDIDATE_LAYER_3_BYTES=32663\nCANDIDATE_LAYER_3_TEXT_BEGIN\nimport hashlib\nimport json\nimport pathlib\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nimport xml.etree.ElementTree as ET\n\nCID = \"NEWFI-260907-PORTAL200-APPLY2\"\nBRANCH = \"implementation/newfi-closed-mvp-20260906\"\nOLD_HEAD = \"06bab5acd124e768f70305b3d7eca2f595871890\"\nWT = pathlib.Path(\"/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906\")\nCAND_ROOT = pathlib.Path(\"/root/newfi-portal200-candidate/src/addons/Newfi/Portal\")\nPORTAL_REL = pathlib.Path(\"src/addons/Newfi/Portal\")\nGIT_PORTAL = WT / PORTAL_REL\nEXPECTED_NEW = {\n \"Pub/Controller/Portal.php\": (363, \"619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d\"),\n \"Service/PortalData.php\": (5213, \"4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d\"),\n \"_data/templates.xml\": (10977, \"1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861\"),\n}\nEXPECTED_OLD = {\n \"Pub/Controller/Portal.php\": (247, \"53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3\"),\n \"_data/templates.xml\": (1461, \"c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\"),\n}\nEXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]\nBACKUP = f\"/root/newfi-portal200-backup-{CID}\"\n\n\nclass PortalError(RuntimeError):\n pass\n\n\ndef emit(key, value=None):\n if value is None:\n print(key, flush=True)\n elif isinstance(value, (dict, list, tuple, bool, int, float)):\n print(\n key + \"=\" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\")),\n flush=True,\n )\n else:\n print(key + \"=\" + str(value), flush=True)\n\n\ndef fail(code):\n raise PortalError(code)\n\n\ndef sha_bytes(data):\n return hashlib.sha256(data).hexdigest()\n\n\ndef file_meta(path):\n data = pathlib.Path(path).read_bytes()\n return len(data), sha_bytes(data)\n\n\ndef run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):\n try:\n p = subprocess.run(\n cmd,\n input=input_bytes,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n timeout=timeout,\n check=False,\n )\n except subprocess.TimeoutExpired:\n fail(\"SUBPROCESS_TIMEOUT\")\n if p.returncode not in ok:\n fail(\"SUBPROCESS_RC\")\n return p\n\n\ndef git(*args, ok=(0,), timeout=120):\n p = run([\"/usr/bin/git\", \"-C\", str(WT), *args], timeout=timeout, ok=ok)\n return p.stdout.decode(\"utf-8\", \"replace\").strip()\n\n\ndef qga(target, argv, *, input_bytes=None, timeout=180):\n if target == \"prod\":\n cmd = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"210\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n cmd += [\"--pass-stdin\", \"1\"]\n cmd += [\"--\", *argv]\n elif target == \"staging\":\n q = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"211\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n q += [\"--pass-stdin\", \"1\"]\n q += [\"--\", *argv]\n remote = (\n \"test \\\"$(id -u):$(hostname -s)\\\" = 0:pve03 || exit 72; \"\n \"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do \"\n \"test -r \\\"$f\\\" && test -s \\\"$f\\\" && cat -- \\\"$f\\\" >/dev/null || exit 73; done; exec \"\n + \" \".join(shlex.quote(x) for x in q)\n )\n cmd = [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"-o\",\n \"ConnectionAttempts=1\",\n \"root@pve03\",\n remote,\n ]\n else:\n fail(\"BAD_TARGET\")\n\n p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)\n try:\n data = json.loads(p.stdout.decode(\"utf-8\"))\n except Exception:\n fail(\"QGA_JSON\")\n if data.get(\"exited\") not in (1, True) or data.get(\"exitcode\") != 0:\n fail(\"QGA_GUEST_RC\")\n if data.get(\"out-truncated\") or data.get(\"err-truncated\"):\n fail(\"QGA_TRUNCATED\")\n return data.get(\"out-data\", \"\")\n\n\nINSPECT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$db = $app->db();\nfunction srcMeta(string $path): ?array {\n if (!is_file($path)) { return null; }\n return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];\n}\n$portal = $root . '/src/addons/Newfi/Portal';\n$templates = $db->fetchAll(\n \"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, \" .\n \"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, \" .\n \"LOCATE('--newfi-portal200', template) > 0 AS css_marker \" .\n \"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title\"\n);\n$result = [\n 'hostname' => gethostname(),\n 'application' => [\n 'boardActive' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardActive'\"),\n 'boardUrl' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardUrl'\"),\n 'indexRoute' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='indexRoute'\"),\n 'defaultStyleId' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'\"),\n 'error_count' => (int)$db->fetchOne(\"SELECT COUNT(*) FROM xf_error_log\"),\n 'error_max_id' => (int)$db->fetchOne(\"SELECT COALESCE(MAX(error_id),0) FROM xf_error_log\")\n ],\n 'addon' => $db->fetchRow(\n \"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action \" .\n \"FROM xf_addon WHERE addon_id='Newfi/Portal'\"\n ),\n 'source' => [\n 'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),\n 'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),\n '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')\n ],\n 'templates' => $templates\n];\necho \"PORTAL200_INSPECT_JSON_BEGIN\\n\";\necho json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\necho \"\\nPORTAL200_INSPECT_JSON_END\\n\";\n?>'''\n\n\nIMPORT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$addOn = $app->addOnManager()->getById('Newfi/Portal');\nif (!$addOn || !$addOn->isInstalled()) {\n fwrite(STDERR, \"ADDON_NOT_INSTALLED\\n\");\n exit(30);\n}\n$addOn->preRebuild();\n$dataManager = $app->addOnDataManager();\n$imported = [];\nforeach ($dataManager->getDataTypeClasses() as $dataType) {\n $handler = $dataManager->getDataTypeHandler($dataType);\n $xml = $handler->openTypeFile($addOn->getDataDirectory());\n if (!$xml || !$xml->count()) {\n $handler->deleteAddOnData($addOn->addon_id);\n continue;\n }\n $start = 0;\n $guard = 0;\n while (true) {\n $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);\n if (!$last) { break; }\n if ($last <= $start || ++$guard > 100000) {\n fwrite(STDERR, \"IMPORT_PROGRESS_INVALID\\n\");\n exit(31);\n }\n $start = $last;\n }\n $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);\n \\XF::triggerRunOnce();\n $imported[] = $handler->getContainerTag();\n}\n$addOn->postDataImport();\n$repo = $app->repository(\\XF\\Repository\\StyleRepository::class);\n$repo->updateAllStylesLastModifiedDateLater();\n$addOn->postRebuild();\necho \"PORTAL200_IMPORT_JSON=\" . json_encode(\n ['result'=>'PASS','containers'=>$imported],\n JSON_UNESCAPED_SLASHES\n) . \"\\n\";\n?>'''\n\n\nERRORS_PHP_TEMPLATE = r'''<?php\n$root='/var/www/forums/newfi/public';\nrequire $root.'/src/XF.php';\n\\XF::start($root);\n$app=\\XF::setupApp('XF\\\\Pub\\\\App');\n$db=$app->db();\n$b=%d;\n$rows=$db->fetchAll(\n \"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id\",\n [$b]\n);\necho json_encode(\n $rows,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\n?>'''\n\n\nRUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php\n$state = '__STATE__';\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$result = ['state'=>$state];\nif ($state === 'new') {\n $service = new \\Newfi\\Portal\\Service\\PortalData($app);\n $portal = $service->build();\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);\n $result['sections'] = [];\n foreach (['forums','resources','media','events','dogs'] as $key) {\n $section = $portal[$key] ?? null;\n if (!is_array($section)) { fwrite(STDERR, \"SECTION_MISSING\\n\"); exit(60); }\n $result['sections'][$key] = [\n 'available'=>(bool)($section['available'] ?? false),\n 'hasItems'=>(bool)($section['hasItems'] ?? false),\n 'status'=>(string)($section['status'] ?? '')\n ];\n }\n $result['html_marker'] = strpos($html, 'data-newfi-portal200=\"1\"') !== false;\n $result['locked_resources'] = strpos($html, 'data-portal-locked=\"resources\"') !== false;\n $result['locked_media'] = strpos($html, 'data-portal-locked=\"media\"') !== false;\n $result['html_bytes'] = strlen($html);\n if (!$result['html_marker']) { fwrite(STDERR, \"HTML_MARKER_MISSING\\n\"); exit(61); }\n if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {\n fwrite(STDERR, \"GUEST_PERMISSION_CONTRACT_CHANGED\\n\"); exit(62);\n }\n if (!$result['locked_resources'] || !$result['locked_media']) {\n fwrite(STDERR, \"LOCKED_COPY_MISSING\\n\"); exit(63);\n }\n if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {\n fwrite(STDERR, \"PUBLIC_SECTION_UNAVAILABLE\\n\"); exit(64);\n }\n} else if ($state === 'old') {\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);\n $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;\n $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;\n $result['html_bytes'] = strlen($html);\n if ($result['html_marker'] || !$result['old_copy']) {\n fwrite(STDERR, \"OLD_RENDER_CONTRACT\\n\"); exit(65);\n }\n} else {\n exit(66);\n}\necho \"PORTAL200_RUNTIME_JSON=\" . json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n) . \"\\n\";\n?>'''\n\n\nHTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse\nmode=sys.argv[1]\nstate=sys.argv[2]\nif mode=='staging':\n host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503\nelif mode=='prod':\n host='newfi.ru'; scheme='https'; port='443'; expected_status=200\nelse:\n raise SystemExit(40)\ndef fetch(url):\n cmd=[\n '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',\n '--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',\n '\\n__PORTAL_STATUS__%{http_code}',url\n ]\n p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)\n if p.returncode!=0: raise SystemExit(41)\n text=p.stdout.decode('utf-8','replace')\n marker='\\n__PORTAL_STATUS__'\n if marker not in text: raise SystemExit(42)\n body,status=text.rsplit(marker,1)\n return int(status.strip()),body\nroot=f'{scheme}://{host}/'\nstatus,body=fetch(root)\nhrefs=[]\nfor raw in re.findall(r\"href=[\\\"']([^\\\"']*css\\\\.php[^\\\"']*)[\\\"']\",body,re.I):\n raw=html.unescape(raw)\n hrefs.append(urllib.parse.urljoin(root,raw))\nnormal_css=[]\nnormal_status=[]\nfor u in hrefs:\n s,b=fetch(u)\n normal_status.append(s)\n normal_css.append(b)\nnormal_css_text='\\n'.join(normal_css)\nforced_css_marker=None\nforced_css_status=None\nif mode=='staging' and state=='new' and hrefs:\n u=urllib.parse.urlsplit(hrefs[0])\n pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)\n out=[]\n found=False\n for k,v in pairs:\n if k=='css':\n found=True\n parts=[x for x in v.split(',') if x]\n if 'public:newfi_portal.less' not in parts:\n parts.append('public:newfi_portal.less')\n v=','.join(parts)\n out.append((k,v))\n if found:\n forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))\n forced_css_status,forced_body=fetch(forced)\n forced_css_marker='--newfi-portal200' in forced_body\nresult={\n 'status':status,\n 'bytes':len(body.encode()),\n 'html_marker':'data-newfi-portal200=\"1\"' in body,\n 'css_marker':'--newfi-portal200' in normal_css_text,\n 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,\n 'css_count':len(hrefs),\n 'css_status':normal_status,\n 'forced_css_status':forced_css_status,\n 'forced_css_marker':forced_css_marker,\n}\nprint('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))\nif status!=expected_status: raise SystemExit(45)\nif mode=='prod':\n if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)\nelif mode=='staging':\n if any(x!=200 for x in normal_status): raise SystemExit(46)\nelse:\n raise SystemExit(50)\nif mode=='prod' and state=='new':\n if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)\nelif mode=='prod' and state=='old':\n if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)\nelif mode=='staging' and state=='new':\n if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)\nelif mode=='staging' and state=='old':\n pass\nelse:\n raise SystemExit(50)\n'''\n\n\ndef parse_inspect(text):\n b = \"PORTAL200_INSPECT_JSON_BEGIN\\n\"\n e = \"\\nPORTAL200_INSPECT_JSON_END\"\n if b not in text or e not in text:\n fail(\"INSPECT_MARKERS\")\n try:\n return json.loads(text.split(b, 1)[1].split(e, 1)[0])\n except Exception:\n fail(\"INSPECT_JSON\")\n\n\ndef inspect(target):\n return parse_inspect(\n qga(target, [\"/usr/bin/php8.3\"], input_bytes=INSPECT_PHP.encode(), timeout=120)\n )\n\n\ndef source_is_old(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_OLD.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return src.get(\"Service/PortalData.php\") is None\n\n\ndef source_is_new(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_NEW.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return True\n\n\ndef template_state_new(data):\n rows = data.get(\"templates\", [])\n rows0 = [r for r in rows if int(r.get(\"style_id\", -1)) == 0]\n titles = [r.get(\"title\") for r in rows0]\n by = {r.get(\"title\"): r for r in rows0}\n return (\n titles == [\"newfi_portal\", \"newfi_portal.less\"]\n and int(by[\"newfi_portal\"].get(\"html_marker\", 0)) == 1\n and int(by[\"newfi_portal.less\"].get(\"css_marker\", 0)) == 1\n )\n\n\ndef app_contract(data, target):\n a = data.get(\"application\", {})\n addon = data.get(\"addon\") or {}\n expected = {\n \"staging\": (\"0\", \"http://newfi-staging.gram1.ru\"),\n \"prod\": (\"1\", \"https://newfi.ru\"),\n }[target]\n return (\n a.get(\"boardActive\") == expected[0]\n and a.get(\"boardUrl\") == expected[1]\n and a.get(\"indexRoute\") == \"portal/\"\n and a.get(\"defaultStyleId\") == \"4\"\n and addon.get(\"addon_id\") == \"Newfi/Portal\"\n and int(addon.get(\"version_id\", 0)) == 1000010\n and addon.get(\"version_string\") == \"1.0.0 Alpha 1\"\n and int(addon.get(\"active\", 0)) == 1\n and int(addon.get(\"is_processing\", 1)) == 0\n )\n\n\ndef new_errors(target, baseline):\n raw = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),\n timeout=90,\n )\n try:\n return json.loads(raw)\n except Exception:\n fail(\"ERROR_LOG_JSON\")\n\n\ndef assert_no_new_errors(target, baseline, phase):\n rows = new_errors(target, baseline)\n if rows:\n emit(\n \"PORTAL200_NEW_XF_ERRORS\",\n {\n \"target\": target,\n \"phase\": phase,\n \"count\": len(rows),\n \"ids\": [r.get(\"error_id\") for r in rows],\n },\n )\n fail(\"NEW_XF_ERRORS\")\n\n\ndef guest_backup(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest ! -e \"$B\"\ninstall -d -m 700 \"$B/Pub/Controller\" \"$B/_data\"\ncp -p -- \"$P/Pub/Controller/Portal.php\" \"$B/Pub/Controller/Portal.php\"\ncp -p -- \"$P/_data/templates.xml\" \"$B/_data/templates.xml\"\ntest ! -e \"$P/Service/PortalData.php\"\nprintf 'PORTAL200_BACKUP=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_BACKUP=PASS\" not in out:\n fail(\"BACKUP_FAILED\")\n\n\ndef guest_write(target, rel, data):\n dest = f\"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}\"\n parent = str(pathlib.PurePosixPath(dest).parent)\n script = (\n \"set -eu; umask 022; \"\n + \"install -d -m 755 \"\n + shlex.quote(parent)\n + \"; T=$(mktemp \"\n + shlex.quote(parent + \"/.portal200.XXXXXX\")\n + \"); cat >\\\"$T\\\"; chmod 0644 \\\"$T\\\"; chown root:root \\\"$T\\\"; \"\n + \"mv -f -- \\\"$T\\\" \"\n + shlex.quote(dest)\n + \"; printf 'PORTAL200_WRITE=PASS\\\\n'\"\n )\n out = qga(\n target,\n [\"/bin/bash\", \"-lc\", script],\n input_bytes=data,\n timeout=90,\n )\n if \"PORTAL200_WRITE=PASS\" not in out:\n fail(\"WRITE_FAILED\")\n\n\ndef install_candidate(target):\n for rel in [\n \"Pub/Controller/Portal.php\",\n \"Service/PortalData.php\",\n \"_data/templates.xml\",\n ]:\n guest_write(target, rel, (CAND_ROOT / rel).read_bytes())\n\n\ndef restore_old(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest -f \"$B/Pub/Controller/Portal.php\"\ntest -f \"$B/_data/templates.xml\"\ncp -p -- \"$B/Pub/Controller/Portal.php\" \"$P/Pub/Controller/Portal.php\"\ncp -p -- \"$B/_data/templates.xml\" \"$P/_data/templates.xml\"\nrm -f -- \"$P/Service/PortalData.php\"\nrmdir -- \"$P/Service\" 2>/dev/null || true\nprintf 'PORTAL200_SOURCE_RESTORE=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_SOURCE_RESTORE=PASS\" not in out:\n fail(\"SOURCE_RESTORE_FAILED\")\n\n\ndef import_addon(target):\n out = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=IMPORT_PHP.encode(),\n timeout=240,\n )\n if \"PORTAL200_IMPORT_JSON=\" not in out:\n fail(\"IMPORT_FAILED\")\n\n\ndef runtime_verify(target, state):\n src = RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", state)\n out = qga(target, [\"/usr/bin/php8.3\"], input_bytes=src.encode(), timeout=120)\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_RUNTIME_JSON=\")),\n None,\n )\n if not line:\n fail(\"RUNTIME_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"RUNTIME_JSON\")\n emit(\"PORTAL200_RUNTIME\", {\"target\": target, **data})\n return data\n\n\ndef http_verify(target, state):\n out = qga(\n target,\n [\"/usr/bin/python3\", \"-\", target, state],\n input_bytes=HTTP_VERIFY_PY.encode(),\n timeout=180,\n )\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_HTTP_JSON=\")),\n None,\n )\n if not line:\n fail(\"HTTP_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"HTTP_JSON\")\n emit(\"PORTAL200_HTTP\", {\"target\": target, \"state\": state, **data})\n return data\n\n\ndef verify_new(target, baseline):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_NEW\")\n if not source_is_new(data):\n fail(\"SOURCE_NEW_MISMATCH\")\n if not template_state_new(data):\n fail(\"TEMPLATE_NEW_MISMATCH\")\n runtime_verify(target, \"new\")\n http_verify(target, \"new\")\n assert_no_new_errors(target, baseline, \"verify-new\")\n return inspect(target)\n\n\ndef verify_old(target, baseline, old_templates, strict_errors=True):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_OLD\")\n if not source_is_old(data):\n fail(\"SOURCE_OLD_MISMATCH\")\n if data.get(\"templates\") != old_templates:\n fail(\"TEMPLATE_ROLLBACK_MISMATCH\")\n if target == \"prod\":\n runtime_verify(target, \"old\")\n http_verify(target, \"old\")\n if strict_errors:\n assert_no_new_errors(target, baseline, \"verify-old\")\n return inspect(target)\n\n\ndef lint_php(target, source, label):\n out = qga(\n target,\n [\"/usr/bin/php8.3\", \"-l\"],\n input_bytes=source.encode(),\n timeout=90,\n )\n if \"No syntax errors detected\" not in out:\n fail(\"PHP_LINT_\" + label)\n\n\ndef cluster_guard():\n p = run([\"/usr/sbin/qm\", \"status\", \"210\"], timeout=30)\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM210_NOT_RUNNING\")\n p = run(\n [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"root@pve03\",\n \"/usr/sbin/qm status 211\",\n ],\n timeout=40,\n )\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM211_NOT_RUNNING\")\n emit(\"PORTAL200_CLUSTER\", \"PASS\")\n\n\ndef result_contract(status, changes, rollback_started, rollback_restored):\n emit(\n \"HOMELAB_RESULT_CONTRACT\",\n {\n \"version\": 1,\n \"command_id\": CID,\n \"status\": status,\n \"changes_made\": bool(changes),\n \"rollback_started\": bool(rollback_started),\n \"rollback_restored\": rollback_restored,\n },\n )\n\n\nemit(\"NEWFI200_BEGIN\")\nemit(\"COMMAND_ID\", CID)\nemit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE\", 22)\nemit(\n \"PORTAL200_FLOW\",\n \"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production\",\n)\n\nstaging_backup = False\nstaging_mutated = False\nstaging_final_new = False\nprod_backup = False\nprod_mutated = False\ngit_files_changed = False\ncommit_created = False\npush_confirmed = False\nfailure_rollback_started = False\nfailure_rollback_restored = None\nold_git_bytes = {}\nnew_head = None\ns_error = None\np_error = None\ns_old_templates = None\np_old_templates = None\n\ntry:\n compile(HTTP_VERIFY_PY, \"<portal200-http-verify>\", \"exec\")\n for rel, (size, sha) in EXPECTED_NEW.items():\n p = CAND_ROOT / rel\n if not p.is_file() or file_meta(p) != (size, sha):\n fail(\"CANDIDATE_IDENTITY\")\n tree = ET.parse(CAND_ROOT / \"_data/templates.xml\")\n titles = sorted(x.attrib.get(\"title\") for x in tree.getroot().findall(\"template\"))\n if titles != [\"newfi_portal\", \"newfi_portal.less\"]:\n fail(\"CANDIDATE_TEMPLATE_SET\")\n service = (CAND_ROOT / \"Service/PortalData.php\").read_text(\"utf-8\")\n if any(\n token in service\n for token in [\n \"\\\\XF::db(\",\n \"->save(\",\n \"->delete(\",\n \"INSERT \",\n \"UPDATE \",\n \"DELETE \",\n \"REPLACE \",\n ]\n ):\n fail(\"CANDIDATE_MUTATION_SCAN\")\n emit(\"PORTAL200_CANDIDATE_IDENTITY\", \"PASS\")\n emit(\"PORTAL200_ORCHESTRATION_FIXES\", {\"staging_503_without_css_links\": True, \"failure_status\": \"FAIL\"})\n\n branch = git(\"branch\", \"--show-current\")\n head = git(\"rev-parse\", \"HEAD\")\n status = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote = remote_out.split()[0] if remote_out else \"\"\n if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:\n fail(\"GIT_PRESTATE\")\n emit(\n \"PORTAL200_GIT_PRESTATE\",\n {\"branch\": branch, \"head\": head, \"remote\": remote, \"clean\": True},\n )\n cluster_guard()\n\n lint_php(\"staging\", INSPECT_PHP, \"INSPECT_STAGING\")\n lint_php(\"staging\", IMPORT_PHP, \"IMPORT_STAGING\")\n lint_php(\"staging\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_STAGING\")\n lint_php(\"prod\", INSPECT_PHP, \"INSPECT_PROD\")\n lint_php(\"prod\", IMPORT_PHP, \"IMPORT_PROD\")\n lint_php(\"prod\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_PROD\")\n emit(\"PORTAL200_RUNTIME_HELPER_LINT\", \"PASS\")\n\n s0 = inspect(\"staging\")\n p0 = inspect(\"prod\")\n if not app_contract(s0, \"staging\") or not app_contract(p0, \"prod\"):\n fail(\"APP_PRESTATE\")\n if not source_is_old(s0) or not source_is_old(p0):\n fail(\"SOURCE_PRESTATE\")\n if s0.get(\"templates\") != p0.get(\"templates\"):\n fail(\"TEMPLATE_PRESTATE_PARITY\")\n s_error = int(s0[\"application\"][\"error_max_id\"])\n p_error = int(p0[\"application\"][\"error_max_id\"])\n s_old_templates = s0[\"templates\"]\n p_old_templates = p0[\"templates\"]\n emit(\"PORTAL200_ERROR_BASELINE\", {\"staging\": s_error, \"prod\": p_error})\n http_verify(\"staging\", \"old\")\n runtime_verify(\"prod\", \"old\")\n http_verify(\"prod\", \"old\")\n assert_no_new_errors(\"staging\", s_error, \"preflight\")\n assert_no_new_errors(\"prod\", p_error, \"preflight\")\n emit(\"PORTAL200_PREFLIGHT\", \"PASS\")\n\n guest_backup(\"staging\")\n staging_backup = True\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n emit(\"PORTAL200_STAGING_FIRST_APPLY\", \"PASS\")\n\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates)\n staging_mutated = False\n emit(\"PORTAL200_STAGING_ROLLBACK_PROOF\", \"PASS\")\n\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n staging_final_new = True\n emit(\"PORTAL200_STAGING_FINAL\", \"PASS\")\n\n for rel in EXPECTED_NEW:\n dest = GIT_PORTAL / rel\n old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None\n dest.parent.mkdir(parents=True, exist_ok=True)\n shutil.copyfile(CAND_ROOT / rel, dest)\n git_files_changed = True\n\n if git(\"diff\", \"--check\"):\n fail(\"GIT_DIFF_CHECK\")\n status_lines = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\").splitlines()\n actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)\n if actual_paths != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_SCOPE\")\n git(\"add\", \"--\", *EXPECTED_GIT_PATHS)\n staged = sorted(x for x in git(\"diff\", \"--cached\", \"--name-only\").splitlines() if x)\n if staged != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_STAGED_SCOPE\")\n git(\"commit\", \"-m\", f\"feat: build data-backed Newfi portal [{CID}]\")\n commit_created = True\n new_head = git(\"rev-parse\", \"HEAD\")\n if new_head == OLD_HEAD:\n fail(\"GIT_COMMIT_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"GIT_POSTCOMMIT_DIRTY\")\n git(\"push\", \"origin\", f\"HEAD:refs/heads/{BRANCH}\", timeout=180)\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote_head = remote_out.split()[0] if remote_out else \"\"\n if remote_head != new_head:\n fail(\"GIT_REMOTE_HEAD\")\n push_confirmed = True\n emit(\n \"PORTAL200_GIT_PROMOTION\",\n {\n \"old_head\": OLD_HEAD,\n \"new_head\": new_head,\n \"remote_head\": remote_head,\n \"result\": \"PASS\",\n },\n )\n\n p_pre = inspect(\"prod\")\n if not source_is_old(p_pre) or p_pre.get(\"templates\") != p_old_templates:\n fail(\"PROD_DRIFT_BEFORE_APPLY\")\n if int(p_pre[\"application\"][\"error_max_id\"]) != p_error:\n fail(\"PROD_ERROR_DRIFT\")\n guest_backup(\"prod\")\n prod_backup = True\n prod_mutated = True\n install_candidate(\"prod\")\n import_addon(\"prod\")\n verify_new(\"prod\", p_error)\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n\n sf = verify_new(\"staging\", s_error)\n pf = verify_new(\"prod\", p_error)\n if sf[\"source\"] != pf[\"source\"]:\n fail(\"FINAL_SOURCE_PARITY\")\n if sf[\"templates\"] != pf[\"templates\"]:\n fail(\"FINAL_TEMPLATE_PARITY\")\n if git(\"rev-parse\", \"HEAD\") != new_head:\n fail(\"FINAL_LOCAL_HEAD\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n if not remote_out or remote_out.split()[0] != new_head:\n fail(\"FINAL_REMOTE_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"FINAL_GIT_DIRTY\")\n cluster_guard()\n emit(\n \"PORTAL200_FINAL_EVIDENCE\",\n {\n \"commit\": new_head,\n \"source_files\": 3,\n \"templates\": 2,\n \"staging_error_baseline\": s_error,\n \"prod_error_baseline\": p_error,\n },\n )\n emit(\"PORTAL200_PORTAL\", \"PASS\")\n emit(\"PORTAL200_STAGING\", \"PASS\")\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n emit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS\", 21)\n result_contract(\"OK\", True, False, None)\n emit(\"NEWFI200_END\")\n raise SystemExit(0)\n\nexcept PortalError as exc:\n code = str(exc)\n emit(\"PORTAL200_FAILURE\", code)\n\n if push_confirmed:\n if prod_mutated and prod_backup:\n failure_rollback_started = True\n try:\n restore_old(\"prod\")\n import_addon(\"prod\")\n verify_old(\"prod\", p_error, p_old_templates, strict_errors=False)\n prod_mutated = False\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"PASS_PARTIAL_OVERALL_STATE_CHANGED\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"FAIL\")\n else:\n if staging_mutated and staging_backup:\n failure_rollback_started = True\n try:\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates, strict_errors=False)\n staging_mutated = False\n staging_final_new = False\n failure_rollback_restored = True\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"PASS\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"FAIL\")\n\n if git_files_changed and not commit_created:\n try:\n for rel, old in old_git_bytes.items():\n dest = GIT_PORTAL / rel\n if old is None:\n if dest.exists():\n dest.unlink()\n else:\n dest.write_bytes(old)\n service_dir = GIT_PORTAL / \"Service\"\n try:\n service_dir.rmdir()\n except OSError:\n pass\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"PASS\")\n except Exception:\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"FAIL\")\n\n if commit_created:\n emit(\n \"PORTAL200_FAILURE_GIT_HISTORY\",\n \"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED\",\n )\n\n persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)\n if failure_rollback_started and failure_rollback_restored is None:\n failure_rollback_restored = False\n result_contract(\n \"FAIL\",\n persisted,\n failure_rollback_started,\n failure_rollback_restored,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nexcept Exception:\n emit(\"PORTAL200_FAILURE\", \"UNEXPECTED_EXCEPTION\")\n result_contract(\n \"FAIL\",\n bool(push_confirmed or commit_created or staging_final_new or prod_mutated),\n False,\n None,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nCANDIDATE_LAYER_3_TEXT_END\nCANDIDATE_LAYER_4_SOURCE=file:newfi-38-portal200-apply.cmd::b64::zlib\nCANDIDATE_LAYER_4_SHA256=e9d137ac729d7513d8d9aa1d9fb509005aefaa7bfe03f5cb26b9896fb1310f62\nCANDIDATE_LAYER_4_BYTES=32448\nCANDIDATE_LAYER_4_TEXT_BEGIN\nimport hashlib\nimport json\nimport pathlib\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nimport xml.etree.ElementTree as ET\n\nCID = \"NEWFI-260907-PORTAL200-APPLY1\"\nBRANCH = \"implementation/newfi-closed-mvp-20260906\"\nOLD_HEAD = \"06bab5acd124e768f70305b3d7eca2f595871890\"\nWT = pathlib.Path(\"/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906\")\nCAND_ROOT = pathlib.Path(\"/root/newfi-portal200-candidate/src/addons/Newfi/Portal\")\nPORTAL_REL = pathlib.Path(\"src/addons/Newfi/Portal\")\nGIT_PORTAL = WT / PORTAL_REL\nEXPECTED_NEW = {\n \"Pub/Controller/Portal.php\": (363, \"619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d\"),\n \"Service/PortalData.php\": (5213, \"4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d\"),\n \"_data/templates.xml\": (10977, \"1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861\"),\n}\nEXPECTED_OLD = {\n \"Pub/Controller/Portal.php\": (247, \"53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3\"),\n \"_data/templates.xml\": (1461, \"c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\"),\n}\nEXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]\nBACKUP = f\"/root/newfi-portal200-backup-{CID}\"\n\n\nclass PortalError(RuntimeError):\n pass\n\n\ndef emit(key, value=None):\n if value is None:\n print(key, flush=True)\n elif isinstance(value, (dict, list, tuple, bool, int, float)):\n print(\n key + \"=\" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\")),\n flush=True,\n )\n else:\n print(key + \"=\" + str(value), flush=True)\n\n\ndef fail(code):\n raise PortalError(code)\n\n\ndef sha_bytes(data):\n return hashlib.sha256(data).hexdigest()\n\n\ndef file_meta(path):\n data = pathlib.Path(path).read_bytes()\n return len(data), sha_bytes(data)\n\n\ndef run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):\n try:\n p = subprocess.run(\n cmd,\n input=input_bytes,\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n timeout=timeout,\n check=False,\n )\n except subprocess.TimeoutExpired:\n fail(\"SUBPROCESS_TIMEOUT\")\n if p.returncode not in ok:\n fail(\"SUBPROCESS_RC\")\n return p\n\n\ndef git(*args, ok=(0,), timeout=120):\n p = run([\"/usr/bin/git\", \"-C\", str(WT), *args], timeout=timeout, ok=ok)\n return p.stdout.decode(\"utf-8\", \"replace\").strip()\n\n\ndef qga(target, argv, *, input_bytes=None, timeout=180):\n if target == \"prod\":\n cmd = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"210\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n cmd += [\"--pass-stdin\", \"1\"]\n cmd += [\"--\", *argv]\n elif target == \"staging\":\n q = [\n \"/usr/sbin/qm\",\n \"guest\",\n \"exec\",\n \"211\",\n \"--synchronous\",\n \"1\",\n \"--timeout\",\n str(timeout),\n ]\n if input_bytes is not None:\n q += [\"--pass-stdin\", \"1\"]\n q += [\"--\", *argv]\n remote = (\n \"test \\\"$(id -u):$(hostname -s)\\\" = 0:pve03 || exit 72; \"\n \"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do \"\n \"test -r \\\"$f\\\" && test -s \\\"$f\\\" && cat -- \\\"$f\\\" >/dev/null || exit 73; done; exec \"\n + \" \".join(shlex.quote(x) for x in q)\n )\n cmd = [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"-o\",\n \"ConnectionAttempts=1\",\n \"root@pve03\",\n remote,\n ]\n else:\n fail(\"BAD_TARGET\")\n\n p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)\n try:\n data = json.loads(p.stdout.decode(\"utf-8\"))\n except Exception:\n fail(\"QGA_JSON\")\n if data.get(\"exited\") not in (1, True) or data.get(\"exitcode\") != 0:\n fail(\"QGA_GUEST_RC\")\n if data.get(\"out-truncated\") or data.get(\"err-truncated\"):\n fail(\"QGA_TRUNCATED\")\n return data.get(\"out-data\", \"\")\n\n\nINSPECT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$db = $app->db();\nfunction srcMeta(string $path): ?array {\n if (!is_file($path)) { return null; }\n return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];\n}\n$portal = $root . '/src/addons/Newfi/Portal';\n$templates = $db->fetchAll(\n \"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, \" .\n \"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, \" .\n \"LOCATE('--newfi-portal200', template) > 0 AS css_marker \" .\n \"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title\"\n);\n$result = [\n 'hostname' => gethostname(),\n 'application' => [\n 'boardActive' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardActive'\"),\n 'boardUrl' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='boardUrl'\"),\n 'indexRoute' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='indexRoute'\"),\n 'defaultStyleId' => (string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'\"),\n 'error_count' => (int)$db->fetchOne(\"SELECT COUNT(*) FROM xf_error_log\"),\n 'error_max_id' => (int)$db->fetchOne(\"SELECT COALESCE(MAX(error_id),0) FROM xf_error_log\")\n ],\n 'addon' => $db->fetchRow(\n \"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action \" .\n \"FROM xf_addon WHERE addon_id='Newfi/Portal'\"\n ),\n 'source' => [\n 'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),\n 'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),\n '_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')\n ],\n 'templates' => $templates\n];\necho \"PORTAL200_INSPECT_JSON_BEGIN\\n\";\necho json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\necho \"\\nPORTAL200_INSPECT_JSON_END\\n\";\n?>'''\n\n\nIMPORT_PHP = r'''<?php\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$addOn = $app->addOnManager()->getById('Newfi/Portal');\nif (!$addOn || !$addOn->isInstalled()) {\n fwrite(STDERR, \"ADDON_NOT_INSTALLED\\n\");\n exit(30);\n}\n$addOn->preRebuild();\n$dataManager = $app->addOnDataManager();\n$imported = [];\nforeach ($dataManager->getDataTypeClasses() as $dataType) {\n $handler = $dataManager->getDataTypeHandler($dataType);\n $xml = $handler->openTypeFile($addOn->getDataDirectory());\n if (!$xml || !$xml->count()) {\n $handler->deleteAddOnData($addOn->addon_id);\n continue;\n }\n $start = 0;\n $guard = 0;\n while (true) {\n $last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);\n if (!$last) { break; }\n if ($last <= $start || ++$guard > 100000) {\n fwrite(STDERR, \"IMPORT_PROGRESS_INVALID\\n\");\n exit(31);\n }\n $start = $last;\n }\n $handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);\n \\XF::triggerRunOnce();\n $imported[] = $handler->getContainerTag();\n}\n$addOn->postDataImport();\n$repo = $app->repository(\\XF\\Repository\\StyleRepository::class);\n$repo->updateAllStylesLastModifiedDateLater();\n$addOn->postRebuild();\necho \"PORTAL200_IMPORT_JSON=\" . json_encode(\n ['result'=>'PASS','containers'=>$imported],\n JSON_UNESCAPED_SLASHES\n) . \"\\n\";\n?>'''\n\n\nERRORS_PHP_TEMPLATE = r'''<?php\n$root='/var/www/forums/newfi/public';\nrequire $root.'/src/XF.php';\n\\XF::start($root);\n$app=\\XF::setupApp('XF\\\\Pub\\\\App');\n$db=$app->db();\n$b=%d;\n$rows=$db->fetchAll(\n \"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id\",\n [$b]\n);\necho json_encode(\n $rows,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n);\n?>'''\n\n\nRUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php\n$state = '__STATE__';\n$root = '/var/www/forums/newfi/public';\nrequire $root . '/src/XF.php';\n\\XF::start($root);\n$app = \\XF::setupApp('XF\\\\Pub\\\\App');\n$result = ['state'=>$state];\nif ($state === 'new') {\n $service = new \\Newfi\\Portal\\Service\\PortalData($app);\n $portal = $service->build();\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);\n $result['sections'] = [];\n foreach (['forums','resources','media','events','dogs'] as $key) {\n $section = $portal[$key] ?? null;\n if (!is_array($section)) { fwrite(STDERR, \"SECTION_MISSING\\n\"); exit(60); }\n $result['sections'][$key] = [\n 'available'=>(bool)($section['available'] ?? false),\n 'hasItems'=>(bool)($section['hasItems'] ?? false),\n 'status'=>(string)($section['status'] ?? '')\n ];\n }\n $result['html_marker'] = strpos($html, 'data-newfi-portal200=\"1\"') !== false;\n $result['locked_resources'] = strpos($html, 'data-portal-locked=\"resources\"') !== false;\n $result['locked_media'] = strpos($html, 'data-portal-locked=\"media\"') !== false;\n $result['html_bytes'] = strlen($html);\n if (!$result['html_marker']) { fwrite(STDERR, \"HTML_MARKER_MISSING\\n\"); exit(61); }\n if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {\n fwrite(STDERR, \"GUEST_PERMISSION_CONTRACT_CHANGED\\n\"); exit(62);\n }\n if (!$result['locked_resources'] || !$result['locked_media']) {\n fwrite(STDERR, \"LOCKED_COPY_MISSING\\n\"); exit(63);\n }\n if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {\n fwrite(STDERR, \"PUBLIC_SECTION_UNAVAILABLE\\n\"); exit(64);\n }\n} else if ($state === 'old') {\n $html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);\n $result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;\n $result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;\n $result['html_bytes'] = strlen($html);\n if ($result['html_marker'] || !$result['old_copy']) {\n fwrite(STDERR, \"OLD_RENDER_CONTRACT\\n\"); exit(65);\n }\n} else {\n exit(66);\n}\necho \"PORTAL200_RUNTIME_JSON=\" . json_encode(\n $result,\n JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE\n) . \"\\n\";\n?>'''\n\n\nHTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse\nmode=sys.argv[1]\nstate=sys.argv[2]\nif mode=='staging':\n host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503\nelif mode=='prod':\n host='newfi.ru'; scheme='https'; port='443'; expected_status=200\nelse:\n raise SystemExit(40)\ndef fetch(url):\n cmd=[\n '/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',\n '--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',\n '\\n__PORTAL_STATUS__%{http_code}',url\n ]\n p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)\n if p.returncode!=0: raise SystemExit(41)\n text=p.stdout.decode('utf-8','replace')\n marker='\\n__PORTAL_STATUS__'\n if marker not in text: raise SystemExit(42)\n body,status=text.rsplit(marker,1)\n return int(status.strip()),body\nroot=f'{scheme}://{host}/'\nstatus,body=fetch(root)\nhrefs=[]\nfor raw in re.findall(r\"href=[\\\"']([^\\\"']*css\\\\.php[^\\\"']*)[\\\"']\",body,re.I):\n raw=html.unescape(raw)\n hrefs.append(urllib.parse.urljoin(root,raw))\nnormal_css=[]\nnormal_status=[]\nfor u in hrefs:\n s,b=fetch(u)\n normal_status.append(s)\n normal_css.append(b)\nnormal_css_text='\\n'.join(normal_css)\nforced_css_marker=None\nforced_css_status=None\nif mode=='staging' and state=='new':\n if not hrefs: raise SystemExit(43)\n u=urllib.parse.urlsplit(hrefs[0])\n pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)\n out=[]\n found=False\n for k,v in pairs:\n if k=='css':\n found=True\n parts=[x for x in v.split(',') if x]\n if 'public:newfi_portal.less' not in parts:\n parts.append('public:newfi_portal.less')\n v=','.join(parts)\n out.append((k,v))\n if not found: raise SystemExit(44)\n forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))\n forced_css_status,forced_body=fetch(forced)\n forced_css_marker='--newfi-portal200' in forced_body\nresult={\n 'status':status,\n 'bytes':len(body.encode()),\n 'html_marker':'data-newfi-portal200=\"1\"' in body,\n 'css_marker':'--newfi-portal200' in normal_css_text,\n 'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,\n 'css_count':len(hrefs),\n 'css_status':normal_status,\n 'forced_css_status':forced_css_status,\n 'forced_css_marker':forced_css_marker,\n}\nprint('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))\nif status!=expected_status: raise SystemExit(45)\nif not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)\nif mode=='prod' and state=='new':\n if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)\nelif mode=='prod' and state=='old':\n if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)\nelif mode=='staging' and state=='new':\n if forced_css_status!=200 or not forced_css_marker: raise SystemExit(49)\nelif mode=='staging' and state=='old':\n pass\nelse:\n raise SystemExit(50)\n'''\n\n\ndef parse_inspect(text):\n b = \"PORTAL200_INSPECT_JSON_BEGIN\\n\"\n e = \"\\nPORTAL200_INSPECT_JSON_END\"\n if b not in text or e not in text:\n fail(\"INSPECT_MARKERS\")\n try:\n return json.loads(text.split(b, 1)[1].split(e, 1)[0])\n except Exception:\n fail(\"INSPECT_JSON\")\n\n\ndef inspect(target):\n return parse_inspect(\n qga(target, [\"/usr/bin/php8.3\"], input_bytes=INSPECT_PHP.encode(), timeout=120)\n )\n\n\ndef source_is_old(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_OLD.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return src.get(\"Service/PortalData.php\") is None\n\n\ndef source_is_new(data):\n src = data[\"source\"]\n for rel, (size, sha) in EXPECTED_NEW.items():\n row = src.get(rel)\n if not row or row.get(\"bytes\") != size or row.get(\"sha256\") != sha:\n return False\n return True\n\n\ndef template_state_new(data):\n rows = data.get(\"templates\", [])\n rows0 = [r for r in rows if int(r.get(\"style_id\", -1)) == 0]\n titles = [r.get(\"title\") for r in rows0]\n by = {r.get(\"title\"): r for r in rows0}\n return (\n titles == [\"newfi_portal\", \"newfi_portal.less\"]\n and int(by[\"newfi_portal\"].get(\"html_marker\", 0)) == 1\n and int(by[\"newfi_portal.less\"].get(\"css_marker\", 0)) == 1\n )\n\n\ndef app_contract(data, target):\n a = data.get(\"application\", {})\n addon = data.get(\"addon\") or {}\n expected = {\n \"staging\": (\"0\", \"http://newfi-staging.gram1.ru\"),\n \"prod\": (\"1\", \"https://newfi.ru\"),\n }[target]\n return (\n a.get(\"boardActive\") == expected[0]\n and a.get(\"boardUrl\") == expected[1]\n and a.get(\"indexRoute\") == \"portal/\"\n and a.get(\"defaultStyleId\") == \"4\"\n and addon.get(\"addon_id\") == \"Newfi/Portal\"\n and int(addon.get(\"version_id\", 0)) == 1000010\n and addon.get(\"version_string\") == \"1.0.0 Alpha 1\"\n and int(addon.get(\"active\", 0)) == 1\n and int(addon.get(\"is_processing\", 1)) == 0\n )\n\n\ndef new_errors(target, baseline):\n raw = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),\n timeout=90,\n )\n try:\n return json.loads(raw)\n except Exception:\n fail(\"ERROR_LOG_JSON\")\n\n\ndef assert_no_new_errors(target, baseline, phase):\n rows = new_errors(target, baseline)\n if rows:\n emit(\n \"PORTAL200_NEW_XF_ERRORS\",\n {\n \"target\": target,\n \"phase\": phase,\n \"count\": len(rows),\n \"ids\": [r.get(\"error_id\") for r in rows],\n },\n )\n fail(\"NEW_XF_ERRORS\")\n\n\ndef guest_backup(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest ! -e \"$B\"\ninstall -d -m 700 \"$B/Pub/Controller\" \"$B/_data\"\ncp -p -- \"$P/Pub/Controller/Portal.php\" \"$B/Pub/Controller/Portal.php\"\ncp -p -- \"$P/_data/templates.xml\" \"$B/_data/templates.xml\"\ntest ! -e \"$P/Service/PortalData.php\"\nprintf 'PORTAL200_BACKUP=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_BACKUP=PASS\" not in out:\n fail(\"BACKUP_FAILED\")\n\n\ndef guest_write(target, rel, data):\n dest = f\"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}\"\n parent = str(pathlib.PurePosixPath(dest).parent)\n script = (\n \"set -eu; umask 022; \"\n + \"install -d -m 755 \"\n + shlex.quote(parent)\n + \"; T=$(mktemp \"\n + shlex.quote(parent + \"/.portal200.XXXXXX\")\n + \"); cat >\\\"$T\\\"; chmod 0644 \\\"$T\\\"; chown root:root \\\"$T\\\"; \"\n + \"mv -f -- \\\"$T\\\" \"\n + shlex.quote(dest)\n + \"; printf 'PORTAL200_WRITE=PASS\\\\n'\"\n )\n out = qga(\n target,\n [\"/bin/bash\", \"-lc\", script],\n input_bytes=data,\n timeout=90,\n )\n if \"PORTAL200_WRITE=PASS\" not in out:\n fail(\"WRITE_FAILED\")\n\n\ndef install_candidate(target):\n for rel in [\n \"Pub/Controller/Portal.php\",\n \"Service/PortalData.php\",\n \"_data/templates.xml\",\n ]:\n guest_write(target, rel, (CAND_ROOT / rel).read_bytes())\n\n\ndef restore_old(target):\n script = f'''set -eu\nB={shlex.quote(BACKUP)}\nP=/var/www/forums/newfi/public/src/addons/Newfi/Portal\ntest -f \"$B/Pub/Controller/Portal.php\"\ntest -f \"$B/_data/templates.xml\"\ncp -p -- \"$B/Pub/Controller/Portal.php\" \"$P/Pub/Controller/Portal.php\"\ncp -p -- \"$B/_data/templates.xml\" \"$P/_data/templates.xml\"\nrm -f -- \"$P/Service/PortalData.php\"\nrmdir -- \"$P/Service\" 2>/dev/null || true\nprintf 'PORTAL200_SOURCE_RESTORE=PASS\\n'\n'''\n out = qga(target, [\"/bin/bash\", \"-lc\", script], timeout=90)\n if \"PORTAL200_SOURCE_RESTORE=PASS\" not in out:\n fail(\"SOURCE_RESTORE_FAILED\")\n\n\ndef import_addon(target):\n out = qga(\n target,\n [\"/usr/bin/php8.3\"],\n input_bytes=IMPORT_PHP.encode(),\n timeout=240,\n )\n if \"PORTAL200_IMPORT_JSON=\" not in out:\n fail(\"IMPORT_FAILED\")\n\n\ndef runtime_verify(target, state):\n src = RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", state)\n out = qga(target, [\"/usr/bin/php8.3\"], input_bytes=src.encode(), timeout=120)\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_RUNTIME_JSON=\")),\n None,\n )\n if not line:\n fail(\"RUNTIME_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"RUNTIME_JSON\")\n emit(\"PORTAL200_RUNTIME\", {\"target\": target, **data})\n return data\n\n\ndef http_verify(target, state):\n out = qga(\n target,\n [\"/usr/bin/python3\", \"-\", target, state],\n input_bytes=HTTP_VERIFY_PY.encode(),\n timeout=180,\n )\n line = next(\n (x for x in out.splitlines() if x.startswith(\"PORTAL200_HTTP_JSON=\")),\n None,\n )\n if not line:\n fail(\"HTTP_MARKER_MISSING\")\n try:\n data = json.loads(line.split(\"=\", 1)[1])\n except Exception:\n fail(\"HTTP_JSON\")\n emit(\"PORTAL200_HTTP\", {\"target\": target, \"state\": state, **data})\n return data\n\n\ndef verify_new(target, baseline):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_NEW\")\n if not source_is_new(data):\n fail(\"SOURCE_NEW_MISMATCH\")\n if not template_state_new(data):\n fail(\"TEMPLATE_NEW_MISMATCH\")\n runtime_verify(target, \"new\")\n http_verify(target, \"new\")\n assert_no_new_errors(target, baseline, \"verify-new\")\n return inspect(target)\n\n\ndef verify_old(target, baseline, old_templates, strict_errors=True):\n data = inspect(target)\n if not app_contract(data, target):\n fail(\"APP_CONTRACT_OLD\")\n if not source_is_old(data):\n fail(\"SOURCE_OLD_MISMATCH\")\n if data.get(\"templates\") != old_templates:\n fail(\"TEMPLATE_ROLLBACK_MISMATCH\")\n if target == \"prod\":\n runtime_verify(target, \"old\")\n http_verify(target, \"old\")\n if strict_errors:\n assert_no_new_errors(target, baseline, \"verify-old\")\n return inspect(target)\n\n\ndef lint_php(target, source, label):\n out = qga(\n target,\n [\"/usr/bin/php8.3\", \"-l\"],\n input_bytes=source.encode(),\n timeout=90,\n )\n if \"No syntax errors detected\" not in out:\n fail(\"PHP_LINT_\" + label)\n\n\ndef cluster_guard():\n p = run([\"/usr/sbin/qm\", \"status\", \"210\"], timeout=30)\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM210_NOT_RUNNING\")\n p = run(\n [\n \"/usr/bin/ssh\",\n \"-T\",\n \"-o\",\n \"BatchMode=yes\",\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n \"UpdateHostKeys=no\",\n \"-o\",\n \"ConnectTimeout=10\",\n \"root@pve03\",\n \"/usr/sbin/qm status 211\",\n ],\n timeout=40,\n )\n if \"status: running\" not in p.stdout.decode():\n fail(\"VM211_NOT_RUNNING\")\n emit(\"PORTAL200_CLUSTER\", \"PASS\")\n\n\ndef result_contract(status, changes, rollback_started, rollback_restored):\n emit(\n \"HOMELAB_RESULT_CONTRACT\",\n {\n \"version\": 1,\n \"command_id\": CID,\n \"status\": status,\n \"changes_made\": bool(changes),\n \"rollback_started\": bool(rollback_started),\n \"rollback_restored\": rollback_restored,\n },\n )\n\n\nemit(\"NEWFI200_BEGIN\")\nemit(\"COMMAND_ID\", CID)\nemit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE\", 22)\nemit(\n \"PORTAL200_FLOW\",\n \"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production\",\n)\n\nstaging_backup = False\nstaging_mutated = False\nstaging_final_new = False\nprod_backup = False\nprod_mutated = False\ngit_files_changed = False\ncommit_created = False\npush_confirmed = False\nfailure_rollback_started = False\nfailure_rollback_restored = None\nold_git_bytes = {}\nnew_head = None\ns_error = None\np_error = None\ns_old_templates = None\np_old_templates = None\n\ntry:\n compile(HTTP_VERIFY_PY, \"<portal200-http-verify>\", \"exec\")\n for rel, (size, sha) in EXPECTED_NEW.items():\n p = CAND_ROOT / rel\n if not p.is_file() or file_meta(p) != (size, sha):\n fail(\"CANDIDATE_IDENTITY\")\n tree = ET.parse(CAND_ROOT / \"_data/templates.xml\")\n titles = sorted(x.attrib.get(\"title\") for x in tree.getroot().findall(\"template\"))\n if titles != [\"newfi_portal\", \"newfi_portal.less\"]:\n fail(\"CANDIDATE_TEMPLATE_SET\")\n service = (CAND_ROOT / \"Service/PortalData.php\").read_text(\"utf-8\")\n if any(\n token in service\n for token in [\n \"\\\\XF::db(\",\n \"->save(\",\n \"->delete(\",\n \"INSERT \",\n \"UPDATE \",\n \"DELETE \",\n \"REPLACE \",\n ]\n ):\n fail(\"CANDIDATE_MUTATION_SCAN\")\n emit(\"PORTAL200_CANDIDATE_IDENTITY\", \"PASS\")\n\n branch = git(\"branch\", \"--show-current\")\n head = git(\"rev-parse\", \"HEAD\")\n status = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote = remote_out.split()[0] if remote_out else \"\"\n if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:\n fail(\"GIT_PRESTATE\")\n emit(\n \"PORTAL200_GIT_PRESTATE\",\n {\"branch\": branch, \"head\": head, \"remote\": remote, \"clean\": True},\n )\n cluster_guard()\n\n lint_php(\"staging\", INSPECT_PHP, \"INSPECT_STAGING\")\n lint_php(\"staging\", IMPORT_PHP, \"IMPORT_STAGING\")\n lint_php(\"staging\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_STAGING\")\n lint_php(\"prod\", INSPECT_PHP, \"INSPECT_PROD\")\n lint_php(\"prod\", IMPORT_PHP, \"IMPORT_PROD\")\n lint_php(\"prod\", RUNTIME_VERIFY_PHP_TEMPLATE.replace(\"__STATE__\", \"new\"), \"RUNTIME_PROD\")\n emit(\"PORTAL200_RUNTIME_HELPER_LINT\", \"PASS\")\n\n s0 = inspect(\"staging\")\n p0 = inspect(\"prod\")\n if not app_contract(s0, \"staging\") or not app_contract(p0, \"prod\"):\n fail(\"APP_PRESTATE\")\n if not source_is_old(s0) or not source_is_old(p0):\n fail(\"SOURCE_PRESTATE\")\n if s0.get(\"templates\") != p0.get(\"templates\"):\n fail(\"TEMPLATE_PRESTATE_PARITY\")\n s_error = int(s0[\"application\"][\"error_max_id\"])\n p_error = int(p0[\"application\"][\"error_max_id\"])\n s_old_templates = s0[\"templates\"]\n p_old_templates = p0[\"templates\"]\n emit(\"PORTAL200_ERROR_BASELINE\", {\"staging\": s_error, \"prod\": p_error})\n http_verify(\"staging\", \"old\")\n runtime_verify(\"prod\", \"old\")\n http_verify(\"prod\", \"old\")\n assert_no_new_errors(\"staging\", s_error, \"preflight\")\n assert_no_new_errors(\"prod\", p_error, \"preflight\")\n emit(\"PORTAL200_PREFLIGHT\", \"PASS\")\n\n guest_backup(\"staging\")\n staging_backup = True\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n emit(\"PORTAL200_STAGING_FIRST_APPLY\", \"PASS\")\n\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates)\n staging_mutated = False\n emit(\"PORTAL200_STAGING_ROLLBACK_PROOF\", \"PASS\")\n\n staging_mutated = True\n install_candidate(\"staging\")\n import_addon(\"staging\")\n verify_new(\"staging\", s_error)\n staging_final_new = True\n emit(\"PORTAL200_STAGING_FINAL\", \"PASS\")\n\n for rel in EXPECTED_NEW:\n dest = GIT_PORTAL / rel\n old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None\n dest.parent.mkdir(parents=True, exist_ok=True)\n shutil.copyfile(CAND_ROOT / rel, dest)\n git_files_changed = True\n\n if git(\"diff\", \"--check\"):\n fail(\"GIT_DIFF_CHECK\")\n status_lines = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\").splitlines()\n actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)\n if actual_paths != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_SCOPE\")\n git(\"add\", \"--\", *EXPECTED_GIT_PATHS)\n staged = sorted(x for x in git(\"diff\", \"--cached\", \"--name-only\").splitlines() if x)\n if staged != sorted(EXPECTED_GIT_PATHS):\n fail(\"GIT_STAGED_SCOPE\")\n git(\"commit\", \"-m\", f\"feat: build data-backed Newfi portal [{CID}]\")\n commit_created = True\n new_head = git(\"rev-parse\", \"HEAD\")\n if new_head == OLD_HEAD:\n fail(\"GIT_COMMIT_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"GIT_POSTCOMMIT_DIRTY\")\n git(\"push\", \"origin\", f\"HEAD:refs/heads/{BRANCH}\", timeout=180)\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n remote_head = remote_out.split()[0] if remote_out else \"\"\n if remote_head != new_head:\n fail(\"GIT_REMOTE_HEAD\")\n push_confirmed = True\n emit(\n \"PORTAL200_GIT_PROMOTION\",\n {\n \"old_head\": OLD_HEAD,\n \"new_head\": new_head,\n \"remote_head\": remote_head,\n \"result\": \"PASS\",\n },\n )\n\n p_pre = inspect(\"prod\")\n if not source_is_old(p_pre) or p_pre.get(\"templates\") != p_old_templates:\n fail(\"PROD_DRIFT_BEFORE_APPLY\")\n if int(p_pre[\"application\"][\"error_max_id\"]) != p_error:\n fail(\"PROD_ERROR_DRIFT\")\n guest_backup(\"prod\")\n prod_backup = True\n prod_mutated = True\n install_candidate(\"prod\")\n import_addon(\"prod\")\n verify_new(\"prod\", p_error)\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n\n sf = verify_new(\"staging\", s_error)\n pf = verify_new(\"prod\", p_error)\n if sf[\"source\"] != pf[\"source\"]:\n fail(\"FINAL_SOURCE_PARITY\")\n if sf[\"templates\"] != pf[\"templates\"]:\n fail(\"FINAL_TEMPLATE_PARITY\")\n if git(\"rev-parse\", \"HEAD\") != new_head:\n fail(\"FINAL_LOCAL_HEAD\")\n remote_out = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n if not remote_out or remote_out.split()[0] != new_head:\n fail(\"FINAL_REMOTE_HEAD\")\n if git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\"):\n fail(\"FINAL_GIT_DIRTY\")\n cluster_guard()\n emit(\n \"PORTAL200_FINAL_EVIDENCE\",\n {\n \"commit\": new_head,\n \"source_files\": 3,\n \"templates\": 2,\n \"staging_error_baseline\": s_error,\n \"prod_error_baseline\": p_error,\n },\n )\n emit(\"PORTAL200_PORTAL\", \"PASS\")\n emit(\"PORTAL200_STAGING\", \"PASS\")\n emit(\"PORTAL200_PRODUCTION\", \"PASS\")\n emit(\"PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS\", 21)\n result_contract(\"OK\", True, False, None)\n emit(\"NEWFI200_END\")\n raise SystemExit(0)\n\nexcept PortalError as exc:\n code = str(exc)\n emit(\"PORTAL200_FAILURE\", code)\n\n if push_confirmed:\n if prod_mutated and prod_backup:\n failure_rollback_started = True\n try:\n restore_old(\"prod\")\n import_addon(\"prod\")\n verify_old(\"prod\", p_error, p_old_templates, strict_errors=False)\n prod_mutated = False\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"PASS_PARTIAL_OVERALL_STATE_CHANGED\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_PROD_ROLLBACK\", \"FAIL\")\n else:\n if staging_mutated and staging_backup:\n failure_rollback_started = True\n try:\n restore_old(\"staging\")\n import_addon(\"staging\")\n verify_old(\"staging\", s_error, s_old_templates, strict_errors=False)\n staging_mutated = False\n staging_final_new = False\n failure_rollback_restored = True\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"PASS\")\n except Exception:\n failure_rollback_restored = False\n emit(\"PORTAL200_FAILURE_STAGING_ROLLBACK\", \"FAIL\")\n\n if git_files_changed and not commit_created:\n try:\n for rel, old in old_git_bytes.items():\n dest = GIT_PORTAL / rel\n if old is None:\n if dest.exists():\n dest.unlink()\n else:\n dest.write_bytes(old)\n service_dir = GIT_PORTAL / \"Service\"\n try:\n service_dir.rmdir()\n except OSError:\n pass\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"PASS\")\n except Exception:\n emit(\"PORTAL200_FAILURE_GIT_WORKTREE_RESTORE\", \"FAIL\")\n\n if commit_created:\n emit(\n \"PORTAL200_FAILURE_GIT_HISTORY\",\n \"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED\",\n )\n\n persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)\n if failure_rollback_started and failure_rollback_restored is None:\n failure_rollback_restored = False\n result_contract(\n \"ERROR\",\n persisted,\n failure_rollback_started,\n failure_rollback_restored,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nexcept Exception:\n emit(\"PORTAL200_FAILURE\", \"UNEXPECTED_EXCEPTION\")\n result_contract(\n \"ERROR\",\n bool(push_confirmed or commit_created or staging_final_new or prod_mutated),\n False,\n None,\n )\n emit(\"PORTAL200_ROADMAP_REMAINS\", 22)\n emit(\"NEWFI200_END\")\n raise SystemExit(1)\n\nCANDIDATE_LAYER_4_TEXT_END\nCANDIDATE_LAYER_5_SOURCE=file:newfi-55-portal200-apply3-rca.cmd::b64::zlib\nCANDIDATE_LAYER_5_SHA256=8911318cd314572edf9c6343c09b4c83d283e946dd2398ea2e05722ac9bd44ac\nCANDIDATE_LAYER_5_BYTES=10641\nCANDIDATE_LAYER_5_TEXT_BEGIN\nimport hashlib\nimport json\nimport pathlib\nimport shlex\nimport subprocess\n\nCID = \"NEWFI-260907-PORTAL200-APPLY3RCA1\"\nFAILED_CID = \"NEWFI-260907-PORTAL200-APPLY3\"\nWT = pathlib.Path(\"/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906\")\nBRANCH = \"implementation/newfi-closed-mvp-20260906\"\nOLD_HEAD = \"06bab5acd124e768f70305b3d7eca2f595871890\"\nFAILED_DIR = pathlib.Path(\"/root/newfi-run-\" + FAILED_CID)\nWRAPPER = FAILED_DIR / \"wrapper.log\"\n\nOLD = {\n \"controller\": \"53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3\",\n \"templates_xml\": \"c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\",\n}\nNEW = {\n \"controller\": \"619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d\",\n \"service\": \"4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d\",\n \"templates_xml\": \"1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861\",\n}\n\nINSPECT = '<?php\\n$root=\\'/var/www/forums/newfi/public\\';\\nrequire $root.\\'/src/XF.php\\';\\n\\\\XF::start($root);\\n$app=\\\\XF::setupApp(\\'XF\\\\\\\\Pub\\\\\\\\App\\');\\n$db=$app->db();\\n\\nfunction meta($p) {\\n if (!is_file($p)) { return null; }\\n return [\\'bytes\\'=>filesize($p),\\'sha256\\'=>hash_file(\\'sha256\\',$p)];\\n}\\n\\n$portal=$root.\\'/src/addons/Newfi/Portal\\';\\n$baseline=(gethostname()===\\'newfi-staging\\') ? 40 : 0;\\n\\n$templates=$db->fetchAll(\\n \"SELECT title,style_id,LENGTH(template) AS bytes,SHA2(template,256) AS sha256,\" .\\n \" LOCATE(\\'data-newfi-portal200\\',template)>0 AS html_marker,\" .\\n \" LOCATE(\\'--newfi-portal200\\',template)>0 AS css_marker\" .\\n \" FROM xf_template WHERE addon_id=\\'Newfi/Portal\\' ORDER BY style_id,title\"\\n);\\n\\n$errors=$db->fetchAll(\\n \"SELECT error_id,exception_type,message,filename,line\" .\\n \" FROM xf_error_log WHERE error_id>? ORDER BY error_id LIMIT 30\",\\n [$baseline]\\n);\\n\\n$result=[\\n \\'hostname\\'=>gethostname(),\\n \\'application\\'=>[\\n \\'boardActive\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'boardActive\\'\"),\\n \\'boardUrl\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'boardUrl\\'\"),\\n \\'indexRoute\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'indexRoute\\'\"),\\n \\'error_count\\'=>(int)$db->fetchOne(\"SELECT COUNT(*) FROM xf_error_log\"),\\n \\'error_max_id\\'=>(int)$db->fetchOne(\"SELECT COALESCE(MAX(error_id),0) FROM xf_error_log\")\\n ],\\n \\'source\\'=>[\\n \\'controller\\'=>meta($portal.\\'/Pub/Controller/Portal.php\\'),\\n \\'service\\'=>meta($portal.\\'/Service/PortalData.php\\'),\\n \\'templates_xml\\'=>meta($portal.\\'/_data/templates.xml\\')\\n ],\\n \\'templates\\'=>$templates,\\n \\'new_errors\\'=>$errors\\n];\\n\\necho \"PORTAL200_RCA_GUEST_JSON=\" .\\n json_encode($result,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE) .\\n \"\\\\n\";\\n?>'\n\ndef emit(k, v=None):\n if v is None:\n print(k, flush=True)\n elif isinstance(v, (dict, list, bool, int, float)):\n print(k + \"=\" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\")), flush=True)\n else:\n print(k + \"=\" + str(v), flush=True)\n\ndef run(cmd, *, input_bytes=None, timeout=120):\n return subprocess.run(\n cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,\n timeout=timeout, check=False\n )\n\ndef git(*args):\n p = run([\"/usr/bin/git\", \"-C\", str(WT), *args], timeout=90)\n return p.returncode, p.stdout.decode(\"utf-8\", \"replace\").strip(), p.stderr.decode(\"utf-8\", \"replace\").strip()\n\ndef qga(target, php_source):\n if target == \"prod\":\n cmd = [\n \"/usr/sbin/qm\", \"guest\", \"exec\", \"210\",\n \"--synchronous\", \"1\", \"--timeout\", \"120\",\n \"--pass-stdin\", \"1\", \"--\", \"/usr/bin/php8.3\"\n ]\n else:\n q = [\n \"/usr/sbin/qm\", \"guest\", \"exec\", \"211\",\n \"--synchronous\", \"1\", \"--timeout\", \"120\",\n \"--pass-stdin\", \"1\", \"--\", \"/usr/bin/php8.3\"\n ]\n remote = (\n \"test \\\"$(id -u):$(hostname -s)\\\" = 0:pve03 || exit 72; \"\n \"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do \"\n \"test -r \\\"$f\\\" && test -s \\\"$f\\\" && cat -- \\\"$f\\\" >/dev/null || exit 73; done; exec \"\n + \" \".join(shlex.quote(x) for x in q)\n )\n cmd = [\n \"/usr/bin/ssh\", \"-T\",\n \"-o\", \"BatchMode=yes\",\n \"-o\", \"StrictHostKeyChecking=yes\",\n \"-o\", \"UpdateHostKeys=no\",\n \"-o\", \"ConnectTimeout=10\",\n \"-o\", \"ConnectionAttempts=1\",\n \"root@pve03\", remote\n ]\n p = run(cmd, input_bytes=php_source.encode(\"utf-8\"), timeout=150)\n if p.returncode != 0:\n return {\"transport_rc\": p.returncode, \"transport_error\": p.stderr.decode(\"utf-8\", \"replace\")[:500]}\n try:\n outer = json.loads(p.stdout.decode(\"utf-8\", \"replace\"))\n except Exception:\n return {\"transport_rc\": 0, \"outer_json\": False, \"raw\": p.stdout.decode(\"utf-8\", \"replace\")[:1000]}\n if outer.get(\"exitcode\") != 0:\n return {\n \"transport_rc\": 0,\n \"guest_rc\": outer.get(\"exitcode\"),\n \"guest_out\": outer.get(\"out-data\", \"\")[:1000],\n \"guest_err\": outer.get(\"err-data\", \"\")[:1000],\n }\n out = outer.get(\"out-data\", \"\")\n marker = \"PORTAL200_RCA_GUEST_JSON=\"\n line = next((x for x in out.splitlines() if x.startswith(marker)), None)\n if not line:\n return {\"transport_rc\": 0, \"guest_rc\": 0, \"marker\": False, \"guest_out\": out[:1000]}\n return json.loads(line[len(marker):])\n\ndef source_state(data):\n src = data.get(\"source\", {})\n controller = (src.get(\"controller\") or {}).get(\"sha256\")\n service = src.get(\"service\")\n templates = (src.get(\"templates_xml\") or {}).get(\"sha256\")\n if controller == OLD[\"controller\"] and service is None and templates == OLD[\"templates_xml\"]:\n return \"OLD\"\n if (\n controller == NEW[\"controller\"]\n and (service or {}).get(\"sha256\") == NEW[\"service\"]\n and templates == NEW[\"templates_xml\"]\n ):\n return \"NEW\"\n return \"DRIFT\"\n\ndef template_state(data):\n rows = data.get(\"templates\", [])\n titles = [r.get(\"title\") for r in rows]\n has_html = any(bool(r.get(\"html_marker\")) for r in rows)\n has_css = any(bool(r.get(\"css_marker\")) for r in rows)\n if titles == [\"newfi_portal\"] and not has_html and not has_css:\n return \"OLD\"\n if titles == [\"newfi_portal\", \"newfi_portal.less\"] and has_html and has_css:\n return \"NEW\"\n return \"DRIFT\"\n\nemit(\"NEWFI55_BEGIN\")\nemit(\"COMMAND_ID\", CID)\nemit(\"RCA_TARGET_FAILED_COMMAND\", FAILED_CID)\nemit(\"PORTAL200_ROADMAP_REMAINS\", 22)\nemit(\"RCA_MUTATION_SCOPE\", \"NONE_READ_ONLY\")\n\nif WRAPPER.is_file() and not WRAPPER.is_symlink():\n raw = WRAPPER.read_bytes()\n emit(\"RCA_WRAPPER_BYTES\", len(raw))\n emit(\"RCA_WRAPPER_SHA256\", hashlib.sha256(raw).hexdigest())\n text = raw.decode(\"utf-8\", \"replace\")\n keep_prefixes = (\n \"CHAT_OUTPUT_BEGIN\", \"CHAT_OUTPUT_END\", \"COMMAND_ID=\", \"STATUS=\", \"RC=\",\n \"RESULT_CONTRACT_\", \"COMMAND_RC=\", \"CHANGES_MADE=\", \"ROLLBACK_\",\n \"MUTATION_OUTCOME=\", \"OUTPUT_BEGIN\", \"OUTPUT_END\", \"NEWFI200\",\n \"PORTAL200_\", \"HOMELAB_RESULT_CONTRACT=\", \"STOP=\", \"Traceback\",\n )\n selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]\n emit(\"RCA_WRAPPER_SELECTED_LINE_COUNT\", len(selected))\n print(\"RCA_WRAPPER_SELECTED_BEGIN\", flush=True)\n for line in selected[-250:]:\n print(line, flush=True)\n print(\"RCA_WRAPPER_SELECTED_END\", flush=True)\nelse:\n emit(\"RCA_WRAPPER_MISSING\", True)\n\n_, branch, _ = git(\"branch\", \"--show-current\")\n_, head, _ = git(\"rev-parse\", \"HEAD\")\n_, remote_out, _ = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n_, status, _ = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\")\nremote = remote_out.split()[0] if remote_out else \"\"\n\ngit_state = {\n \"branch\": branch,\n \"head\": head,\n \"remote\": remote,\n \"clean\": (status == \"\"),\n \"status_lines\": status.splitlines()[:20],\n}\nemit(\"RCA_GIT_STATE\", git_state)\n\nstaging = qga(\"staging\", INSPECT)\nprod = qga(\"prod\", INSPECT)\n\nemit(\"RCA_STAGING_STATE\", staging)\nemit(\"RCA_PROD_STATE\", prod)\n\ns_src = source_state(staging) if isinstance(staging, dict) else \"UNKNOWN\"\np_src = source_state(prod) if isinstance(prod, dict) else \"UNKNOWN\"\ns_tpl = template_state(staging) if isinstance(staging, dict) else \"UNKNOWN\"\np_tpl = template_state(prod) if isinstance(prod, dict) else \"UNKNOWN\"\n\nsummary = {\n \"git_old_head\": head == OLD_HEAD and remote == OLD_HEAD,\n \"git_clean\": status == \"\",\n \"staging_source\": s_src,\n \"staging_templates\": s_tpl,\n \"staging_error_max\": staging.get(\"application\", {}).get(\"error_max_id\") if isinstance(staging, dict) else None,\n \"prod_source\": p_src,\n \"prod_templates\": p_tpl,\n \"prod_error_max\": prod.get(\"application\", {}).get(\"error_max_id\") if isinstance(prod, dict) else None,\n}\nsummary[\"clean_old_prestate\"] = bool(\n summary[\"git_old_head\"]\n and summary[\"git_clean\"]\n and s_src == \"OLD\"\n and s_tpl == \"OLD\"\n and p_src == \"OLD\"\n and p_tpl == \"OLD\"\n)\nemit(\"RCA_STATE_SUMMARY\", summary)\n\nrunner_path = pathlib.Path(\"/usr/local/sbin/homelab-chat-run\")\nif runner_path.is_file() and not runner_path.is_symlink():\n runner_bytes = runner_path.read_bytes()\n runner_text = runner_bytes.decode(\"utf-8\", \"replace\")\n emit(\"RCA_RUNNER_SHA256\", hashlib.sha256(runner_bytes).hexdigest())\n emit(\"RCA_RUNNER_BYTES\", len(runner_bytes))\n lines = runner_text.splitlines()\n terms = (\n \"HOMELAB_RESULT_CONTRACT\",\n \"rollback_restored\",\n \"rollback_started\",\n \"changes_made\",\n \"status not in\",\n \"ValueError\",\n )\n indexes = set()\n for i, line in enumerate(lines):\n if any(t in line for t in terms):\n for j in range(max(0, i - 3), min(len(lines), i + 4)):\n indexes.add(j)\n print(\"RCA_RUNNER_CONTRACT_SOURCE_BEGIN\", flush=True)\n for j in sorted(indexes):\n line = lines[j]\n if len(line) > 500:\n line = line[:500] + \"...[TRUNCATED]\"\n print(json.dumps({\"line\": j + 1, \"text\": line}, ensure_ascii=False, separators=(\",\", \":\")), flush=True)\n print(\"RCA_RUNNER_CONTRACT_SOURCE_END\", flush=True)\nelse:\n emit(\"RCA_RUNNER_SOURCE_MISSING\", True)\n\nemit(\"HOMELAB_RESULT_CONTRACT\", {\n \"version\": 1,\n \"command_id\": CID,\n \"status\": \"OK\",\n \"changes_made\": False,\n \"rollback_started\": False,\n \"rollback_restored\": None\n})\nemit(\"NEWFI55_END\")\n\nCANDIDATE_LAYER_5_TEXT_END\nCANDIDATE_LAYER_6_SOURCE=file:newfi-39-portal200-rca1.cmd::b64::zlib\nCANDIDATE_LAYER_6_SHA256=08edd716820df9797dc01cbee765750d9924fc4f94855fde4aba2c25eacb76f2\nCANDIDATE_LAYER_6_BYTES=9466\nCANDIDATE_LAYER_6_TEXT_BEGIN\nimport hashlib\nimport json\nimport pathlib\nimport shlex\nimport subprocess\n\nCID = \"NEWFI-260907-PORTAL200-RCA1\"\nFAILED_CID = \"NEWFI-260907-PORTAL200-APPLY1\"\nWT = pathlib.Path(\"/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906\")\nBRANCH = \"implementation/newfi-closed-mvp-20260906\"\nOLD_HEAD = \"06bab5acd124e768f70305b3d7eca2f595871890\"\nFAILED_DIR = pathlib.Path(\"/root/newfi-run-\" + FAILED_CID)\nWRAPPER = FAILED_DIR / \"wrapper.log\"\n\nOLD = {\n \"controller\": \"53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3\",\n \"templates_xml\": \"c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\",\n}\nNEW = {\n \"controller\": \"619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d\",\n \"service\": \"4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d\",\n \"templates_xml\": \"1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861\",\n}\n\nINSPECT = '<?php\\n$root=\\'/var/www/forums/newfi/public\\';\\nrequire $root.\\'/src/XF.php\\';\\n\\\\XF::start($root);\\n$app=\\\\XF::setupApp(\\'XF\\\\\\\\Pub\\\\\\\\App\\');\\n$db=$app->db();\\n\\nfunction meta($p) {\\n if (!is_file($p)) { return null; }\\n return [\\'bytes\\'=>filesize($p),\\'sha256\\'=>hash_file(\\'sha256\\',$p)];\\n}\\n\\n$portal=$root.\\'/src/addons/Newfi/Portal\\';\\n$baseline=(gethostname()===\\'newfi-staging\\') ? 40 : 0;\\n\\n$templates=$db->fetchAll(\\n \"SELECT title,style_id,LENGTH(template) AS bytes,SHA2(template,256) AS sha256,\" .\\n \" LOCATE(\\'data-newfi-portal200\\',template)>0 AS html_marker,\" .\\n \" LOCATE(\\'--newfi-portal200\\',template)>0 AS css_marker\" .\\n \" FROM xf_template WHERE addon_id=\\'Newfi/Portal\\' ORDER BY style_id,title\"\\n);\\n\\n$errors=$db->fetchAll(\\n \"SELECT error_id,exception_type,message,filename,line\" .\\n \" FROM xf_error_log WHERE error_id>? ORDER BY error_id LIMIT 30\",\\n [$baseline]\\n);\\n\\n$result=[\\n \\'hostname\\'=>gethostname(),\\n \\'application\\'=>[\\n \\'boardActive\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'boardActive\\'\"),\\n \\'boardUrl\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'boardUrl\\'\"),\\n \\'indexRoute\\'=>(string)$db->fetchOne(\"SELECT option_value FROM xf_option WHERE option_id=\\'indexRoute\\'\"),\\n \\'error_count\\'=>(int)$db->fetchOne(\"SELECT COUNT(*) FROM xf_error_log\"),\\n \\'error_max_id\\'=>(int)$db->fetchOne(\"SELECT COALESCE(MAX(error_id),0) FROM xf_error_log\")\\n ],\\n \\'source\\'=>[\\n \\'controller\\'=>meta($portal.\\'/Pub/Controller/Portal.php\\'),\\n \\'service\\'=>meta($portal.\\'/Service/PortalData.php\\'),\\n \\'templates_xml\\'=>meta($portal.\\'/_data/templates.xml\\')\\n ],\\n \\'templates\\'=>$templates,\\n \\'new_errors\\'=>$errors\\n];\\n\\necho \"PORTAL200_RCA_GUEST_JSON=\" .\\n json_encode($result,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE) .\\n \"\\\\n\";\\n?>'\n\ndef emit(k, v=None):\n if v is None:\n print(k, flush=True)\n elif isinstance(v, (dict, list, bool, int, float)):\n print(k + \"=\" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(\",\", \":\")), flush=True)\n else:\n print(k + \"=\" + str(v), flush=True)\n\ndef run(cmd, *, input_bytes=None, timeout=120):\n return subprocess.run(\n cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,\n timeout=timeout, check=False\n )\n\ndef git(*args):\n p = run([\"/usr/bin/git\", \"-C\", str(WT), *args], timeout=90)\n return p.returncode, p.stdout.decode(\"utf-8\", \"replace\").strip(), p.stderr.decode(\"utf-8\", \"replace\").strip()\n\ndef qga(target, php_source):\n if target == \"prod\":\n cmd = [\n \"/usr/sbin/qm\", \"guest\", \"exec\", \"210\",\n \"--synchronous\", \"1\", \"--timeout\", \"120\",\n \"--pass-stdin\", \"1\", \"--\", \"/usr/bin/php8.3\"\n ]\n else:\n q = [\n \"/usr/sbin/qm\", \"guest\", \"exec\", \"211\",\n \"--synchronous\", \"1\", \"--timeout\", \"120\",\n \"--pass-stdin\", \"1\", \"--\", \"/usr/bin/php8.3\"\n ]\n remote = (\n \"test \\\"$(id -u):$(hostname -s)\\\" = 0:pve03 || exit 72; \"\n \"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do \"\n \"test -r \\\"$f\\\" && test -s \\\"$f\\\" && cat -- \\\"$f\\\" >/dev/null || exit 73; done; exec \"\n + \" \".join(shlex.quote(x) for x in q)\n )\n cmd = [\n \"/usr/bin/ssh\", \"-T\",\n \"-o\", \"BatchMode=yes\",\n \"-o\", \"StrictHostKeyChecking=yes\",\n \"-o\", \"UpdateHostKeys=no\",\n \"-o\", \"ConnectTimeout=10\",\n \"-o\", \"ConnectionAttempts=1\",\n \"root@pve03\", remote\n ]\n p = run(cmd, input_bytes=php_source.encode(\"utf-8\"), timeout=150)\n if p.returncode != 0:\n return {\"transport_rc\": p.returncode, \"transport_error\": p.stderr.decode(\"utf-8\", \"replace\")[:500]}\n try:\n outer = json.loads(p.stdout.decode(\"utf-8\", \"replace\"))\n except Exception:\n return {\"transport_rc\": 0, \"outer_json\": False, \"raw\": p.stdout.decode(\"utf-8\", \"replace\")[:1000]}\n if outer.get(\"exitcode\") != 0:\n return {\n \"transport_rc\": 0,\n \"guest_rc\": outer.get(\"exitcode\"),\n \"guest_out\": outer.get(\"out-data\", \"\")[:1000],\n \"guest_err\": outer.get(\"err-data\", \"\")[:1000],\n }\n out = outer.get(\"out-data\", \"\")\n marker = \"PORTAL200_RCA_GUEST_JSON=\"\n line = next((x for x in out.splitlines() if x.startswith(marker)), None)\n if not line:\n return {\"transport_rc\": 0, \"guest_rc\": 0, \"marker\": False, \"guest_out\": out[:1000]}\n return json.loads(line[len(marker):])\n\ndef source_state(data):\n src = data.get(\"source\", {})\n controller = (src.get(\"controller\") or {}).get(\"sha256\")\n service = src.get(\"service\")\n templates = (src.get(\"templates_xml\") or {}).get(\"sha256\")\n if controller == OLD[\"controller\"] and service is None and templates == OLD[\"templates_xml\"]:\n return \"OLD\"\n if (\n controller == NEW[\"controller\"]\n and (service or {}).get(\"sha256\") == NEW[\"service\"]\n and templates == NEW[\"templates_xml\"]\n ):\n return \"NEW\"\n return \"DRIFT\"\n\ndef template_state(data):\n rows = data.get(\"templates\", [])\n titles = [r.get(\"title\") for r in rows]\n has_html = any(bool(r.get(\"html_marker\")) for r in rows)\n has_css = any(bool(r.get(\"css_marker\")) for r in rows)\n if titles == [\"newfi_portal\"] and not has_html and not has_css:\n return \"OLD\"\n if titles == [\"newfi_portal\", \"newfi_portal.less\"] and has_html and has_css:\n return \"NEW\"\n return \"DRIFT\"\n\nemit(\"NEWFI39_BEGIN\")\nemit(\"COMMAND_ID\", CID)\nemit(\"RCA_TARGET_FAILED_COMMAND\", FAILED_CID)\nemit(\"PORTAL200_ROADMAP_REMAINS\", 22)\nemit(\"RCA_MUTATION_SCOPE\", \"NONE_READ_ONLY\")\n\nif WRAPPER.is_file() and not WRAPPER.is_symlink():\n raw = WRAPPER.read_bytes()\n emit(\"RCA_WRAPPER_BYTES\", len(raw))\n emit(\"RCA_WRAPPER_SHA256\", hashlib.sha256(raw).hexdigest())\n text = raw.decode(\"utf-8\", \"replace\")\n keep_prefixes = (\n \"CHAT_OUTPUT_BEGIN\", \"CHAT_OUTPUT_END\", \"COMMAND_ID=\", \"STATUS=\", \"RC=\",\n \"RESULT_CONTRACT_\", \"COMMAND_RC=\", \"CHANGES_MADE=\", \"ROLLBACK_\",\n \"MUTATION_OUTCOME=\", \"OUTPUT_BEGIN\", \"OUTPUT_END\", \"NEWFI200\",\n \"PORTAL200_\", \"HOMELAB_RESULT_CONTRACT=\", \"STOP=\", \"Traceback\",\n )\n selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]\n emit(\"RCA_WRAPPER_SELECTED_LINE_COUNT\", len(selected))\n print(\"RCA_WRAPPER_SELECTED_BEGIN\", flush=True)\n for line in selected[-250:]:\n print(line, flush=True)\n print(\"RCA_WRAPPER_SELECTED_END\", flush=True)\nelse:\n emit(\"RCA_WRAPPER_MISSING\", True)\n\n_, branch, _ = git(\"branch\", \"--show-current\")\n_, head, _ = git(\"rev-parse\", \"HEAD\")\n_, remote_out, _ = git(\"ls-remote\", \"origin\", f\"refs/heads/{BRANCH}\")\n_, status, _ = git(\"status\", \"--porcelain=v1\", \"--untracked-files=all\")\nremote = remote_out.split()[0] if remote_out else \"\"\n\ngit_state = {\n \"branch\": branch,\n \"head\": head,\n \"remote\": remote,\n \"clean\": (status == \"\"),\n \"status_lines\": status.splitlines()[:20],\n}\nemit(\"RCA_GIT_STATE\", git_state)\n\nstaging = qga(\"staging\", INSPECT)\nprod = qga(\"prod\", INSPECT)\n\nemit(\"RCA_STAGING_STATE\", staging)\nemit(\"RCA_PROD_STATE\", prod)\n\ns_src = source_state(staging) if isinstance(staging, dict) else \"UNKNOWN\"\np_src = source_state(prod) if isinstance(prod, dict) else \"UNKNOWN\"\ns_tpl = template_state(staging) if isinstance(staging, dict) else \"UNKNOWN\"\np_tpl = template_state(prod) if isinstance(prod, dict) else \"UNKNOWN\"\n\nsummary = {\n \"git_old_head\": head == OLD_HEAD and remote == OLD_HEAD,\n \"git_clean\": status == \"\",\n \"staging_source\": s_src,\n \"staging_templates\": s_tpl,\n \"staging_error_max\": staging.get(\"application\", {}).get(\"error_max_id\") if isinstance(staging, dict) else None,\n \"prod_source\": p_src,\n \"prod_templates\": p_tpl,\n \"prod_error_max\": prod.get(\"application\", {}).get(\"error_max_id\") if isinstance(prod, dict) else None,\n}\nsummary[\"clean_old_prestate\"] = bool(\n summary[\"git_old_head\"]\n and summary[\"git_clean\"]\n and s_src == \"OLD\"\n and s_tpl == \"OLD\"\n and p_src == \"OLD\"\n and p_tpl == \"OLD\"\n)\nemit(\"RCA_STATE_SUMMARY\", summary)\n\nemit(\"HOMELAB_RESULT_CONTRACT\", {\n \"version\": 1,\n \"command_id\": CID,\n \"status\": \"OK\",\n \"changes_made\": False,\n \"rollback_started\": False,\n \"rollback_restored\": None\n})\nemit(\"NEWFI39_END\")\n\nCANDIDATE_LAYER_6_TEXT_END\nRECOVERED_VERSION_IDS=\nRECOVERED_VERSION_STRINGS==\nRECOVERED_SHA256_COUNT=11\nRECOVERED_SHA256=458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e,834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3,28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b,fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5,2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143,b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d,619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d,4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d,1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861,53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3,c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf\nRECOVERY_RESULT=STRONG_APPLY3_CANDIDATE_EVIDENCE_FOUND\nLOCAL_APPLY3_CARRIER_RECOVERY5_END=true\n"
|
||
}
|