1
0
Files
homelab-public-context/runtime/latest.json
T
2026-09-07 20:27:19 +00:00

39 lines
191 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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"
}