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

5753 lines
180 KiB
Plaintext
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.
CHAT_OUTPUT_BEGIN
COMMAND_ID=NEWFI-260907-A-PORTAL200-APPLY4DESIGN5
STATUS=OK
RC=0
HOST=pve01
MODE=read-only
COMPONENT=newfi-portal200-apply4-local-carrier-recovery5
REFERENCE_REGISTER_CHECK=OK
REFERENCE_SHA256=5a3d8e5154c41cb582a4e0aca68090be1f0138918bf82131a948df326f9d8d66
ERROR_REGISTER_CHECK=OK
ERROR_REGISTER_SHA256=3b09a553ec0f527ed3afeed4753f52a74ee3036045bbfb3c685e9f8af4ba7ba0
COMMAND_SHA256=d8972287e5a04bda3fc17acba988b2651b92ce4193aa3038f19cf27372093f65
DUPLICATE_FAILED_COMMAND_BLOCKED=false
EXECUTION_STARTED=true
CHANGE_DECLARED=false
RESULT_CONTRACT_VALID=true
RESULT_CONTRACT_STATUS=NOT_APPLICABLE
RESULT_CONTRACT_ERROR=NONE
COMMAND_RC=0
CHANGES_MADE=false
ROLLBACK_STARTED=false
ROLLBACK_RESTORED=null
MUTATION_OUTCOME=NO_MUTATION
SANITIZED=yes
SECRETS_INCLUDED=no
PRIVATE_ADDRESSES_INCLUDED=no
RAW_EVIDENCE_SHA256=58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f
SANITIZED_OUTPUT_SHA256=58a32ed77e16ff3fbab7bcfac43e592c6e2daf2a7cf6208e7a229a258b6e3a3f
OUTPUT_BEGIN
LOCAL_APPLY3_CARRIER_RECOVERY5_BEGIN=true
COMMAND_ID=NEWFI-260907-A-PORTAL200-APPLY4DESIGN5
MUTATION_SCOPE=NONE_READ_ONLY
LOCAL_MUTATIONS_PERFORMED=NO
TARGET_OLD_COMMAND_ID=NEWFI-260907-PORTAL200-APPLY3
SEED_COUNT=41
SEED_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
SEED_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
DECODED_LAYER_COUNT=64
STRONG_LAYER_COUNT=3
SOURCE_LAYER_COUNT=9
CHOSEN_LAYER_COUNT=9
CANDIDATE_LAYER_1_SOURCE=file:NEWFI.txt
CANDIDATE_LAYER_1_SHA256=869d6fdf2d4ac222d9ab4b83242563676f201e6ab21b84360fa73fc7f9bf7195
CANDIDATE_LAYER_1_BYTES=57343
CANDIDATE_LAYER_1_TEXT_BEGIN
ПОЛНЫЙ ПАРАЛЛЕЛЬНЫЙ HANDOFF: NEWFI / PORTAL200
Дата сборки: 2026-09-07
Назначение: начать работу сразу в двух чатах без повторного восстановления контекста.
===============================================================================
0. КРИТИЧЕСКОЕ РАЗДЕЛЕНИЕ ПРОЕКТОВ
===============================================================================
Приложенный handoff второго чата относится к KINGOFWOLK CSP.
KINGOFWOLK:
- agreed safe CSP stage полностью закрыт;
- shared counter = 0;
- final accepted checkpoint = KOW-260907-A-FINAL1;
- его product-state НЕ переносить в NEWFI.
Из KINGOFWOLK для NEWFI переиспользуются только проверенные operational rules:
- true-parallel read-only direct bridge;
- один writer для любых изменений;
- Pageant -> Plink -> pve01 transport;
- authority checks;
- homelab-chat-run contracts;
- immutable Gitea readback;
- unique COMMAND_ID;
- fail-closed drift handling;
- destructive Git / raw SQL mutation prohibition.
Полный KINGOFWOLK handoff приложен ниже как APPENDIX A.
===============================================================================
1. АКТИВНЫЙ ПРОЕКТ
===============================================================================
Проект: NEWFI.
Активный участок: Portal200.
Сейчас НЕ продолжаем KingOfWolk и НЕ занимаемся Linux.
Текущая рабочая проблема:
предыдущая change-команда Portal200 APPLY3 не является accepted checkpoint.
После неё выполнены read-only RCA/discovery шаги.
===============================================================================
2. AUTHORITATIVE GITEA RUNTIME
===============================================================================
Runtime repo:
homelab-admin/homelab-public-context
Branch:
assistant-runtime
Authoritative exact readback:
runtime/history/<COMMAND_ID>.txt
Не полагаться на generic latest без точного COMMAND_ID.
Ключевые текущие NEWFI checkpoints:
A) NEWFI-260907-PORTAL200-APPLY3
STATUS=FAIL
RC=76
HOST=pve01
MODE=change
COMPONENT=newfi-portal200-deploy
Критично:
RESULT_CONTRACT_VALID=false
RESULT_CONTRACT_STATUS=UNKNOWN
RESULT_CONTRACT_ERROR=INVALID_CONTRACT:ValueError
COMMAND_RC=1
CHANGES_MADE=unknown
ROLLBACK_STARTED=unknown
ROLLBACK_RESTORED=null
MUTATION_OUTCOME=UNKNOWN
Следствие:
APPLY3 НЕЛЬЗЯ считать ни успешным deploy, ни доказанным rollback.
Не повторять этот COMMAND_ID.
Не строить следующий change на предположении, что production/staging точно остались
в старом состоянии.
B) NEWFI-260907-PORTAL200-CONTRACTAST1
STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-contract-ast-rca
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION
Назначение:
read-only RCA runner result-contract / AST после APPLY3.
Это accepted diagnostic checkpoint.
C) NEWFI-260907-PORTAL200-IMPORTDISC2
STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-import-compact-discovery
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION
Назначение:
compact read-only discovery import path + runner-contract requirements.
Из опубликованного evidence видно, что runner contract жёстко валидирует как минимум:
- поддерживаемую version;
- command_id exact match;
- status только OK / BLOCKED / FAIL;
- changes_made обязан быть boolean.
При APPLY4 не импровизировать contract JSON:
сначала перечитать CONTRACTAST1/IMPORTDISC2 и runner source.
D) NEWFI-260907-PORTAL200-REBUILDHELP1
STATUS=OK
RC=0
MODE=read-only
COMPONENT=newfi-portal200-rebuild-help
CHANGES_MADE=false
MUTATION_OUTCOME=NO_MUTATION
Ключевой вывод:
XenForo CLI поддерживает штатный:
xf:addon-rebuild [options] [--] <id>
Arguments:
id = Add-On ID
Option:
-f, --force
Skip verifying that the add-on is rebuildable
Следствие:
старый ручной PHP/import loop не должен быть механизмом следующего deploy.
Следующий candidate должен опираться на штатный XenForo add-on rebuild,
НО exact Add-On ID сначала должен быть подтверждён read-only evidence.
Не hardcode Newfi/Portal только по памяти.
===============================================================================
3. ГЛАВНЫЙ ВЫВОД ПО APPLY3
===============================================================================
APPLY3 имеет ДВЕ независимые проблемы, которые нельзя смешивать:
1. RUNNER / PUBLICATION CONTRACT FAILURE
Runner отклонил result contract:
INVALID_CONTRACT:ValueError.
2. PRODUCT MUTATION STATE НЕ ДОКАЗАН
Outer runtime показывает:
CHANGES_MADE=unknown
ROLLBACK_STARTED=unknown
MUTATION_OUTCOME=UNKNOWN
Поэтому правильная последовательность сейчас:
НЕ:
"сразу повторить deploy как APPLY4"
А:
- параллельно восстановить current prestate;
- параллельно построить корректный APPLY4 design;
- change запускать только после read-only PASS prestate.
===============================================================================
4. ПАРАЛЛЕЛЬНАЯ МОДЕЛЬ ДЛЯ NEWFI
===============================================================================
Использовать проверенную KOW concurrency model.
LANE A = SINGLE WRITER / DEPLOY DESIGN
LANE B = TRUE PARALLEL READ-ONLY AUDITOR
Разрешено одновременно:
- Lane A читает Git/Gitea/reference и строит новый carrier;
- Lane B выполняет direct read-only audit;
- оба читают immutable Gitea history.
Запрещено:
- два change одновременно;
- два git commit/push writer;
- два application/XenForo mutation path;
- повтор failed COMMAND_ID;
- Lane B mutation.
На момент фактического APPLY4:
Lane A — единственный writer.
Lane B не должен запускать новый state-sensitive snapshot в середине mutation,
кроме специально спроектированного concurrent monitor с drift guards.
===============================================================================
5. COMMAND_ID FAMILY ДЛЯ НОВОЙ ПАРАЛЛЕЛЬНОЙ СЕССИИ
===============================================================================
Чтобы не путать старую историю:
Lane A:
NEWFI-260907-A-PORTAL200-...
Lane B:
NEWFI-260907-B-PORTAL200-...
Каждый attempt:
новый COMMAND_ID.
Не reuse:
NEWFI-260907-PORTAL200-APPLY3
или любой другой failed ID.
===============================================================================
6. LANE B — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ЗАПУСКАТЬ СРАЗУ
===============================================================================
Цель:
POST-APPLY3 CURRENT-STATE RECOVERY AUDIT.
Только read-only.
Lane B должен доказать:
1. Authority:
/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md
readable + nonempty + прочитаны.
2. Runner:
current /usr/local/sbin/homelab-chat-run SHA;
exact result-contract schema;
duplicate command semantics.
3. Git:
authoritative NEWFI repo/worktree;
branch;
local HEAD;
remote HEAD;
clean/dirty;
relevant Portal200 files + hashes;
no unknown local mutation.
4. XenForo environment:
exact production/staging VM/CT IDs;
exact docroots;
exact XenForo versions;
service health;
current addon list.
5. Exact Portal Add-On ID:
resolve from add-on metadata/live XF state.
Do not mutate.
Candidate methods:
- xf:addon-list;
- addon.json;
- xf_addon read-only SELECT;
- existing deployment metadata.
6. Current Portal add-on state:
installed/active/version;
pending rebuild/install state if observable read-only;
relevant cache/code-cache presence;
current route behavior.
7. APPLY3 aftermath:
whether product files differ from accepted pre-APPLY3 baseline;
whether DB/install state changed;
whether staging changed;
whether production changed;
whether rollback evidence exists.
8. Health:
Nginx / PHP-FPM / MariaDB / related app services;
HTTP codes for important NEWFI routes.
9. DB:
SELECT only.
No UPDATE/INSERT/DELETE/ALTER.
10. Final decision:
ELIGIBLE_FOR_PORTAL200_APPLY4_PREP
or exact HOLD_...
Lane B output should be bounded and sanitized.
No secrets/private credentials.
===============================================================================
7. LANE A — ПЕРВАЯ ЗАДАЧА, КОТОРУЮ МОЖНО ДЕЛАТЬ ПАРАЛЛЕЛЬНО
===============================================================================
Lane A НЕ должен ждать Lane B, чтобы начать design.
Он может сразу:
1. перечитать:
APPLY3
CONTRACTAST1
IMPORTDISC2
REBUILDHELP1
2. перечитать:
/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md
3. найти authoritative NEWFI Git worktree / branch / Portal200 deployment source.
4. построить APPLY4 carrier OFFLINE / PREP ONLY.
5. В APPLY4 заранее исправить:
- result contract;
- official XenForo rebuild path;
- fail-closed prestate;
- explicit rollback semantics;
- exact mutation accounting;
- exact COMMAND_ID;
- publication/readback.
6. Не запускать APPLY4 mutation, пока Lane B не подтвердил current-state eligibility.
===============================================================================
8. APPLY4 — ЧТО ДОЛЖНО БЫТЬ В ДИЗАЙНЕ
===============================================================================
APPLY4 не должен быть слепым rerun APPLY3.
Обязательные свойства:
A. PRESTATE
- authority checks;
- runner SHA;
- exact Git local/remote/clean;
- exact expected source bytes/hashes;
- exact current Portal add-on state;
- services healthy;
- target environment identity;
- no unexplained post-APPLY3 drift.
B. ADD-ON IMPORT / REBUILD
- use native XenForo command:
xf:addon-rebuild <CONFIRMED_ADDON_ID>
- use -f only if evidence/design shows it is needed;
- do not return to manual PHP import loop.
C. RESULT CONTRACT
Must match runner schema exactly.
At minimum:
- supported version;
- exact command_id;
- status in OK/BLOCKED/FAIL;
- changes_made boolean;
- rollback fields with correct types.
Do not emit unknown where boolean is required.
D. FAIL CLOSED
If prestate is not exact:
BLOCKED / no mutation.
E. ROLLBACK
Before mutation:
- prove rollback target/material exists;
- preserve previous product/app state;
- rollback on any failed verification;
- verify rollback result.
F. VERIFICATION
After candidate:
- XenForo CLI exit;
- add-on version/state;
- relevant DB rows read-only;
- routes;
- services;
- XenForo error log;
- no unexpected file drift.
G. GIT
If Git promotion belongs in this Portal200 stage:
- one writer;
- explicit path staging;
- git diff --check;
- no git add .;
- no reset --hard;
- no git clean -fd;
- no force push;
- verify local == remote after push.
H. PUBLICATION
Exact runtime/history/<CID>.txt must be readable and match COMMAND_ID.
===============================================================================
9. ЧТО НЕЛЬЗЯ ДЕЛАТЬ
===============================================================================
- Не считать APPLY3 успешным.
- Не считать APPLY3 rollback успешным.
- Не reuse APPLY3 ID.
- Не запускать raw SQL mutation.
- Не запускать manual PHP import loop, пока native rebuild доказан.
- Не hardcode add-on ID без live/source evidence.
- Не делать git reset --hard.
- Не делать git clean -fd.
- Не force push.
- Не запускать два writer.
- Не принимать generic latest как authoritative без exact COMMAND_ID.
- Не использовать KOW VM/IP/path как NEWFI environment.
- Не переносить KOW product state в NEWFI.
===============================================================================
10. WINDOWS / SSH TRANSPORT — ПЕРЕИСПОЛЬЗУЕМ ИЗ KOW
===============================================================================
Plink:
C:\Program Files\PuTTY\plink.exe
Pageant:
C:\Program Files\PuTTY\pageant.exe
SSH key:
<USERPROFILE>\Documents\ssh\homelab_ed25519.ppk
Encrypted PPK.
<REDACTED_SENSITIVE_LINE>
Normal transport:
CMD
-> PowerShell 5.1
-> Start-Process
-> Plink -batch -agent -ssh -P 22 -m <remotePath>
-> root@<PRIVATE_IPV4>
-> pve01
-> direct read-only bridge OR homelab-chat-run
-> Gitea runtime publication
-> exact immutable readback
Do not use:
- MobaXterm;
- -i in normal carrier;
- pause;
- Notepad;
- blind host-key acceptance.
PowerShell:
separate stdout/stderr;
-NoNewWindow;
-Wait;
-PassThru;
real ExitCode.
Remote command file:
UTF-8 no BOM;
one physical line;
SHA verified.
Large payload:
zlib + base64;
SHA256;
python3 -I -B.
===============================================================================
11. AUTHORITY CHECKS
===============================================================================
Перед серьёзной server command:
/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md
Оба:
readable;
nonempty;
прочитать.
Visibly emit:
ERROR_REGISTER_CHECK=OK
REFERENCE_CHECK=OK
AUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED
Fail -> STOP.
===============================================================================
12. GITEA READBACK
===============================================================================
Authoritative:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.txt
JSON, если нужен:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/<COMMAND_ID>.json
Нельзя делать вывод по runtime/latest.txt,
если COMMAND_ID не совпадает.
===============================================================================
13. КАК СИНХРОНИЗИРОВАТЬ ДВА ЧАТА
===============================================================================
После каждого accepted checkpoint:
1. чат публикует exact COMMAND_ID;
2. второй чат читает immutable Gitea history;
3. оба обновляют общий current-state;
4. FAIL/RCA не считать product progress;
5. PASS read-only может закрывать audit/preflight;
6. mutation checkpoint только Lane A.
Если Lane A сделал change:
Lane B перед следующим audit обязан заново прочитать этот exact checkpoint.
Если Lane B обнаружил HOLD:
Lane A не запускает mutation до разбора HOLD.
===============================================================================
14. PROMPT ДЛЯ ЧАТА A — SINGLE WRITER
===============================================================================
Ты LANE A / SINGLE WRITER для NEWFI Portal200.
Используй Gitea assistant-runtime как machine truth.
Не занимайся KingOfWolk: KOW CSP agreed stage уже CLOSED.
Current NEWFI checkpoints:
- APPLY3 FAIL, invalid result contract, mutation outcome UNKNOWN.
- CONTRACTAST1 OK read-only.
- IMPORTDISC2 OK read-only.
- REBUILDHELP1 OK read-only.
- native XenForo command xf:addon-rebuild [options] <id> подтверждён.
Твоя задача:
параллельно с Lane B спроектировать NEWFI Portal200 APPLY4,
но НЕ запускать mutation, пока Lane B не вернёт
ELIGIBLE_FOR_PORTAL200_APPLY4_PREP.
APPLY4:
- новый unique COMMAND_ID family NEWFI-260907-A-PORTAL200-...
- exact authority checks;
- current-state preconditions;
- correct homelab-chat-run result contract;
- native xf:addon-rebuild with exact live-confirmed add-on ID;
- fail closed;
- rollback proof;
- verification;
- single writer;
- exact Gitea publication/readback.
Перед каждой реальной mutation заново прочитай последние Lane A + Lane B checkpoints.
===============================================================================
15. PROMPT ДЛЯ ЧАТА B — READ-ONLY AUDITOR
===============================================================================
Ты LANE B / STRICT READ-ONLY AUDITOR для NEWFI Portal200.
Никаких Git/application/DB/production mutations.
Используй proven bridge:
direct read-only phase outside homelab-chat-run;
serialized sanitized publication through runner.
COMMAND_ID family:
NEWFI-260907-B-PORTAL200-...
Сразу выполни post-APPLY3 current-state recovery audit:
- authority checks;
- runner SHA + contract;
- authoritative Git local/remote/clean;
- exact NEWFI staging/prod identity;
- exact XenForo docroot/version;
- exact Portal Add-On ID;
- installed/version/rebuildable state;
- APPLY3 aftermath;
- file/source hashes;
- service health;
- relevant HTTP routes;
- XF error log state;
- read-only DB evidence;
- no mutation.
Final decision:
ELIGIBLE_FOR_PORTAL200_APPLY4_PREP
or exact HOLD_...
Публикуй bounded sanitized evidence в immutable Gitea history.
После публикации сообщи exact COMMAND_ID.
===============================================================================
16. РЕКОМЕНДУЕМЫЙ СТАРТ СЕЙЧАС
===============================================================================
Лучший быстрый порядок:
NOW:
- текущий чат = Lane A;
- новый второй чат = Lane B.
Одновременно:
Lane A готовит APPLY4 design.
Lane B снимает current-state recovery audit.
Когда Lane B PASS:
Lane A сверяет immutable evidence и запускает единственный change APPLY4.
После APPLY4:
Lane B выполняет независимый read-only post-change verification,
если это реально добавляет evidence и не дублирует уже встроенный rollback/canary.
===============================================================================
17. COUNTER / ROADMAP
===============================================================================
Не использовать KingOfWolk counter.
У KOW counter = 0 и scope закрыт.
Для NEWFI ранее использовалась рабочая оценка оставшихся команд,
но её нельзя считать authoritative runtime state без восстановления NEWFI roadmap.
Первый Lane B recovery audit должен также найти/проверить текущую NEWFI roadmap/
accepted-stage count, если он хранится в repo/reference.
До этого не печатать ложный точный countdown.
===============================================================================
18. SOURCE-OF-TRUTH PRIORITY
===============================================================================
При конфликте:
1. /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
2. /etc/pve/31_HOMELAB_REFERENCE.md
3. exact immutable Gitea runtime/history/<CID>.txt
4. authoritative Git source/current live state
5. этот handoff
Этот handoff ускоряет старт, но не перекрывает более свежий live/Gitea evidence.
===============================================================================
APPENDIX A
ПОЛНЫЙ HANDOFF ИЗ ВТОРОГО ЧАТА: KINGOFWOLK CSP
(сохранён целиком; используется для operational/concurrency rules, не как NEWFI product-state)
===============================================================================
ПОЛНЫЙ HANDOFF: KINGOFWOLK CSP
Дата контекста: 2026-09-07
1. СТАТУС ПРОЕКТА НА МОМЕНТ HANDOFF
Проект: KingOfWolk forum CSP.
Shared counter:
0 команд до полного финиша.
Важно: 0 означает, что согласованный безопасный CSP-stage полностью завершён и подтверждён evidence. Это не означает, что CSP доведён до теоретически максимальной жёсткости.
Отдельно оставлены будущие hardening-задачи:
- удаление style-src 'unsafe-inline';
- рефакторинг существующего javascript: URL;
- перевод script-src-elem из Report-Only в enforcing после достаточного периода наблюдения.
Финальный accepted checkpoint:
KOW-260907-A-FINAL1
STATUS=OK
RC=0
DECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED
Финальный canary не внёс изменений в production.
2. CANONICAL KINGOFWOLK ENVIRONMENT
Форум:
Domain:
https://kingofwolk.ru
Forum VM:
VM160
host: pve02
IP: <PRIVATE_IPV4>
XenForo:
2.3.12
XF id: 2031270
Database:
xf_kingofwolk
Document root:
/var/www/forums/kingofwolk/public
XF source:
/var/www/forums/kingofwolk/src/XF
Из ранее принятого baseline:
boardActive = 1
board title = KingOfWolk
style id = 2
style name = KingOfWolk Dark
CAPTCHA = Cloudflare Turnstile
friendly URLs = enabled
third-party add-ons = none
users = 2
admins = 1
nodes = 13
threads = 73
posts = 73
attachments = 0
XF errors = 0
Origin-side services были healthy:
Nginx
MariaDB
php8.3-fpm
CSP реализуется на edge, а не origin XenForo.
3. EDGE / NPMPLUS
Edge находится на:
VM130
host: pve03
NPMplus:
container: npmplus
image: zoeyvid/npmplus:2026-06-17-b1
Live KingOfWolk proxy config:
/opt/npmplus/nginx/proxy_host/996.conf
Metadata:
owner: root:root
mode: 0644
CSP collector log:
/opt/npmplus/csp-reports/kingofwolk-csp-report.log
Log metadata:
root:root
0600
Directory:
0700
Collector endpoint:
/__csp-report-v1
Semantics:
GET -> 405
Allow -> POST
Collector поддерживает:
application/csp-report
application/reports+json
application/json
Collector sanitizes:
- script-sample;
- query/fragment у URI.
Rotation threshold:
>= 5 MiB
Финальный canary снова подтвердил:
collector GET = 405
Allow = POST
4. GIT — AUTHORITATIVE SOURCE
Используемый repo:
homelab-admin/homelab-ops
Изолированный CSP worktree:
/root/kingofwolk-csp-baseline-20260907
Branch:
forum/kingofwolk-csp-baseline-20260907
Не использовать /srv/homelab-ops для изменений KingOfWolk CSP.
Исторически он был dirty/diverged и использовался только как reference.
Clone-local Git identity:
user.name = homelab-ops-bootstrap
user.email = root@pve01.gram1.ru
Текущий финальный accepted HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
Parent:
3e5d8584ae6887c292cc910c5796e73d8d20aecd
Commit message:
csp(kingofwolk): stage script nonce and enforce font media
Текущий source file:
deploy/forums/kingofwolk/edge/996.conf
Текущий source SHA / production edge SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
Финальный canary подтвердил:
Git local HEAD = 6f584c5faa76d0067b08977aeb583fa0e4de14aa
Git remote HEAD = 6f584c5faa76d0067b08977aeb583fa0e4de14aa
worktree clean = true
И edge до/после финального canary оставался:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
5. ИСТОРИЯ ACCEPTED GIT CSP COMMITS
Последовательность основных accepted commits:
6908d4ebf2552244a68e3c0990fdc3a219c05d2c
initial CSP baseline docs
f8d1ae6981df35f25d09a41392bc0a26742b7f38
JoyPixels Report-Only allowance
34f9c76c5cbf81f9cdf7f50d1ac501cf870abc9c
minimal structural enforcement source
c7a8a68a74fd3eb74cfb708b6bd3b866159b4899
csp(kingofwolk): enforce same-origin form submissions
Его source SHA:
834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3
Следующий:
e4635c9997801a0ea65aad727270c88ee3bdb817
csp(kingofwolk): enforce PWA self sources
Source SHA:
28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b
Следующий:
3e5d8584ae6887c292cc910c5796e73d8d20aecd
csp(kingofwolk): enforce image and network sources
Source SHA:
fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5
Финальный текущий commit:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
csp(kingofwolk): stage script nonce and enforce font media
Source SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
6. ТЕКУЩИЙ PRODUCTION ENFORCING CSP
Финальный enforcing CSP:
base-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';
В одной строке:
base-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';
Что намеренно НЕ enforcing:
default-src
script-src
script-src-elem
script-src-attr
style-src
style-src-elem
style-src-attr
Также enforcing policy не содержит:
'unsafe-inline'
'unsafe-eval'
Это намеренно: script/style hardening ещё не переведён в production enforcement.
7. ТЕКУЩИЙ PRODUCTION REPORT-ONLY CSP
Source-template на edge содержит Nginx variable $request_id.
Логическая policy:
default-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;
Точная source-строка:
default-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;
При HTTP response $request_id уже заменён Nginx на фактический per-request nonce.
8. NGINX NONCE TRANSPORT
Текущий source также содержит transport для nonce-инъекции.
Ключевые строки:
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter '<script' '<script nonce="$request_id"';
Зачем отключено upstream compression:
sub_filter должен видеть HTML body в несжатом виде.
До production promotion был отдельно доказан forwarded-origin request:
HTTP = 200
Content-Encoding = NONE
Nonce source:
$request_id
В Report-Only header используется:
'nonce-$request_id'
В HTML тот же $request_id инъектируется в каждый <script> tag.
9. ФИНАЛЬНЫЙ NONCE EVIDENCE
Финальный checkpoint:
KOW-260907-A-FINAL1
проверил HTML маршруты дважды:
/
/login/
/register/
/help/smilies/
/threads/dobro-pozhalovat-na-ofitsial-nyi-forum-kingofwolk.1/
Для каждого запроса проверялось:
HTTP = 200
ровно один nonce в CSP Report-Only
nonce не содержит literal $
nonce имеет допустимый формат
каждый <script> имеет ровно один nonce
nonce каждого <script> == nonce из response CSP header
raw nonce count == parsed script count
Дополнительно:
nonce не должен повторяться между двумя запросами одного route
nonce не должен совпадать ни с одним nonce других canary requests
Финальный результат:
KOWAFINAL1_NONCE_UNIQUE_ACROSS_ALL_REQUESTS=true
KOWAFINAL1_NONCE_VALUES_EXPORTED=NO
То есть сами nonce наружу не публиковались.
10. CSP REPORT LOG BASELINE
Принятый baseline:
SHA256:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143
lines:
2
Исторически в нём:
line 1 = synthetic test
line 2 = historical real JoyPixels violation до allow-list
Этот baseline не менялся через:
- PWA stage;
- img/connect/frame stage;
- nonce design;
- font/media promotion;
- production Stage2;
- финальный browser/evidence canary.
Финальный результат:
KOWAFINAL1_NEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0
NPMplus:
before = healthy
after = healthy
11. ДРУГИЕ SECURITY HEADERS
Они не менялись и финальный canary проверил их exact match.
X-Frame-Options:
SAMEORIGIN
X-Content-Type-Options:
nosniff
Referrer-Policy:
strict-origin-when-cross-origin
Strict-Transport-Security:
max-age=31536000
Permissions-Policy:
geolocation=(), microphone=(), camera=()
Ранее жёлтые browser console warnings были связаны с Permissions-Policy, а не с CSP.
12. JOYPIXELS
Разрешён только path-scoped источник:
https://cdn.jsdelivr.net/joypixels/assets/8.0/png/unicode/64/
Не разрешён весь:
https://cdn.jsdelivr.net
Ранее dedicated evidence:
- реальный historical CSP report указывал JoyPixels;
- /help/smilies/ реально использует этот path;
- bad JoyPixels path count = 0.
Финальный canary:
KOWAFINAL1_JOYPIXELS_RENDERED_REF_COUNT=32
KOWAFINAL1_JOYPIXELS_SAMPLE_HTTP=200
13. CLOUDFLARE TURNSTILE
Turnstile остаётся обязательной runtime зависимостью для регистрации.
Нужные разрешения сохранены:
script:
https://challenges.cloudflare.com
connect:
https://challenges.cloudflare.com
frame:
https://challenges.cloudflare.com
В текущем enforcing уже есть:
connect-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
В Report-Only script policies также Cloudflare разрешён.
Критически важный урок:
не требовать статический <script src="https://challenges.cloudflare.com/..."> в исходном HTML.
Turnstile может быть динамически instantiated.
Именно из-за неправильного static-script требования ранее упал:
KOW-260907-A-ICF5
с:
TURNSTILE_SCRIPT_REF_MISSING
После исправления модели:
KOW-260907-A-ICF6
был accepted.
Финальный canary проверял наличие Turnstile/challenges runtime tokens на /register/, а не статический script tag.
14. FORM-ACTION
Этап полностью закрыт ранее.
Enforcing:
form-action 'self'
Перед promotion были проверены:
external public form origins = 0
payment profiles = 0
user upgrades = 0
provider/options related external forms = 0
Production apply был с rollback proof и browser/server canary.
Не пересматривать без новой фактической причины.
15. PWA DIRECTIVES
Полностью accepted:
worker-src 'self'
manifest-src 'self'
Закрыты checkpoints:
PWA3 audit
PWA4 preflight
PWA5 Git promotion
PWA6 production + rollback proof
PWA9 final recovery/readback
Исторический transport bug:
PWA7 browser canary сам по себе был PASS,
но outer publication сломался.
PWA8 RCA:
publisher ошибочно ожидал evidence path в sys.argv[1].
После этого правило:
publisher должен использовать literal evidence path, а не рассчитывать, что sys.argv[1] содержит путь.
16. IMG/CONNECT/FRAME STAGE
Полностью закрыт.
Текущие enforcing directives:
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;
Ключевые accepted checkpoints:
KOW-260907-A-ICF2
STATUS=OK
DECISION=ELIGIBLE_FOR_IMG_CONNECT_FRAME_SOURCE_PREP
Evidence:
CSS bytes scanned = 297724
stylesheets = 9
CSS url refs = 245
origins = DATA / SELF
Git promotion:
KOW-260907-A-ICF3
Commit:
3e5d8584ae6887c292cc910c5796e73d8d20aecd
Production apply:
KOW-260907-A-ICF4
Rollback proof passed.
Browser recovery:
KOW-260907-A-ICF6
DECISION=IMG_CONNECT_FRAME_ENFORCEMENT_POST_BROWSER_CANARY_CLEAN
17. INLINE SCRIPT/STYLE AUDIT — ИСХОДНЫЕ ВЫВОДЫ
До nonce stage:
rendered nonce attrs = 0
native nonce source signals = 0
KOW-260907-A-INLINE1 показал по 5 guest routes:
external_scripts = 35
inline executable scripts = 5
inline non-executable scripts = 3
inline script tags total = 8
style attributes = 31
javascript: URLs = 6 occurrences
distinct javascript: values later narrowed to 1
event-handler attrs = 0
script nonce attrs = 0
style nonce attrs = 0
style tags = 0
Основной decision:
HOLD_UNSAFE_INLINE_APPLICATION_NONCE_HASH_DESIGN_REQUIRED
18. ПОЧЕМУ СТАТИЧЕСКИЕ HASHES ДЛЯ INLINE SCRIPTS БЫЛИ ОТКЛОНЕНЫ
KOW-260907-A-INLINE2:
STATUS=OK
RC=0
DECISION=
APPLICATION_TEMPLATE_REFACTOR_DESIGN_REQUIRED
Было установлено:
EXEC_SCRIPTS_WITH_DYNAMIC_MARKERS=5
GLOBAL_DISTINCT_EXEC_SCRIPT_BODIES=5
GLOBAL_DISTINCT_STYLE_ATTR_VALUES=7
GLOBAL_DISTINCT_JAVASCRIPT_URLS=1
GLOBAL_DISTINCT_EVENT_HANDLER_VALUES=0
То есть все 5 executable inline scripts имеют динамические признаки.
Поэтому:
- хеширование уже rendered script bodies не использовать;
- не публиковать и не хешировать dynamic rendered inline values;
- nonce оказался более подходящим механизмом для <script> elements.
19. STYLE ATTRIBUTES
Было обнаружено:
31 style attributes
7 distinct style attribute values
Они оказались стабильными между guest renders и не сработал secretish gate.
Но removal style-src 'unsafe-inline' не включён в текущий завершённый scope.
Причина:
nonce работает для <style> / <script> elements, но не решает обычные style="" attributes.
Потенциальные будущие пути:
- template/application refactor style attrs;
- либо отдельный строго доказанный style-src-attr / 'unsafe-hashes' design.
На текущем production:
Report-Only:
style-src 'self' 'unsafe-inline';
Enforcing style-src отсутствует.
20. JAVASCRIPT: URLS
Исходно:
6 occurrences
1 distinct value
event handlers = 0
В процессе анализа было решено:
JAVASCRIPT_URL_DESIGN=
APPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST
То есть javascript: URL не закрывается текущей CSP-stage через hash allowlist.
Было выполнено source mapping на VM160 по:
/var/www/forums/kingofwolk/internal_data/code_cache/templates
/var/www/forums/kingofwolk/src/XF
/var/www/forums/kingofwolk/public/js
Raw javascript: value наружу не публиковался.
Текущий статус:
deferred application refactor.
21. EDGE NONCE DESIGN SEQUENCE И HARNESS FAILURES
Во время разработки nonce-stage было несколько не product failures, которые важно не путать с production regressions.
KOW-260907-A-INLINE3
STATUS=FAIL
RC=23
Failed step:
origin_compression
Error:
origin_compression:RC_2
Причина:
probe зависел от Python внутри guest/runtime path
Product mutation:
NO
KOW-260907-A-INLINE4
STATUS=FAIL
RC=23
Failed step:
transform_and_hash_candidates
Error:
'str' object has no attribute 'decode'
Причина — чистый harness bug:
- transformed HTML уже был str;
- helper повторно вызывал .decode().
До падения подтвердилось:
Git clean
NPMplus healthy
nginx sub module compiled = true
nginx version = 1.31.2
proxy_pass count = 1
origin gzip = gzip
origin identity = NONE
HTTP = 200
KOW-260907-A-INLINE5
STATUS=FAIL
RC=23
Failed step:
nginx_nonce_syntax_probe_tempfile
Error:
EPHEMERAL_PROBE_FILE_NOT_CLEANED
Это был слишком строгий/неправильный cleanup harness.
Product mutation не было.
KOW-260907-A-INLINE6
STATUS=FAIL
RC=23
Failed step:
vm160_jsurl_source_map
Error:
qout() got an unexpected keyword argument 'timeout'
Опять чистый harness bug.
До падения уже было подтверждено:
distinct javascript URLs = 1
distinct style attrs = 7
event handlers = 0
Git clean
production exact
stale probe files = 0
KOW-260907-A-INLINE7
Recovery без повторения всего INLINE6.
STATUS=OK
RC=0
Он прочитал evidence INLINE6 и выполнил только недостающий VM160 source-map.
Decision:
HOLD_NONCE_STYLE_HASH_DESIGN
Но единственный HOLD был:
UPSTREAM_IDENTITY_PROBE_NOT_CLEAN
Почему:
direct upstream ответил:
HTTP=301
Content-Encoding=NONE
То есть compression уже был в порядке; harness ошибочно требовал именно HTTP==200.
KOW-260907-A-INLINE8
Исправил probe с правильными forwarded headers.
Результат:
STATUS=OK
RC=0
FORWARDED_IDENTITY_200_OK=true
FORWARDED_IDENTITY_TRANSPORT_OK=true
HOLD_REASONS=[]
DECISION=
ELIGIBLE_FOR_STAGED_SCRIPT_ELEM_NONCE_SOURCE_PREP
Это финально закрыло nonce design.
22. LANE B — FONT/MEDIA
Lane B выполнил независимый read-only audit.
Accepted checkpoint:
KOW-260907-B-FONTMEDIA4
STATUS=OK
RC=0
DECISION=
ELIGIBLE_FOR_FONT_MEDIA_SOURCE_PREP
Подтверждено:
native media attachments = 0
XFMG media = 0
[MEDIA] content = 0
board URL = https://kingofwolk.ru
При этом наличие media-site capability не считалось само по себе runtime requirement.
Lane B после этого прекратил новые checkpoints.
Lane A стал единственным writer.
23. FONT/MEDIA FINAL POLICY
После независимого Lane B audit и дополнительной precommit verification были enforcing добавлены:
font-src 'self' data:;
media-src 'self';
На финальном canary:
FONT_ORIGINS=[]
для непосредственно проверенных текущих страниц, то есть никаких новых внешних font runtime origins выявлено не было.
Ранее dedicated audit уже доказал допустимость SELF/DATA.
Rendered media origins также не потребовали дополнительного allow-list.
24. KOW-260907-A-STAGE1 — GIT PROMOTION
Команда:
KOW-260907-A-STAGE1
MODE=change
COMPONENT=kingofwolk-csp-font-media-nonce-stage-git-promotion
Выполнила перед mutation:
- exact Git branch/head/remote/clean checks;
- production old edge SHA check;
- CSP log baseline;
- NPMplus health;
- font/media audit;
- route checks;
- DB read-only media checks;
- repository validation;
- unit tests.
После этого построила exact candidate:
1. enforcement:
+ font-src 'self' data:;
+ media-src 'self';
2. Report-Only:
+ script-src-elem 'self' 'nonce-$request_id' https://challenges.cloudflare.com;
3. transport:
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter '<script' '<script nonce="$request_id"';
Не менялись:
style-src 'unsafe-inline'
base script-src 'unsafe-inline'
javascript: behavior
Repository validation:
PASS
Tests:
PASS
Новый Git HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
Новый source SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
Local == remote.
Во время Stage1 production оставался на старом SHA.
25. KOW-260907-A-STAGE2 — PRODUCTION APPLY
KOW-260907-A-STAGE2
STATUS=OK
RC=0
MODE=change
RESULT_CONTRACT_STATUS=OK
CHANGES_MADE=true
Перед apply были проверены:
runner SHA
Git branch/head/parent/message
source exact bytes
production old SHA
live file owner/mode
CSP log baseline
NPMplus health
nginx -t
public old CSP exact
Runner baseline SHA:
b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d
Stage2 sequence:
Phase 1 — APPLY1
Установлен новый candidate.
Проверены:
- nginx -t;
- reload;
- routes;
- exact enforcing CSP;
- dynamic Report-Only nonce;
- каждый HTML <script> получил nonce;
- nonce == header nonce;
- CSP log baseline;
- NPMplus health.
Phase 2 — ROLLBACK PROOF
Production вернули к старому:
fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5
Проверены old policies, routes, отсутствие nonce injection, health/log.
Rollback proof:
PASS
Phase 3 — FINAL REAPPLY
Candidate применён повторно.
Финальный edge:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
Git не drifted.
Итоговая reason:
FONT_MEDIA_ENFORCED_AND_SCRIPT_ELEM_NONCE_REPORT_ONLY_STAGED_WITH_ROLLBACK_PROOF
26. PRODUCTION ROLLBACK ARTIFACT
Stage2 создал manual rollback backup в NPMplus VM/container filesystem:
/opt/npmplus/manual-backups/kingofwolk-csp-font-media-nonce-stage-20260907-6f584c5/
Old config:
996.conf.before
Old SHA:
fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5
Также stage directory использовался для candidate copy.
Если когда-либо потребуется ручной emergency rollback, сначала всё равно заново проверить текущий live state и не предполагать, что этот backup всё ещё является правильной rollback target после будущих изменений.
27. KOW-260907-A-FINAL1 — ФИНАЛЬНЫЙ EVIDENCE CLOSURE
STATUS=OK
RC=0
MODE=read-only
CHANGES_MADE=false
Decision:
KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED
Closure scope:
AGREED_SAFE_CSP_STAGE
Проверено:
- Git exact local/remote/clean;
- source SHA exact;
- production edge SHA exact;
- edge metadata 0:0:644;
- CSP log baseline before;
- NPMplus healthy;
- nginx -t;
- пять HTML routes по два запроса;
- /service_worker.js;
- /webmanifest.php;
- exact security headers;
- exact enforcing CSP;
- exact dynamic Report-Only CSP;
- unique nonce across requests;
- nonce coherence для всех <script>;
- JoyPixels rendered refs + HTTP sample;
- fonts;
- media origins;
- Turnstile signal;
- collector 405/POST;
- CSP log baseline after;
- production SHA after;
- Git state after.
Финал:
NEW_CSP_REPORTS_AFTER_FINAL_BROWSER_CANARY=0
NONCE_UNIQUE_ACROSS_ALL_REQUESTS=true
NPMPLUS_HEALTH_AFTER=healthy
28. CURRENT FINAL HASHES / IDENTIFIERS — БЫСТРЫЙ БЛОК
Git repo:
/root/kingofwolk-csp-baseline-20260907
Branch:
forum/kingofwolk-csp-baseline-20260907
Current HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
Parent:
3e5d8584ae6887c292cc910c5796e73d8d20aecd
Current source:
deploy/forums/kingofwolk/edge/996.conf
Current source / production edge SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
Previous production SHA:
fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5
CSP report log SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143
CSP report log lines:
2
Runner SHA:
b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d
29. OPERATIONAL COMMAND CONTRACT
Любая новая server command по этому проекту должна сначала читать:
/etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md
/etc/pve/31_HOMELAB_REFERENCE.md
И visibly emit:
ERROR_REGISTER_CHECK=OK
REFERENCE_CHECK=OK
AUTHORITY_CHECK_SCOPE=READABILITY_ONLY_INCIDENTS_NOT_CLOSED
Обычно shell:
root@pve01:~#
Git discipline:
Перед любым change:
git status
branch
local HEAD
remote HEAD
Fail closed при неизвестном drift.
До commit:
git diff --check
Staging:
git add <explicit path>
Не использовать:
git add .
После staging:
git diff --cached --check
git diff --cached
После push:
git fetch
LOCAL_HEAD == REMOTE_HEAD
Запрещённый destructive workflow:
git reset --hard
git clean -fd
force push
Также:
не мутировать XenForo state raw SQL.
Read-only SQL допустим.
30. CONCURRENCY / LANE MODEL
Была доказана настоящая параллельность read-only lanes через direct bridge.
Checkpoint:
KOW-260907-PBR2
Decision:
ALLOW_TRUE_PARALLEL_READ_ONLY_LANES
При этом обычные full runner carriers сериализуются из-за flock.
Checkpoint:
KOW-260907-CONC1
Поэтому правила:
read-only direct bridge:
может работать параллельно
Git commit/push:
ровно один writer
edge production apply:
ровно один writer
XenForo/app mutation:
ровно один writer
Сейчас Lane B закончен.
Если CSP scope снова открыть:
текущий чат/следующий оператор должен оставаться единственным writer.
31. WINDOWS TRANSPORT
Стандартный transport:
.CMD
-> Windows PowerShell 5.1
-> Plink
-> Pageant
-> SSH pve01
-> direct bridge или homelab-chat-run
-> Gitea publication
-> readback
PuTTY:
C:\Program Files\PuTTY\plink.exe
C:\Program Files\PuTTY\pageant.exe
PPK:
<USERPROFILE>\Documents\ssh\homelab_ed25519.ppk
PPK encrypted:
aes256-cbc
<REDACTED_SENSITIVE_LINE>
<REDACTED_SENSITIVE_LINE>
Нормальный carrier:
plink
-batch
-agent
-ssh
-P 22
-m <remotePath>
root@<PRIVATE_IPV4>
Не использовать обычным carrier:
-i
Pageant должен уже держать unlocked key.
Endpoint:
pve01
root@<PRIVATE_IPV4>:22
PuTTY registry host key:
HKCU:\Software\SimonTatham\PuTTY\SshHostKeys
ssh-ed25519@22:<PRIVATE_IPV4>
32. CARRIER IMPLEMENTATION RULES
PowerShell запускает Plink через:
Start-Process
Обязательно:
separate stdout
separate stderr
real ExitCode
-NoNewWindow
Не использовать:
pause
notepad
MobaXterm
Temp diagnostics:
%TEMP%
Remote command file:
UTF-8
no BOM
one physical line
SHA verified
Большие payloads:
zlib + base64
SHA256 verification
/usr/bin/python3 -I -B
Каждый attempt:
новый COMMAND_ID
Не reuse failed ID.
Runner:
/usr/local/sbin/homelab-chat-run <CID> read-only|change <component> -- <command>
Change result contract:
OK
BLOCKED
FAIL
33. GITEA RUNTIME PUBLICATION / READBACK
Runtime repo:
homelab-admin/homelab-public-context
Branch:
assistant-runtime
Authoritative history path:
runtime/history/<COMMAND_ID>.txt
Главное правило:
не доверять generic latest без проверки COMMAND_ID.
Exact history конкретного command ID — authoritative.
Fast latest может вернуть unrelated/stale result.
34. НЕДАВНИЕ .CMD ARTIFACTS ИЗ ЭТОГО ЧАТА
Последовательность последних команд:
KOW-260907-A-INLINE2
KOW_CSP_INLINE_HASH_FEASIBILITY_AUDIT_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE3
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE4
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FEASIBILITY2_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE5
KOW_CSP_EDGE_NONCE_UNSAFE_HASHES_FINAL_FEASIBILITY_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE6
KOW_CSP_EDGE_NONCE_STYLE_HASH_JSURL_DESIGN_AUDIT_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE7
KOW_CSP_INLINE6_RECOVERY_JSURL_SOURCE_MAP_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-INLINE8
KOW_CSP_SCRIPT_ELEM_NONCE_DESIGN_CLOSURE_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-STAGE1
KOW_CSP_FONT_MEDIA_NONCE_STAGE_GIT_PROMOTION_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-STAGE2
KOW_CSP_FONT_MEDIA_NONCE_STAGE_PRODUCTION_PLINK_GITEA_MOSCOW.cmd
KOW-260907-A-FINAL1
KOW_CSP_FINAL_BROWSER_EVIDENCE_CLOSURE_PLINK_GITEA_MOSCOW.cmd
Не нужно повторно запускать их для «проверки», если нет новой причины.
При новом scope создавать новые IDs.
35. ЧТО НЕ НАДО ПОВТОРЯТЬ СЛЕДУЮЩЕМУ ОПЕРАТОРУ
Уже закрыто, не делать заново без новой фактической причины:
form-action audit
PWA worker/manifest audit
img-src audit
connect-src audit
frame-src audit
JoyPixels path audit
Turnstile static-script search
font-src/media-src discovery
inline hash feasibility
nonce feasibility
upstream identity/compression feasibility
font/media Git promotion
font/media production promotion
rollback proof
final browser/evidence closure
Особенно:
не повторять KOW-260907-B-FONTMEDIA4.
Lane B audit уже accepted.
36. ЧТО ОСТАЁТСЯ ТОЛЬКО КАК FUTURE HARDENING
Это не незавершённые команды текущего counter.
Текущий counter:
0
Future scope можно открыть отдельно.
A. script-src-elem enforcement
Сейчас:
Report-Only only
Nonce транспорт уже работает и финально доказан.
Следующий возможный будущий шаг — только после периода наблюдения CSP report log:
перенести script-src-elem nonce policy в enforcing
Но до этого надо заново проверить:
- log не накопил реальные violations;
- authenticated flows;
- Turnstile;
- admin/Account overlays, если входят в scope;
- все важные XenForo page types.
B. style-src 'unsafe-inline'
Сейчас всё ещё Report-Only:
style-src 'self' 'unsafe-inline'
Причина — style attributes.
Исходное evidence:
31 occurrences
7 distinct values
Нужен отдельный application/template design.
C. javascript: refactor
Есть:
1 distinct javascript: value
несколько rendered occurrences
Не разрешать это через сомнительный hash workaround.
Принятый design:
APPLICATION_REFACTOR_REQUIRED_NOT_HASH_ALLOWLIST
37. ТОЧКА СТАРТА ДЛЯ НОВОГО ЧАТА
Новый чат должен считать canonical current state следующим:
KINGOFWOLK CSP AGREED SAFE STAGE = CLOSED
shared counter = 0
current Git HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
current edge/source SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
CSP log SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143
CSP log lines:
2
NPMplus:
healthy
final accepted checkpoint:
KOW-260907-A-FINAL1
final decision:
KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED
Если пользователь просит «продолжить CSP», сначала надо считать, что открывается новый hardening scope, потому что прежний shared counter уже завершён.
Наиболее логичный новый порядок:
1. read-only observation / authenticated coverage для script-src-elem Report-Only
2. javascript: application refactor design
3. style attribute refactor/hash design
4. только затем новый enforcement promotion с отдельным rollback proof
Но ничего из этого не является долгом старого плана.
38. КОРОТКАЯ ИТОГОВАЯ ФОРМУЛИРОВКА ДЛЯ ВСТАВКИ В НОВЫЙ ЧАТ
KingOfWolk CSP agreed safe stage полностью закрыт. Shared counter = 0.
Финальный accepted checkpoint KOW-260907-A-FINAL1, STATUS=OK, RC=0,
DECISION=KINGOFWOLK_CSP_AGREED_STAGE_FINAL_EVIDENCE_CLOSED.
Текущий Git HEAD:
6f584c5faa76d0067b08977aeb583fa0e4de14aa
Branch:
forum/kingofwolk-csp-baseline-20260907
Worktree:
/root/kingofwolk-csp-baseline-20260907
Source:
deploy/forums/kingofwolk/edge/996.conf
Source/production edge SHA:
458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e
CSP log baseline SHA:
2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143
CSP log:
2 строки
Новых reports после финального browser-canary:
0
NPMplus:
healthy
Enforcing сейчас закрывает:
base-uri
object-src
frame-ancestors
form-action
worker-src
manifest-src
img-src
connect-src
frame-src
font-src
media-src
script-src-elem с per-request $request_id nonce находится только в Report-Only.
Nginx добавляет тот же nonce каждому <script> через sub_filter.
Финальный canary доказал unique nonce per request и полную header↔script coherence.
style-src 'unsafe-inline', существующий javascript: URL и перевод script-src-elem в enforcing оставлены как отдельный будущий hardening scope.
Lane B KOW-260907-B-FONTMEDIA4 accepted и не должен повторяться.
Любой новый change:
- один writer;
- authority checks;
- exact Git/source/edge/log prestate;
- explicit staging;
- rollback proof;
- новый COMMAND_ID.
39. ОСНОВНЫЕ AUTHORITATIVE EVIDENCE URLS
Финальный evidence:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-FINAL1.txt
Production apply:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE2.txt
Git promotion:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-STAGE1.txt
Lane B font/media:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-B-FONTMEDIA4.txt
Nonce design closure:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE8.txt
Inline feasibility:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-INLINE2.txt
Img/connect/frame final recovery:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF6.txt
Img/connect/frame production:
https://git.gram1.ru/homelab-admin/homelab-public-context/raw/branch/assistant-runtime/runtime/history/KOW-260907-A-ICF4.txt
CANDIDATE_LAYER_1_TEXT_END
CANDIDATE_LAYER_2_SOURCE=file:newfi-54-portal200-apply3.cmd::b64::zlib
CANDIDATE_LAYER_2_SHA256=97a1807a275e5d309591f7a7e044d7d56ba5b2f4c6a068df6353bae147707184
CANDIDATE_LAYER_2_BYTES=34155
CANDIDATE_LAYER_2_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
CID = "NEWFI-260907-PORTAL200-APPLY3"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
"Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
"Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
"_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
"Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
"_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"
class PortalError(RuntimeError):
pass
def emit(key, value=None):
if value is None:
print(key, flush=True)
elif isinstance(value, (dict, list, tuple, bool, int, float)):
print(
key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
flush=True,
)
else:
print(key + "=" + str(value), flush=True)
def fail(code):
raise PortalError(code)
def sha_bytes(data):
return hashlib.sha256(data).hexdigest()
def file_meta(path):
data = pathlib.Path(path).read_bytes()
return len(data), sha_bytes(data)
def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
try:
p = subprocess.run(
cmd,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
fail("SUBPROCESS_TIMEOUT")
if p.returncode not in ok:
fail("SUBPROCESS_RC")
return p
def git(*args, ok=(0,), timeout=120):
p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
return p.stdout.decode("utf-8", "replace").strip()
def qga(target, argv, *, input_bytes=None, timeout=180):
if target == "prod":
cmd = [
"/usr/sbin/qm",
"guest",
"exec",
"210",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
cmd += ["--pass-stdin", "1"]
cmd += ["--", *argv]
elif target == "staging":
q = [
"/usr/sbin/qm",
"guest",
"exec",
"211",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
q += ["--pass-stdin", "1"]
q += ["--", *argv]
remote = (
"test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
"test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
+ " ".join(shlex.quote(x) for x in q)
)
cmd = [
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"-o",
"ConnectionAttempts=1",
"root@pve03",
remote,
]
else:
fail("BAD_TARGET")
p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
try:
data = json.loads(p.stdout.decode("utf-8"))
except Exception:
fail("QGA_JSON")
if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
fail("QGA_GUEST_RC")
if data.get("out-truncated") or data.get("err-truncated"):
fail("QGA_TRUNCATED")
return data.get("out-data", "")
INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
if (!is_file($path)) { return null; }
return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
"LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
'hostname' => gethostname(),
'application' => [
'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
],
'addon' => $db->fetchRow(
"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
"FROM xf_addon WHERE addon_id='Newfi/Portal'"
),
'source' => [
'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
'_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
],
'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''
IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
$handler = $dataManager->getDataTypeHandler($dataType);
$xml = $handler->openTypeFile($addOn->getDataDirectory());
if (!$xml || !$xml->count()) {
$handler->deleteAddOnData($addOn->addon_id);
continue;
}
$start = 0;
$guard = 0;
while (true) {
$last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
if (!$last) { break; }
if ($last <= $start || ++$guard > 100000) {
fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
exit(31);
}
$start = $last;
}
$handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
\XF::triggerRunOnce();
$imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
['result'=>'PASS','containers'=>$imported],
JSON_UNESCAPED_SLASHES
) . "\n";
?>'''
ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
[$b]
);
echo json_encode(
$rows,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''
RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
$service = new \Newfi\Portal\Service\PortalData($app);
$portal = $service->build();
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
$result['sections'] = [];
foreach (['forums','resources','media','events','dogs'] as $key) {
$section = $portal[$key] ?? null;
if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
$result['sections'][$key] = [
'available'=>(bool)($section['available'] ?? false),
'hasItems'=>(bool)($section['hasItems'] ?? false),
'status'=>(string)($section['status'] ?? '')
];
}
$result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
$result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
$result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
$result['html_bytes'] = strlen($html);
if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
}
if (!$result['locked_resources'] || !$result['locked_media']) {
fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
}
if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
}
} else if ($state === 'old') {
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
$result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
$result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
$result['html_bytes'] = strlen($html);
if ($result['html_marker'] || !$result['old_copy']) {
fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
}
} else {
exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''
HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
raise SystemExit(40)
def fetch(url):
cmd=[
'/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10'
]
if mode=='staging':
cmd += ['--resolve',f'{host}:{port}:127.0.0.1']
cmd += [
'-o','-','-w','\n__PORTAL_STATUS__%{http_code}',url
]
p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
if p.returncode!=0: raise SystemExit(41)
text=p.stdout.decode('utf-8','replace')
marker='\n__PORTAL_STATUS__'
if marker not in text: raise SystemExit(42)
body,status=text.rsplit(marker,1)
return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\.php[^\"']*)[\"']",body,re.I):
raw=html.unescape(raw)
hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
s,b=fetch(u)
normal_status.append(s)
normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new' and hrefs:
u=urllib.parse.urlsplit(hrefs[0])
pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
out=[]
found=False
for k,v in pairs:
if k=='css':
found=True
parts=[x for x in v.split(',') if x]
if 'public:newfi_portal.less' not in parts:
parts.append('public:newfi_portal.less')
v=','.join(parts)
out.append((k,v))
if found:
forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
forced_css_status,forced_body=fetch(forced)
forced_css_marker='--newfi-portal200' in forced_body
result={
'status':status,
'bytes':len(body.encode()),
'html_marker':'data-newfi-portal200="1"' in body,
'css_marker':'--newfi-portal200' in normal_css_text,
'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
'css_count':len(hrefs),
'css_status':normal_status,
'forced_css_status':forced_css_status,
'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if mode=='prod':
if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
elif mode=='staging':
if any(x!=200 for x in normal_status): raise SystemExit(46)
else:
raise SystemExit(50)
if mode=='prod' and state=='new':
if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)
elif mode=='staging' and state=='old':
pass
else:
raise SystemExit(50)
'''
def parse_inspect(text):
b = "PORTAL200_INSPECT_JSON_BEGIN\n"
e = "\nPORTAL200_INSPECT_JSON_END"
if b not in text or e not in text:
fail("INSPECT_MARKERS")
try:
return json.loads(text.split(b, 1)[1].split(e, 1)[0])
except Exception:
fail("INSPECT_JSON")
def inspect(target):
return parse_inspect(
qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
)
def source_is_old(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_OLD.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return src.get("Service/PortalData.php") is None
def source_is_new(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_NEW.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return True
def template_state_new(data):
rows = data.get("templates", [])
rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
titles = [r.get("title") for r in rows0]
by = {r.get("title"): r for r in rows0}
return (
titles == ["newfi_portal", "newfi_portal.less"]
and int(by["newfi_portal"].get("html_marker", 0)) == 1
and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
)
def app_contract(data, target):
a = data.get("application", {})
addon = data.get("addon") or {}
expected = {
"staging": ("0", "http://newfi-staging.gram1.ru"),
"prod": ("1", "https://newfi.ru"),
}[target]
return (
a.get("boardActive") == expected[0]
and a.get("boardUrl") == expected[1]
and a.get("indexRoute") == "portal/"
and a.get("defaultStyleId") == "4"
and addon.get("addon_id") == "Newfi/Portal"
and int(addon.get("version_id", 0)) == 1000010
and addon.get("version_string") == "1.0.0 Alpha 1"
and int(addon.get("active", 0)) == 1
and int(addon.get("is_processing", 1)) == 0
)
def new_errors(target, baseline):
raw = qga(
target,
["/usr/bin/php8.3"],
input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
timeout=90,
)
try:
return json.loads(raw)
except Exception:
fail("ERROR_LOG_JSON")
def assert_no_new_errors(target, baseline, phase):
rows = new_errors(target, baseline)
if rows:
emit(
"PORTAL200_NEW_XF_ERRORS",
{
"target": target,
"phase": phase,
"count": len(rows),
"ids": [r.get("error_id") for r in rows],
},
)
fail("NEW_XF_ERRORS")
def guest_backup(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_BACKUP=PASS" not in out:
fail("BACKUP_FAILED")
def guest_write(target, rel, data):
dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
parent = str(pathlib.PurePosixPath(dest).parent)
script = (
"set -eu; umask 022; "
+ "install -d -m 755 "
+ shlex.quote(parent)
+ "; T=$(mktemp "
+ shlex.quote(parent + "/.portal200.XXXXXX")
+ "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
+ "mv -f -- \"$T\" "
+ shlex.quote(dest)
+ "; printf 'PORTAL200_WRITE=PASS\\n'"
)
out = qga(
target,
["/bin/bash", "-lc", script],
input_bytes=data,
timeout=90,
)
if "PORTAL200_WRITE=PASS" not in out:
fail("WRITE_FAILED")
def install_candidate(target):
for rel in [
"Pub/Controller/Portal.php",
"Service/PortalData.php",
"_data/templates.xml",
]:
guest_write(target, rel, (CAND_ROOT / rel).read_bytes())
def restore_old(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
fail("SOURCE_RESTORE_FAILED")
def import_addon(target):
out = qga(
target,
["/usr/bin/php8.3"],
input_bytes=IMPORT_PHP.encode(),
timeout=240,
)
if "PORTAL200_IMPORT_JSON=" not in out:
fail("IMPORT_FAILED")
def runtime_verify(target, state):
src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
None,
)
if not line:
fail("RUNTIME_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("RUNTIME_JSON")
emit("PORTAL200_RUNTIME", {"target": target, **data})
return data
def http_verify(target, state):
argv = ["/usr/bin/python3", "-", target, state]
if target == "prod":
try:
p = subprocess.run(
argv,
input=HTTP_VERIFY_PY.encode(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=180,
check=False,
)
except subprocess.TimeoutExpired:
fail("HTTP_PROD_TIMEOUT")
out = p.stdout.decode("utf-8", "replace")
err = p.stderr.decode("utf-8", "replace")
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
None,
)
data = None
if line:
try:
data = json.loads(line.split("=", 1)[1])
emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
except Exception:
fail("HTTP_JSON")
if p.returncode != 0:
emit(
"PORTAL200_HTTP_EXEC",
{
"target": target,
"state": state,
"rc": p.returncode,
"stderr": err[:800],
},
)
fail("HTTP_PROD_RC")
if data is None:
fail("HTTP_MARKER_MISSING")
return data
out = qga(
target,
argv,
input_bytes=HTTP_VERIFY_PY.encode(),
timeout=180,
)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
None,
)
if not line:
fail("HTTP_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("HTTP_JSON")
emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
return data
def verify_new(target, baseline):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_NEW")
if not source_is_new(data):
fail("SOURCE_NEW_MISMATCH")
if not template_state_new(data):
fail("TEMPLATE_NEW_MISMATCH")
runtime_verify(target, "new")
http_verify(target, "new")
assert_no_new_errors(target, baseline, "verify-new")
return inspect(target)
def verify_old(target, baseline, old_templates, strict_errors=True):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_OLD")
if not source_is_old(data):
fail("SOURCE_OLD_MISMATCH")
if data.get("templates") != old_templates:
fail("TEMPLATE_ROLLBACK_MISMATCH")
if target == "prod":
runtime_verify(target, "old")
http_verify(target, "old")
if strict_errors:
assert_no_new_errors(target, baseline, "verify-old")
return inspect(target)
def lint_php(target, source, label):
out = qga(
target,
["/usr/bin/php8.3", "-l"],
input_bytes=source.encode(),
timeout=90,
)
if "No syntax errors detected" not in out:
fail("PHP_LINT_" + label)
def cluster_guard():
p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
if "status: running" not in p.stdout.decode():
fail("VM210_NOT_RUNNING")
p = run(
[
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"root@pve03",
"/usr/sbin/qm status 211",
],
timeout=40,
)
if "status: running" not in p.stdout.decode():
fail("VM211_NOT_RUNNING")
emit("PORTAL200_CLUSTER", "PASS")
def result_contract(status, changes, rollback_started, rollback_restored):
emit(
"HOMELAB_RESULT_CONTRACT",
{
"version": 1,
"command_id": CID,
"status": status,
"changes_made": bool(changes),
"rollback_started": bool(rollback_started),
"rollback_restored": rollback_restored,
},
)
emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
"PORTAL200_FLOW",
"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)
staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None
try:
compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
for rel, (size, sha) in EXPECTED_NEW.items():
p = CAND_ROOT / rel
if not p.is_file() or file_meta(p) != (size, sha):
fail("CANDIDATE_IDENTITY")
tree = ET.parse(CAND_ROOT / "_data/templates.xml")
titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
if titles != ["newfi_portal", "newfi_portal.less"]:
fail("CANDIDATE_TEMPLATE_SET")
service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
if any(
token in service
for token in [
"\\XF::db(",
"->save(",
"->delete(",
"INSERT ",
"UPDATE ",
"DELETE ",
"REPLACE ",
]
):
fail("CANDIDATE_MUTATION_SCAN")
emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")
emit(
"PORTAL200_ORCHESTRATION_FIXES",
{
"staging_503_without_css_links": True,
"failure_status": "FAIL",
"prod_http_via_pve01_public_route": True,
"css_href_regex_fixed": True,
},
)
branch = git("branch", "--show-current")
head = git("rev-parse", "HEAD")
status = git("status", "--porcelain=v1", "--untracked-files=all")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote = remote_out.split()[0] if remote_out else ""
if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
fail("GIT_PRESTATE")
emit(
"PORTAL200_GIT_PRESTATE",
{"branch": branch, "head": head, "remote": remote, "clean": True},
)
cluster_guard()
lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")
s0 = inspect("staging")
p0 = inspect("prod")
if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
fail("APP_PRESTATE")
if not source_is_old(s0) or not source_is_old(p0):
fail("SOURCE_PRESTATE")
if s0.get("templates") != p0.get("templates"):
fail("TEMPLATE_PRESTATE_PARITY")
s_error = int(s0["application"]["error_max_id"])
p_error = int(p0["application"]["error_max_id"])
s_old_templates = s0["templates"]
p_old_templates = p0["templates"]
emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
http_verify("staging", "old")
runtime_verify("prod", "old")
http_verify("prod", "old")
assert_no_new_errors("staging", s_error, "preflight")
assert_no_new_errors("prod", p_error, "preflight")
emit("PORTAL200_PREFLIGHT", "PASS")
guest_backup("staging")
staging_backup = True
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates)
staging_mutated = False
emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
staging_final_new = True
emit("PORTAL200_STAGING_FINAL", "PASS")
for rel in EXPECTED_NEW:
dest = GIT_PORTAL / rel
old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CAND_ROOT / rel, dest)
git_files_changed = True
if git("diff", "--check"):
fail("GIT_DIFF_CHECK")
status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
if actual_paths != sorted(EXPECTED_GIT_PATHS):
fail("GIT_SCOPE")
git("add", "--", *EXPECTED_GIT_PATHS)
staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
if staged != sorted(EXPECTED_GIT_PATHS):
fail("GIT_STAGED_SCOPE")
git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
commit_created = True
new_head = git("rev-parse", "HEAD")
if new_head == OLD_HEAD:
fail("GIT_COMMIT_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("GIT_POSTCOMMIT_DIRTY")
git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote_head = remote_out.split()[0] if remote_out else ""
if remote_head != new_head:
fail("GIT_REMOTE_HEAD")
push_confirmed = True
emit(
"PORTAL200_GIT_PROMOTION",
{
"old_head": OLD_HEAD,
"new_head": new_head,
"remote_head": remote_head,
"result": "PASS",
},
)
p_pre = inspect("prod")
if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
fail("PROD_DRIFT_BEFORE_APPLY")
if int(p_pre["application"]["error_max_id"]) != p_error:
fail("PROD_ERROR_DRIFT")
guest_backup("prod")
prod_backup = True
prod_mutated = True
install_candidate("prod")
import_addon("prod")
verify_new("prod", p_error)
emit("PORTAL200_PRODUCTION", "PASS")
sf = verify_new("staging", s_error)
pf = verify_new("prod", p_error)
if sf["source"] != pf["source"]:
fail("FINAL_SOURCE_PARITY")
if sf["templates"] != pf["templates"]:
fail("FINAL_TEMPLATE_PARITY")
if git("rev-parse", "HEAD") != new_head:
fail("FINAL_LOCAL_HEAD")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
if not remote_out or remote_out.split()[0] != new_head:
fail("FINAL_REMOTE_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("FINAL_GIT_DIRTY")
cluster_guard()
emit(
"PORTAL200_FINAL_EVIDENCE",
{
"commit": new_head,
"source_files": 3,
"templates": 2,
"staging_error_baseline": s_error,
"prod_error_baseline": p_error,
},
)
emit("PORTAL200_PORTAL", "PASS")
emit("PORTAL200_STAGING", "PASS")
emit("PORTAL200_PRODUCTION", "PASS")
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
result_contract("OK", True, False, None)
emit("NEWFI200_END")
raise SystemExit(0)
except PortalError as exc:
code = str(exc)
emit("PORTAL200_FAILURE", code)
if push_confirmed:
if prod_mutated and prod_backup:
failure_rollback_started = True
try:
restore_old("prod")
import_addon("prod")
verify_old("prod", p_error, p_old_templates, strict_errors=False)
prod_mutated = False
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
else:
if staging_mutated and staging_backup:
failure_rollback_started = True
try:
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates, strict_errors=False)
staging_mutated = False
staging_final_new = False
failure_rollback_restored = True
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")
if git_files_changed and not commit_created:
try:
for rel, old in old_git_bytes.items():
dest = GIT_PORTAL / rel
if old is None:
if dest.exists():
dest.unlink()
else:
dest.write_bytes(old)
service_dir = GIT_PORTAL / "Service"
try:
service_dir.rmdir()
except OSError:
pass
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
except Exception:
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")
if commit_created:
emit(
"PORTAL200_FAILURE_GIT_HISTORY",
"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
)
persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
if failure_rollback_started and failure_rollback_restored is None:
failure_rollback_restored = False
result_contract(
"FAIL",
persisted,
failure_rollback_started,
failure_rollback_restored,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
except Exception:
emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
result_contract(
"FAIL",
bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
False,
None,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
CANDIDATE_LAYER_2_TEXT_END
CANDIDATE_LAYER_3_SOURCE=file:newfi-48-portal200-apply2.cmd::b64::zlib
CANDIDATE_LAYER_3_SHA256=5e3aa56505b7d7ef5b7f0cf588f89d5e1c5f6b8bd9bffc13b8debdbdc5be26c7
CANDIDATE_LAYER_3_BYTES=32663
CANDIDATE_LAYER_3_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
CID = "NEWFI-260907-PORTAL200-APPLY2"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
"Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
"Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
"_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
"Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
"_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"
class PortalError(RuntimeError):
pass
def emit(key, value=None):
if value is None:
print(key, flush=True)
elif isinstance(value, (dict, list, tuple, bool, int, float)):
print(
key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
flush=True,
)
else:
print(key + "=" + str(value), flush=True)
def fail(code):
raise PortalError(code)
def sha_bytes(data):
return hashlib.sha256(data).hexdigest()
def file_meta(path):
data = pathlib.Path(path).read_bytes()
return len(data), sha_bytes(data)
def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
try:
p = subprocess.run(
cmd,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
fail("SUBPROCESS_TIMEOUT")
if p.returncode not in ok:
fail("SUBPROCESS_RC")
return p
def git(*args, ok=(0,), timeout=120):
p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
return p.stdout.decode("utf-8", "replace").strip()
def qga(target, argv, *, input_bytes=None, timeout=180):
if target == "prod":
cmd = [
"/usr/sbin/qm",
"guest",
"exec",
"210",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
cmd += ["--pass-stdin", "1"]
cmd += ["--", *argv]
elif target == "staging":
q = [
"/usr/sbin/qm",
"guest",
"exec",
"211",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
q += ["--pass-stdin", "1"]
q += ["--", *argv]
remote = (
"test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
"test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
+ " ".join(shlex.quote(x) for x in q)
)
cmd = [
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"-o",
"ConnectionAttempts=1",
"root@pve03",
remote,
]
else:
fail("BAD_TARGET")
p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
try:
data = json.loads(p.stdout.decode("utf-8"))
except Exception:
fail("QGA_JSON")
if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
fail("QGA_GUEST_RC")
if data.get("out-truncated") or data.get("err-truncated"):
fail("QGA_TRUNCATED")
return data.get("out-data", "")
INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
if (!is_file($path)) { return null; }
return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
"LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
'hostname' => gethostname(),
'application' => [
'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
],
'addon' => $db->fetchRow(
"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
"FROM xf_addon WHERE addon_id='Newfi/Portal'"
),
'source' => [
'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
'_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
],
'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''
IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
$handler = $dataManager->getDataTypeHandler($dataType);
$xml = $handler->openTypeFile($addOn->getDataDirectory());
if (!$xml || !$xml->count()) {
$handler->deleteAddOnData($addOn->addon_id);
continue;
}
$start = 0;
$guard = 0;
while (true) {
$last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
if (!$last) { break; }
if ($last <= $start || ++$guard > 100000) {
fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
exit(31);
}
$start = $last;
}
$handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
\XF::triggerRunOnce();
$imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
['result'=>'PASS','containers'=>$imported],
JSON_UNESCAPED_SLASHES
) . "\n";
?>'''
ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
[$b]
);
echo json_encode(
$rows,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''
RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
$service = new \Newfi\Portal\Service\PortalData($app);
$portal = $service->build();
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
$result['sections'] = [];
foreach (['forums','resources','media','events','dogs'] as $key) {
$section = $portal[$key] ?? null;
if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
$result['sections'][$key] = [
'available'=>(bool)($section['available'] ?? false),
'hasItems'=>(bool)($section['hasItems'] ?? false),
'status'=>(string)($section['status'] ?? '')
];
}
$result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
$result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
$result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
$result['html_bytes'] = strlen($html);
if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
}
if (!$result['locked_resources'] || !$result['locked_media']) {
fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
}
if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
}
} else if ($state === 'old') {
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
$result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
$result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
$result['html_bytes'] = strlen($html);
if ($result['html_marker'] || !$result['old_copy']) {
fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
}
} else {
exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''
HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
raise SystemExit(40)
def fetch(url):
cmd=[
'/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',
'--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',
'\n__PORTAL_STATUS__%{http_code}',url
]
p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
if p.returncode!=0: raise SystemExit(41)
text=p.stdout.decode('utf-8','replace')
marker='\n__PORTAL_STATUS__'
if marker not in text: raise SystemExit(42)
body,status=text.rsplit(marker,1)
return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\\.php[^\"']*)[\"']",body,re.I):
raw=html.unescape(raw)
hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
s,b=fetch(u)
normal_status.append(s)
normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new' and hrefs:
u=urllib.parse.urlsplit(hrefs[0])
pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
out=[]
found=False
for k,v in pairs:
if k=='css':
found=True
parts=[x for x in v.split(',') if x]
if 'public:newfi_portal.less' not in parts:
parts.append('public:newfi_portal.less')
v=','.join(parts)
out.append((k,v))
if found:
forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
forced_css_status,forced_body=fetch(forced)
forced_css_marker='--newfi-portal200' in forced_body
result={
'status':status,
'bytes':len(body.encode()),
'html_marker':'data-newfi-portal200="1"' in body,
'css_marker':'--newfi-portal200' in normal_css_text,
'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
'css_count':len(hrefs),
'css_status':normal_status,
'forced_css_status':forced_css_status,
'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if mode=='prod':
if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
elif mode=='staging':
if any(x!=200 for x in normal_status): raise SystemExit(46)
else:
raise SystemExit(50)
if mode=='prod' and state=='new':
if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
if hrefs and (forced_css_status!=200 or not forced_css_marker): raise SystemExit(49)
elif mode=='staging' and state=='old':
pass
else:
raise SystemExit(50)
'''
def parse_inspect(text):
b = "PORTAL200_INSPECT_JSON_BEGIN\n"
e = "\nPORTAL200_INSPECT_JSON_END"
if b not in text or e not in text:
fail("INSPECT_MARKERS")
try:
return json.loads(text.split(b, 1)[1].split(e, 1)[0])
except Exception:
fail("INSPECT_JSON")
def inspect(target):
return parse_inspect(
qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
)
def source_is_old(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_OLD.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return src.get("Service/PortalData.php") is None
def source_is_new(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_NEW.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return True
def template_state_new(data):
rows = data.get("templates", [])
rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
titles = [r.get("title") for r in rows0]
by = {r.get("title"): r for r in rows0}
return (
titles == ["newfi_portal", "newfi_portal.less"]
and int(by["newfi_portal"].get("html_marker", 0)) == 1
and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
)
def app_contract(data, target):
a = data.get("application", {})
addon = data.get("addon") or {}
expected = {
"staging": ("0", "http://newfi-staging.gram1.ru"),
"prod": ("1", "https://newfi.ru"),
}[target]
return (
a.get("boardActive") == expected[0]
and a.get("boardUrl") == expected[1]
and a.get("indexRoute") == "portal/"
and a.get("defaultStyleId") == "4"
and addon.get("addon_id") == "Newfi/Portal"
and int(addon.get("version_id", 0)) == 1000010
and addon.get("version_string") == "1.0.0 Alpha 1"
and int(addon.get("active", 0)) == 1
and int(addon.get("is_processing", 1)) == 0
)
def new_errors(target, baseline):
raw = qga(
target,
["/usr/bin/php8.3"],
input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
timeout=90,
)
try:
return json.loads(raw)
except Exception:
fail("ERROR_LOG_JSON")
def assert_no_new_errors(target, baseline, phase):
rows = new_errors(target, baseline)
if rows:
emit(
"PORTAL200_NEW_XF_ERRORS",
{
"target": target,
"phase": phase,
"count": len(rows),
"ids": [r.get("error_id") for r in rows],
},
)
fail("NEW_XF_ERRORS")
def guest_backup(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_BACKUP=PASS" not in out:
fail("BACKUP_FAILED")
def guest_write(target, rel, data):
dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
parent = str(pathlib.PurePosixPath(dest).parent)
script = (
"set -eu; umask 022; "
+ "install -d -m 755 "
+ shlex.quote(parent)
+ "; T=$(mktemp "
+ shlex.quote(parent + "/.portal200.XXXXXX")
+ "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
+ "mv -f -- \"$T\" "
+ shlex.quote(dest)
+ "; printf 'PORTAL200_WRITE=PASS\\n'"
)
out = qga(
target,
["/bin/bash", "-lc", script],
input_bytes=data,
timeout=90,
)
if "PORTAL200_WRITE=PASS" not in out:
fail("WRITE_FAILED")
def install_candidate(target):
for rel in [
"Pub/Controller/Portal.php",
"Service/PortalData.php",
"_data/templates.xml",
]:
guest_write(target, rel, (CAND_ROOT / rel).read_bytes())
def restore_old(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
fail("SOURCE_RESTORE_FAILED")
def import_addon(target):
out = qga(
target,
["/usr/bin/php8.3"],
input_bytes=IMPORT_PHP.encode(),
timeout=240,
)
if "PORTAL200_IMPORT_JSON=" not in out:
fail("IMPORT_FAILED")
def runtime_verify(target, state):
src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
None,
)
if not line:
fail("RUNTIME_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("RUNTIME_JSON")
emit("PORTAL200_RUNTIME", {"target": target, **data})
return data
def http_verify(target, state):
out = qga(
target,
["/usr/bin/python3", "-", target, state],
input_bytes=HTTP_VERIFY_PY.encode(),
timeout=180,
)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
None,
)
if not line:
fail("HTTP_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("HTTP_JSON")
emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
return data
def verify_new(target, baseline):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_NEW")
if not source_is_new(data):
fail("SOURCE_NEW_MISMATCH")
if not template_state_new(data):
fail("TEMPLATE_NEW_MISMATCH")
runtime_verify(target, "new")
http_verify(target, "new")
assert_no_new_errors(target, baseline, "verify-new")
return inspect(target)
def verify_old(target, baseline, old_templates, strict_errors=True):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_OLD")
if not source_is_old(data):
fail("SOURCE_OLD_MISMATCH")
if data.get("templates") != old_templates:
fail("TEMPLATE_ROLLBACK_MISMATCH")
if target == "prod":
runtime_verify(target, "old")
http_verify(target, "old")
if strict_errors:
assert_no_new_errors(target, baseline, "verify-old")
return inspect(target)
def lint_php(target, source, label):
out = qga(
target,
["/usr/bin/php8.3", "-l"],
input_bytes=source.encode(),
timeout=90,
)
if "No syntax errors detected" not in out:
fail("PHP_LINT_" + label)
def cluster_guard():
p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
if "status: running" not in p.stdout.decode():
fail("VM210_NOT_RUNNING")
p = run(
[
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"root@pve03",
"/usr/sbin/qm status 211",
],
timeout=40,
)
if "status: running" not in p.stdout.decode():
fail("VM211_NOT_RUNNING")
emit("PORTAL200_CLUSTER", "PASS")
def result_contract(status, changes, rollback_started, rollback_restored):
emit(
"HOMELAB_RESULT_CONTRACT",
{
"version": 1,
"command_id": CID,
"status": status,
"changes_made": bool(changes),
"rollback_started": bool(rollback_started),
"rollback_restored": rollback_restored,
},
)
emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
"PORTAL200_FLOW",
"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)
staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None
try:
compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
for rel, (size, sha) in EXPECTED_NEW.items():
p = CAND_ROOT / rel
if not p.is_file() or file_meta(p) != (size, sha):
fail("CANDIDATE_IDENTITY")
tree = ET.parse(CAND_ROOT / "_data/templates.xml")
titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
if titles != ["newfi_portal", "newfi_portal.less"]:
fail("CANDIDATE_TEMPLATE_SET")
service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
if any(
token in service
for token in [
"\\XF::db(",
"->save(",
"->delete(",
"INSERT ",
"UPDATE ",
"DELETE ",
"REPLACE ",
]
):
fail("CANDIDATE_MUTATION_SCAN")
emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")
emit("PORTAL200_ORCHESTRATION_FIXES", {"staging_503_without_css_links": True, "failure_status": "FAIL"})
branch = git("branch", "--show-current")
head = git("rev-parse", "HEAD")
status = git("status", "--porcelain=v1", "--untracked-files=all")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote = remote_out.split()[0] if remote_out else ""
if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
fail("GIT_PRESTATE")
emit(
"PORTAL200_GIT_PRESTATE",
{"branch": branch, "head": head, "remote": remote, "clean": True},
)
cluster_guard()
lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")
s0 = inspect("staging")
p0 = inspect("prod")
if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
fail("APP_PRESTATE")
if not source_is_old(s0) or not source_is_old(p0):
fail("SOURCE_PRESTATE")
if s0.get("templates") != p0.get("templates"):
fail("TEMPLATE_PRESTATE_PARITY")
s_error = int(s0["application"]["error_max_id"])
p_error = int(p0["application"]["error_max_id"])
s_old_templates = s0["templates"]
p_old_templates = p0["templates"]
emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
http_verify("staging", "old")
runtime_verify("prod", "old")
http_verify("prod", "old")
assert_no_new_errors("staging", s_error, "preflight")
assert_no_new_errors("prod", p_error, "preflight")
emit("PORTAL200_PREFLIGHT", "PASS")
guest_backup("staging")
staging_backup = True
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates)
staging_mutated = False
emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
staging_final_new = True
emit("PORTAL200_STAGING_FINAL", "PASS")
for rel in EXPECTED_NEW:
dest = GIT_PORTAL / rel
old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CAND_ROOT / rel, dest)
git_files_changed = True
if git("diff", "--check"):
fail("GIT_DIFF_CHECK")
status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
if actual_paths != sorted(EXPECTED_GIT_PATHS):
fail("GIT_SCOPE")
git("add", "--", *EXPECTED_GIT_PATHS)
staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
if staged != sorted(EXPECTED_GIT_PATHS):
fail("GIT_STAGED_SCOPE")
git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
commit_created = True
new_head = git("rev-parse", "HEAD")
if new_head == OLD_HEAD:
fail("GIT_COMMIT_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("GIT_POSTCOMMIT_DIRTY")
git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote_head = remote_out.split()[0] if remote_out else ""
if remote_head != new_head:
fail("GIT_REMOTE_HEAD")
push_confirmed = True
emit(
"PORTAL200_GIT_PROMOTION",
{
"old_head": OLD_HEAD,
"new_head": new_head,
"remote_head": remote_head,
"result": "PASS",
},
)
p_pre = inspect("prod")
if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
fail("PROD_DRIFT_BEFORE_APPLY")
if int(p_pre["application"]["error_max_id"]) != p_error:
fail("PROD_ERROR_DRIFT")
guest_backup("prod")
prod_backup = True
prod_mutated = True
install_candidate("prod")
import_addon("prod")
verify_new("prod", p_error)
emit("PORTAL200_PRODUCTION", "PASS")
sf = verify_new("staging", s_error)
pf = verify_new("prod", p_error)
if sf["source"] != pf["source"]:
fail("FINAL_SOURCE_PARITY")
if sf["templates"] != pf["templates"]:
fail("FINAL_TEMPLATE_PARITY")
if git("rev-parse", "HEAD") != new_head:
fail("FINAL_LOCAL_HEAD")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
if not remote_out or remote_out.split()[0] != new_head:
fail("FINAL_REMOTE_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("FINAL_GIT_DIRTY")
cluster_guard()
emit(
"PORTAL200_FINAL_EVIDENCE",
{
"commit": new_head,
"source_files": 3,
"templates": 2,
"staging_error_baseline": s_error,
"prod_error_baseline": p_error,
},
)
emit("PORTAL200_PORTAL", "PASS")
emit("PORTAL200_STAGING", "PASS")
emit("PORTAL200_PRODUCTION", "PASS")
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
result_contract("OK", True, False, None)
emit("NEWFI200_END")
raise SystemExit(0)
except PortalError as exc:
code = str(exc)
emit("PORTAL200_FAILURE", code)
if push_confirmed:
if prod_mutated and prod_backup:
failure_rollback_started = True
try:
restore_old("prod")
import_addon("prod")
verify_old("prod", p_error, p_old_templates, strict_errors=False)
prod_mutated = False
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
else:
if staging_mutated and staging_backup:
failure_rollback_started = True
try:
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates, strict_errors=False)
staging_mutated = False
staging_final_new = False
failure_rollback_restored = True
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")
if git_files_changed and not commit_created:
try:
for rel, old in old_git_bytes.items():
dest = GIT_PORTAL / rel
if old is None:
if dest.exists():
dest.unlink()
else:
dest.write_bytes(old)
service_dir = GIT_PORTAL / "Service"
try:
service_dir.rmdir()
except OSError:
pass
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
except Exception:
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")
if commit_created:
emit(
"PORTAL200_FAILURE_GIT_HISTORY",
"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
)
persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
if failure_rollback_started and failure_rollback_restored is None:
failure_rollback_restored = False
result_contract(
"FAIL",
persisted,
failure_rollback_started,
failure_rollback_restored,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
except Exception:
emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
result_contract(
"FAIL",
bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
False,
None,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
CANDIDATE_LAYER_3_TEXT_END
CANDIDATE_LAYER_4_SOURCE=file:newfi-38-portal200-apply.cmd::b64::zlib
CANDIDATE_LAYER_4_SHA256=e9d137ac729d7513d8d9aa1d9fb509005aefaa7bfe03f5cb26b9896fb1310f62
CANDIDATE_LAYER_4_BYTES=32448
CANDIDATE_LAYER_4_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
CID = "NEWFI-260907-PORTAL200-APPLY1"
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
CAND_ROOT = pathlib.Path("/root/newfi-portal200-candidate/src/addons/Newfi/Portal")
PORTAL_REL = pathlib.Path("src/addons/Newfi/Portal")
GIT_PORTAL = WT / PORTAL_REL
EXPECTED_NEW = {
"Pub/Controller/Portal.php": (363, "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d"),
"Service/PortalData.php": (5213, "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d"),
"_data/templates.xml": (10977, "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861"),
}
EXPECTED_OLD = {
"Pub/Controller/Portal.php": (247, "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3"),
"_data/templates.xml": (1461, "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf"),
}
EXPECTED_GIT_PATHS = [str(PORTAL_REL / p) for p in sorted(EXPECTED_NEW)]
BACKUP = f"/root/newfi-portal200-backup-{CID}"
class PortalError(RuntimeError):
pass
def emit(key, value=None):
if value is None:
print(key, flush=True)
elif isinstance(value, (dict, list, tuple, bool, int, float)):
print(
key + "=" + json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
flush=True,
)
else:
print(key + "=" + str(value), flush=True)
def fail(code):
raise PortalError(code)
def sha_bytes(data):
return hashlib.sha256(data).hexdigest()
def file_meta(path):
data = pathlib.Path(path).read_bytes()
return len(data), sha_bytes(data)
def run(cmd, *, input_bytes=None, timeout=180, ok=(0,)):
try:
p = subprocess.run(
cmd,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
fail("SUBPROCESS_TIMEOUT")
if p.returncode not in ok:
fail("SUBPROCESS_RC")
return p
def git(*args, ok=(0,), timeout=120):
p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=timeout, ok=ok)
return p.stdout.decode("utf-8", "replace").strip()
def qga(target, argv, *, input_bytes=None, timeout=180):
if target == "prod":
cmd = [
"/usr/sbin/qm",
"guest",
"exec",
"210",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
cmd += ["--pass-stdin", "1"]
cmd += ["--", *argv]
elif target == "staging":
q = [
"/usr/sbin/qm",
"guest",
"exec",
"211",
"--synchronous",
"1",
"--timeout",
str(timeout),
]
if input_bytes is not None:
q += ["--pass-stdin", "1"]
q += ["--", *argv]
remote = (
"test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
"test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
+ " ".join(shlex.quote(x) for x in q)
)
cmd = [
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"-o",
"ConnectionAttempts=1",
"root@pve03",
remote,
]
else:
fail("BAD_TARGET")
p = run(cmd, input_bytes=input_bytes, timeout=timeout + 30)
try:
data = json.loads(p.stdout.decode("utf-8"))
except Exception:
fail("QGA_JSON")
if data.get("exited") not in (1, True) or data.get("exitcode") != 0:
fail("QGA_GUEST_RC")
if data.get("out-truncated") or data.get("err-truncated"):
fail("QGA_TRUNCATED")
return data.get("out-data", "")
INSPECT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$db = $app->db();
function srcMeta(string $path): ?array {
if (!is_file($path)) { return null; }
return ['bytes' => filesize($path), 'sha256' => hash_file('sha256', $path)];
}
$portal = $root . '/src/addons/Newfi/Portal';
$templates = $db->fetchAll(
"SELECT title, style_id, LENGTH(template) AS bytes, SHA2(template,256) AS sha256, " .
"LOCATE('data-newfi-portal200', template) > 0 AS html_marker, " .
"LOCATE('--newfi-portal200', template) > 0 AS css_marker " .
"FROM xf_template WHERE addon_id='Newfi/Portal' ORDER BY style_id,title"
);
$result = [
'hostname' => gethostname(),
'application' => [
'boardActive' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardActive'"),
'boardUrl' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='boardUrl'"),
'indexRoute' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='indexRoute'"),
'defaultStyleId' => (string)$db->fetchOne("SELECT option_value FROM xf_option WHERE option_id='defaultStyleId'"),
'error_count' => (int)$db->fetchOne("SELECT COUNT(*) FROM xf_error_log"),
'error_max_id' => (int)$db->fetchOne("SELECT COALESCE(MAX(error_id),0) FROM xf_error_log")
],
'addon' => $db->fetchRow(
"SELECT addon_id,version_id,version_string,active,is_processing,last_pending_action " .
"FROM xf_addon WHERE addon_id='Newfi/Portal'"
),
'source' => [
'Pub/Controller/Portal.php' => srcMeta($portal . '/Pub/Controller/Portal.php'),
'Service/PortalData.php' => srcMeta($portal . '/Service/PortalData.php'),
'_data/templates.xml' => srcMeta($portal . '/_data/templates.xml')
],
'templates' => $templates
];
echo "PORTAL200_INSPECT_JSON_BEGIN\n";
echo json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
echo "\nPORTAL200_INSPECT_JSON_END\n";
?>'''
IMPORT_PHP = r'''<?php
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$addOn = $app->addOnManager()->getById('Newfi/Portal');
if (!$addOn || !$addOn->isInstalled()) {
fwrite(STDERR, "ADDON_NOT_INSTALLED\n");
exit(30);
}
$addOn->preRebuild();
$dataManager = $app->addOnDataManager();
$imported = [];
foreach ($dataManager->getDataTypeClasses() as $dataType) {
$handler = $dataManager->getDataTypeHandler($dataType);
$xml = $handler->openTypeFile($addOn->getDataDirectory());
if (!$xml || !$xml->count()) {
$handler->deleteAddOnData($addOn->addon_id);
continue;
}
$start = 0;
$guard = 0;
while (true) {
$last = $handler->importAddOnData($addOn->addon_id, $xml, $start, 0);
if (!$last) { break; }
if ($last <= $start || ++$guard > 100000) {
fwrite(STDERR, "IMPORT_PROGRESS_INVALID\n");
exit(31);
}
$start = $last;
}
$handler->deleteOrphanedAddOnData($addOn->addon_id, $xml);
\XF::triggerRunOnce();
$imported[] = $handler->getContainerTag();
}
$addOn->postDataImport();
$repo = $app->repository(\XF\Repository\StyleRepository::class);
$repo->updateAllStylesLastModifiedDateLater();
$addOn->postRebuild();
echo "PORTAL200_IMPORT_JSON=" . json_encode(
['result'=>'PASS','containers'=>$imported],
JSON_UNESCAPED_SLASHES
) . "\n";
?>'''
ERRORS_PHP_TEMPLATE = r'''<?php
$root='/var/www/forums/newfi/public';
require $root.'/src/XF.php';
\XF::start($root);
$app=\XF::setupApp('XF\\Pub\\App');
$db=$app->db();
$b=%d;
$rows=$db->fetchAll(
"SELECT error_id,exception_type,message,filename,line FROM xf_error_log WHERE error_id>? ORDER BY error_id",
[$b]
);
echo json_encode(
$rows,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
);
?>'''
RUNTIME_VERIFY_PHP_TEMPLATE = r'''<?php
$state = '__STATE__';
$root = '/var/www/forums/newfi/public';
require $root . '/src/XF.php';
\XF::start($root);
$app = \XF::setupApp('XF\\Pub\\App');
$result = ['state'=>$state];
if ($state === 'new') {
$service = new \Newfi\Portal\Service\PortalData($app);
$portal = $service->build();
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', ['portal'=>$portal]);
$result['sections'] = [];
foreach (['forums','resources','media','events','dogs'] as $key) {
$section = $portal[$key] ?? null;
if (!is_array($section)) { fwrite(STDERR, "SECTION_MISSING\n"); exit(60); }
$result['sections'][$key] = [
'available'=>(bool)($section['available'] ?? false),
'hasItems'=>(bool)($section['hasItems'] ?? false),
'status'=>(string)($section['status'] ?? '')
];
}
$result['html_marker'] = strpos($html, 'data-newfi-portal200="1"') !== false;
$result['locked_resources'] = strpos($html, 'data-portal-locked="resources"') !== false;
$result['locked_media'] = strpos($html, 'data-portal-locked="media"') !== false;
$result['html_bytes'] = strlen($html);
if (!$result['html_marker']) { fwrite(STDERR, "HTML_MARKER_MISSING\n"); exit(61); }
if ($result['sections']['resources']['available'] || $result['sections']['media']['available']) {
fwrite(STDERR, "GUEST_PERMISSION_CONTRACT_CHANGED\n"); exit(62);
}
if (!$result['locked_resources'] || !$result['locked_media']) {
fwrite(STDERR, "LOCKED_COPY_MISSING\n"); exit(63);
}
if (!$result['sections']['forums']['available'] || !$result['sections']['events']['available'] || !$result['sections']['dogs']['available']) {
fwrite(STDERR, "PUBLIC_SECTION_UNAVAILABLE\n"); exit(64);
}
} else if ($state === 'old') {
$html = (string)$app->templater()->renderTemplate('public:newfi_portal', []);
$result['html_marker'] = strpos($html, 'data-newfi-portal200') !== false;
$result['old_copy'] = strpos($html, 'Закрытое пространство для общения, знаний и материалов сообщества.') !== false;
$result['html_bytes'] = strlen($html);
if ($result['html_marker'] || !$result['old_copy']) {
fwrite(STDERR, "OLD_RENDER_CONTRACT\n"); exit(65);
}
} else {
exit(66);
}
echo "PORTAL200_RUNTIME_JSON=" . json_encode(
$result,
JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_INVALID_UTF8_SUBSTITUTE
) . "\n";
?>'''
HTTP_VERIFY_PY = r'''import html,json,re,subprocess,sys,urllib.parse
mode=sys.argv[1]
state=sys.argv[2]
if mode=='staging':
host='newfi-staging.gram1.ru'; scheme='http'; port='80'; expected_status=503
elif mode=='prod':
host='newfi.ru'; scheme='https'; port='443'; expected_status=200
else:
raise SystemExit(40)
def fetch(url):
cmd=[
'/usr/bin/curl','-sS','-k','--max-time','30','--connect-timeout','10',
'--resolve',f'{host}:{port}:127.0.0.1','-o','-','-w',
'\n__PORTAL_STATUS__%{http_code}',url
]
p=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
if p.returncode!=0: raise SystemExit(41)
text=p.stdout.decode('utf-8','replace')
marker='\n__PORTAL_STATUS__'
if marker not in text: raise SystemExit(42)
body,status=text.rsplit(marker,1)
return int(status.strip()),body
root=f'{scheme}://{host}/'
status,body=fetch(root)
hrefs=[]
for raw in re.findall(r"href=[\"']([^\"']*css\\.php[^\"']*)[\"']",body,re.I):
raw=html.unescape(raw)
hrefs.append(urllib.parse.urljoin(root,raw))
normal_css=[]
normal_status=[]
for u in hrefs:
s,b=fetch(u)
normal_status.append(s)
normal_css.append(b)
normal_css_text='\n'.join(normal_css)
forced_css_marker=None
forced_css_status=None
if mode=='staging' and state=='new':
if not hrefs: raise SystemExit(43)
u=urllib.parse.urlsplit(hrefs[0])
pairs=urllib.parse.parse_qsl(u.query,keep_blank_values=True)
out=[]
found=False
for k,v in pairs:
if k=='css':
found=True
parts=[x for x in v.split(',') if x]
if 'public:newfi_portal.less' not in parts:
parts.append('public:newfi_portal.less')
v=','.join(parts)
out.append((k,v))
if not found: raise SystemExit(44)
forced=urllib.parse.urlunsplit((u.scheme,u.netloc,u.path,urllib.parse.urlencode(out),u.fragment))
forced_css_status,forced_body=fetch(forced)
forced_css_marker='--newfi-portal200' in forced_body
result={
'status':status,
'bytes':len(body.encode()),
'html_marker':'data-newfi-portal200="1"' in body,
'css_marker':'--newfi-portal200' in normal_css_text,
'old_copy':'Закрытое пространство для общения, знаний и материалов сообщества.' in body,
'css_count':len(hrefs),
'css_status':normal_status,
'forced_css_status':forced_css_status,
'forced_css_marker':forced_css_marker,
}
print('PORTAL200_HTTP_JSON='+json.dumps(result,ensure_ascii=False,sort_keys=True,separators=(',',':')))
if status!=expected_status: raise SystemExit(45)
if not hrefs or any(x!=200 for x in normal_status): raise SystemExit(46)
if mode=='prod' and state=='new':
if not result['html_marker'] or not result['css_marker']: raise SystemExit(47)
elif mode=='prod' and state=='old':
if result['html_marker'] or result['css_marker'] or not result['old_copy']: raise SystemExit(48)
elif mode=='staging' and state=='new':
if forced_css_status!=200 or not forced_css_marker: raise SystemExit(49)
elif mode=='staging' and state=='old':
pass
else:
raise SystemExit(50)
'''
def parse_inspect(text):
b = "PORTAL200_INSPECT_JSON_BEGIN\n"
e = "\nPORTAL200_INSPECT_JSON_END"
if b not in text or e not in text:
fail("INSPECT_MARKERS")
try:
return json.loads(text.split(b, 1)[1].split(e, 1)[0])
except Exception:
fail("INSPECT_JSON")
def inspect(target):
return parse_inspect(
qga(target, ["/usr/bin/php8.3"], input_bytes=INSPECT_PHP.encode(), timeout=120)
)
def source_is_old(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_OLD.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return src.get("Service/PortalData.php") is None
def source_is_new(data):
src = data["source"]
for rel, (size, sha) in EXPECTED_NEW.items():
row = src.get(rel)
if not row or row.get("bytes") != size or row.get("sha256") != sha:
return False
return True
def template_state_new(data):
rows = data.get("templates", [])
rows0 = [r for r in rows if int(r.get("style_id", -1)) == 0]
titles = [r.get("title") for r in rows0]
by = {r.get("title"): r for r in rows0}
return (
titles == ["newfi_portal", "newfi_portal.less"]
and int(by["newfi_portal"].get("html_marker", 0)) == 1
and int(by["newfi_portal.less"].get("css_marker", 0)) == 1
)
def app_contract(data, target):
a = data.get("application", {})
addon = data.get("addon") or {}
expected = {
"staging": ("0", "http://newfi-staging.gram1.ru"),
"prod": ("1", "https://newfi.ru"),
}[target]
return (
a.get("boardActive") == expected[0]
and a.get("boardUrl") == expected[1]
and a.get("indexRoute") == "portal/"
and a.get("defaultStyleId") == "4"
and addon.get("addon_id") == "Newfi/Portal"
and int(addon.get("version_id", 0)) == 1000010
and addon.get("version_string") == "1.0.0 Alpha 1"
and int(addon.get("active", 0)) == 1
and int(addon.get("is_processing", 1)) == 0
)
def new_errors(target, baseline):
raw = qga(
target,
["/usr/bin/php8.3"],
input_bytes=(ERRORS_PHP_TEMPLATE % int(baseline)).encode(),
timeout=90,
)
try:
return json.loads(raw)
except Exception:
fail("ERROR_LOG_JSON")
def assert_no_new_errors(target, baseline, phase):
rows = new_errors(target, baseline)
if rows:
emit(
"PORTAL200_NEW_XF_ERRORS",
{
"target": target,
"phase": phase,
"count": len(rows),
"ids": [r.get("error_id") for r in rows],
},
)
fail("NEW_XF_ERRORS")
def guest_backup(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test ! -e "$B"
install -d -m 700 "$B/Pub/Controller" "$B/_data"
cp -p -- "$P/Pub/Controller/Portal.php" "$B/Pub/Controller/Portal.php"
cp -p -- "$P/_data/templates.xml" "$B/_data/templates.xml"
test ! -e "$P/Service/PortalData.php"
printf 'PORTAL200_BACKUP=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_BACKUP=PASS" not in out:
fail("BACKUP_FAILED")
def guest_write(target, rel, data):
dest = f"/var/www/forums/newfi/public/src/addons/Newfi/Portal/{rel}"
parent = str(pathlib.PurePosixPath(dest).parent)
script = (
"set -eu; umask 022; "
+ "install -d -m 755 "
+ shlex.quote(parent)
+ "; T=$(mktemp "
+ shlex.quote(parent + "/.portal200.XXXXXX")
+ "); cat >\"$T\"; chmod 0644 \"$T\"; chown root:root \"$T\"; "
+ "mv -f -- \"$T\" "
+ shlex.quote(dest)
+ "; printf 'PORTAL200_WRITE=PASS\\n'"
)
out = qga(
target,
["/bin/bash", "-lc", script],
input_bytes=data,
timeout=90,
)
if "PORTAL200_WRITE=PASS" not in out:
fail("WRITE_FAILED")
def install_candidate(target):
for rel in [
"Pub/Controller/Portal.php",
"Service/PortalData.php",
"_data/templates.xml",
]:
guest_write(target, rel, (CAND_ROOT / rel).read_bytes())
def restore_old(target):
script = f'''set -eu
B={shlex.quote(BACKUP)}
P=/var/www/forums/newfi/public/src/addons/Newfi/Portal
test -f "$B/Pub/Controller/Portal.php"
test -f "$B/_data/templates.xml"
cp -p -- "$B/Pub/Controller/Portal.php" "$P/Pub/Controller/Portal.php"
cp -p -- "$B/_data/templates.xml" "$P/_data/templates.xml"
rm -f -- "$P/Service/PortalData.php"
rmdir -- "$P/Service" 2>/dev/null || true
printf 'PORTAL200_SOURCE_RESTORE=PASS\n'
'''
out = qga(target, ["/bin/bash", "-lc", script], timeout=90)
if "PORTAL200_SOURCE_RESTORE=PASS" not in out:
fail("SOURCE_RESTORE_FAILED")
def import_addon(target):
out = qga(
target,
["/usr/bin/php8.3"],
input_bytes=IMPORT_PHP.encode(),
timeout=240,
)
if "PORTAL200_IMPORT_JSON=" not in out:
fail("IMPORT_FAILED")
def runtime_verify(target, state):
src = RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", state)
out = qga(target, ["/usr/bin/php8.3"], input_bytes=src.encode(), timeout=120)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_RUNTIME_JSON=")),
None,
)
if not line:
fail("RUNTIME_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("RUNTIME_JSON")
emit("PORTAL200_RUNTIME", {"target": target, **data})
return data
def http_verify(target, state):
out = qga(
target,
["/usr/bin/python3", "-", target, state],
input_bytes=HTTP_VERIFY_PY.encode(),
timeout=180,
)
line = next(
(x for x in out.splitlines() if x.startswith("PORTAL200_HTTP_JSON=")),
None,
)
if not line:
fail("HTTP_MARKER_MISSING")
try:
data = json.loads(line.split("=", 1)[1])
except Exception:
fail("HTTP_JSON")
emit("PORTAL200_HTTP", {"target": target, "state": state, **data})
return data
def verify_new(target, baseline):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_NEW")
if not source_is_new(data):
fail("SOURCE_NEW_MISMATCH")
if not template_state_new(data):
fail("TEMPLATE_NEW_MISMATCH")
runtime_verify(target, "new")
http_verify(target, "new")
assert_no_new_errors(target, baseline, "verify-new")
return inspect(target)
def verify_old(target, baseline, old_templates, strict_errors=True):
data = inspect(target)
if not app_contract(data, target):
fail("APP_CONTRACT_OLD")
if not source_is_old(data):
fail("SOURCE_OLD_MISMATCH")
if data.get("templates") != old_templates:
fail("TEMPLATE_ROLLBACK_MISMATCH")
if target == "prod":
runtime_verify(target, "old")
http_verify(target, "old")
if strict_errors:
assert_no_new_errors(target, baseline, "verify-old")
return inspect(target)
def lint_php(target, source, label):
out = qga(
target,
["/usr/bin/php8.3", "-l"],
input_bytes=source.encode(),
timeout=90,
)
if "No syntax errors detected" not in out:
fail("PHP_LINT_" + label)
def cluster_guard():
p = run(["/usr/sbin/qm", "status", "210"], timeout=30)
if "status: running" not in p.stdout.decode():
fail("VM210_NOT_RUNNING")
p = run(
[
"/usr/bin/ssh",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"UpdateHostKeys=no",
"-o",
"ConnectTimeout=10",
"root@pve03",
"/usr/sbin/qm status 211",
],
timeout=40,
)
if "status: running" not in p.stdout.decode():
fail("VM211_NOT_RUNNING")
emit("PORTAL200_CLUSTER", "PASS")
def result_contract(status, changes, rollback_started, rollback_restored):
emit(
"HOMELAB_RESULT_CONTRACT",
{
"version": 1,
"command_id": CID,
"status": status,
"changes_made": bool(changes),
"rollback_started": bool(rollback_started),
"rollback_restored": rollback_restored,
},
)
emit("NEWFI200_BEGIN")
emit("COMMAND_ID", CID)
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_BEFORE", 22)
emit(
"PORTAL200_FLOW",
"candidate->staging-apply->rollback-proof->staging-reapply->git-push->production",
)
staging_backup = False
staging_mutated = False
staging_final_new = False
prod_backup = False
prod_mutated = False
git_files_changed = False
commit_created = False
push_confirmed = False
failure_rollback_started = False
failure_rollback_restored = None
old_git_bytes = {}
new_head = None
s_error = None
p_error = None
s_old_templates = None
p_old_templates = None
try:
compile(HTTP_VERIFY_PY, "<portal200-http-verify>", "exec")
for rel, (size, sha) in EXPECTED_NEW.items():
p = CAND_ROOT / rel
if not p.is_file() or file_meta(p) != (size, sha):
fail("CANDIDATE_IDENTITY")
tree = ET.parse(CAND_ROOT / "_data/templates.xml")
titles = sorted(x.attrib.get("title") for x in tree.getroot().findall("template"))
if titles != ["newfi_portal", "newfi_portal.less"]:
fail("CANDIDATE_TEMPLATE_SET")
service = (CAND_ROOT / "Service/PortalData.php").read_text("utf-8")
if any(
token in service
for token in [
"\\XF::db(",
"->save(",
"->delete(",
"INSERT ",
"UPDATE ",
"DELETE ",
"REPLACE ",
]
):
fail("CANDIDATE_MUTATION_SCAN")
emit("PORTAL200_CANDIDATE_IDENTITY", "PASS")
branch = git("branch", "--show-current")
head = git("rev-parse", "HEAD")
status = git("status", "--porcelain=v1", "--untracked-files=all")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote = remote_out.split()[0] if remote_out else ""
if branch != BRANCH or head != OLD_HEAD or remote != OLD_HEAD or status:
fail("GIT_PRESTATE")
emit(
"PORTAL200_GIT_PRESTATE",
{"branch": branch, "head": head, "remote": remote, "clean": True},
)
cluster_guard()
lint_php("staging", INSPECT_PHP, "INSPECT_STAGING")
lint_php("staging", IMPORT_PHP, "IMPORT_STAGING")
lint_php("staging", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_STAGING")
lint_php("prod", INSPECT_PHP, "INSPECT_PROD")
lint_php("prod", IMPORT_PHP, "IMPORT_PROD")
lint_php("prod", RUNTIME_VERIFY_PHP_TEMPLATE.replace("__STATE__", "new"), "RUNTIME_PROD")
emit("PORTAL200_RUNTIME_HELPER_LINT", "PASS")
s0 = inspect("staging")
p0 = inspect("prod")
if not app_contract(s0, "staging") or not app_contract(p0, "prod"):
fail("APP_PRESTATE")
if not source_is_old(s0) or not source_is_old(p0):
fail("SOURCE_PRESTATE")
if s0.get("templates") != p0.get("templates"):
fail("TEMPLATE_PRESTATE_PARITY")
s_error = int(s0["application"]["error_max_id"])
p_error = int(p0["application"]["error_max_id"])
s_old_templates = s0["templates"]
p_old_templates = p0["templates"]
emit("PORTAL200_ERROR_BASELINE", {"staging": s_error, "prod": p_error})
http_verify("staging", "old")
runtime_verify("prod", "old")
http_verify("prod", "old")
assert_no_new_errors("staging", s_error, "preflight")
assert_no_new_errors("prod", p_error, "preflight")
emit("PORTAL200_PREFLIGHT", "PASS")
guest_backup("staging")
staging_backup = True
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
emit("PORTAL200_STAGING_FIRST_APPLY", "PASS")
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates)
staging_mutated = False
emit("PORTAL200_STAGING_ROLLBACK_PROOF", "PASS")
staging_mutated = True
install_candidate("staging")
import_addon("staging")
verify_new("staging", s_error)
staging_final_new = True
emit("PORTAL200_STAGING_FINAL", "PASS")
for rel in EXPECTED_NEW:
dest = GIT_PORTAL / rel
old_git_bytes[str(rel)] = dest.read_bytes() if dest.exists() else None
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CAND_ROOT / rel, dest)
git_files_changed = True
if git("diff", "--check"):
fail("GIT_DIFF_CHECK")
status_lines = git("status", "--porcelain=v1", "--untracked-files=all").splitlines()
actual_paths = sorted(line[3:] for line in status_lines if len(line) >= 4)
if actual_paths != sorted(EXPECTED_GIT_PATHS):
fail("GIT_SCOPE")
git("add", "--", *EXPECTED_GIT_PATHS)
staged = sorted(x for x in git("diff", "--cached", "--name-only").splitlines() if x)
if staged != sorted(EXPECTED_GIT_PATHS):
fail("GIT_STAGED_SCOPE")
git("commit", "-m", f"feat: build data-backed Newfi portal [{CID}]")
commit_created = True
new_head = git("rev-parse", "HEAD")
if new_head == OLD_HEAD:
fail("GIT_COMMIT_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("GIT_POSTCOMMIT_DIRTY")
git("push", "origin", f"HEAD:refs/heads/{BRANCH}", timeout=180)
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
remote_head = remote_out.split()[0] if remote_out else ""
if remote_head != new_head:
fail("GIT_REMOTE_HEAD")
push_confirmed = True
emit(
"PORTAL200_GIT_PROMOTION",
{
"old_head": OLD_HEAD,
"new_head": new_head,
"remote_head": remote_head,
"result": "PASS",
},
)
p_pre = inspect("prod")
if not source_is_old(p_pre) or p_pre.get("templates") != p_old_templates:
fail("PROD_DRIFT_BEFORE_APPLY")
if int(p_pre["application"]["error_max_id"]) != p_error:
fail("PROD_ERROR_DRIFT")
guest_backup("prod")
prod_backup = True
prod_mutated = True
install_candidate("prod")
import_addon("prod")
verify_new("prod", p_error)
emit("PORTAL200_PRODUCTION", "PASS")
sf = verify_new("staging", s_error)
pf = verify_new("prod", p_error)
if sf["source"] != pf["source"]:
fail("FINAL_SOURCE_PARITY")
if sf["templates"] != pf["templates"]:
fail("FINAL_TEMPLATE_PARITY")
if git("rev-parse", "HEAD") != new_head:
fail("FINAL_LOCAL_HEAD")
remote_out = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
if not remote_out or remote_out.split()[0] != new_head:
fail("FINAL_REMOTE_HEAD")
if git("status", "--porcelain=v1", "--untracked-files=all"):
fail("FINAL_GIT_DIRTY")
cluster_guard()
emit(
"PORTAL200_FINAL_EVIDENCE",
{
"commit": new_head,
"source_files": 3,
"templates": 2,
"staging_error_baseline": s_error,
"prod_error_baseline": p_error,
},
)
emit("PORTAL200_PORTAL", "PASS")
emit("PORTAL200_STAGING", "PASS")
emit("PORTAL200_PRODUCTION", "PASS")
emit("PORTAL200_REMAINING_FULL_PRODUCT_COMMANDS_AFTER_PASS", 21)
result_contract("OK", True, False, None)
emit("NEWFI200_END")
raise SystemExit(0)
except PortalError as exc:
code = str(exc)
emit("PORTAL200_FAILURE", code)
if push_confirmed:
if prod_mutated and prod_backup:
failure_rollback_started = True
try:
restore_old("prod")
import_addon("prod")
verify_old("prod", p_error, p_old_templates, strict_errors=False)
prod_mutated = False
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "PASS_PARTIAL_OVERALL_STATE_CHANGED")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_PROD_ROLLBACK", "FAIL")
else:
if staging_mutated and staging_backup:
failure_rollback_started = True
try:
restore_old("staging")
import_addon("staging")
verify_old("staging", s_error, s_old_templates, strict_errors=False)
staging_mutated = False
staging_final_new = False
failure_rollback_restored = True
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "PASS")
except Exception:
failure_rollback_restored = False
emit("PORTAL200_FAILURE_STAGING_ROLLBACK", "FAIL")
if git_files_changed and not commit_created:
try:
for rel, old in old_git_bytes.items():
dest = GIT_PORTAL / rel
if old is None:
if dest.exists():
dest.unlink()
else:
dest.write_bytes(old)
service_dir = GIT_PORTAL / "Service"
try:
service_dir.rmdir()
except OSError:
pass
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "PASS")
except Exception:
emit("PORTAL200_FAILURE_GIT_WORKTREE_RESTORE", "FAIL")
if commit_created:
emit(
"PORTAL200_FAILURE_GIT_HISTORY",
"COMMIT_CREATED_NO_DESTRUCTIVE_RESET_FRESH_ID_REQUIRED",
)
persisted = bool(push_confirmed or commit_created or staging_final_new or prod_mutated)
if failure_rollback_started and failure_rollback_restored is None:
failure_rollback_restored = False
result_contract(
"ERROR",
persisted,
failure_rollback_started,
failure_rollback_restored,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
except Exception:
emit("PORTAL200_FAILURE", "UNEXPECTED_EXCEPTION")
result_contract(
"ERROR",
bool(push_confirmed or commit_created or staging_final_new or prod_mutated),
False,
None,
)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("NEWFI200_END")
raise SystemExit(1)
CANDIDATE_LAYER_4_TEXT_END
CANDIDATE_LAYER_5_SOURCE=file:newfi-55-portal200-apply3-rca.cmd::b64::zlib
CANDIDATE_LAYER_5_SHA256=8911318cd314572edf9c6343c09b4c83d283e946dd2398ea2e05722ac9bd44ac
CANDIDATE_LAYER_5_BYTES=10641
CANDIDATE_LAYER_5_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import subprocess
CID = "NEWFI-260907-PORTAL200-APPLY3RCA1"
FAILED_CID = "NEWFI-260907-PORTAL200-APPLY3"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
FAILED_DIR = pathlib.Path("/root/newfi-run-" + FAILED_CID)
WRAPPER = FAILED_DIR / "wrapper.log"
OLD = {
"controller": "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3",
"templates_xml": "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf",
}
NEW = {
"controller": "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d",
"service": "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d",
"templates_xml": "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861",
}
INSPECT = '<?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?>'
def emit(k, v=None):
if v is None:
print(k, flush=True)
elif isinstance(v, (dict, list, bool, int, float)):
print(k + "=" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")), flush=True)
else:
print(k + "=" + str(v), flush=True)
def run(cmd, *, input_bytes=None, timeout=120):
return subprocess.run(
cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, check=False
)
def git(*args):
p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=90)
return p.returncode, p.stdout.decode("utf-8", "replace").strip(), p.stderr.decode("utf-8", "replace").strip()
def qga(target, php_source):
if target == "prod":
cmd = [
"/usr/sbin/qm", "guest", "exec", "210",
"--synchronous", "1", "--timeout", "120",
"--pass-stdin", "1", "--", "/usr/bin/php8.3"
]
else:
q = [
"/usr/sbin/qm", "guest", "exec", "211",
"--synchronous", "1", "--timeout", "120",
"--pass-stdin", "1", "--", "/usr/bin/php8.3"
]
remote = (
"test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
"test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
+ " ".join(shlex.quote(x) for x in q)
)
cmd = [
"/usr/bin/ssh", "-T",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", "UpdateHostKeys=no",
"-o", "ConnectTimeout=10",
"-o", "ConnectionAttempts=1",
"root@pve03", remote
]
p = run(cmd, input_bytes=php_source.encode("utf-8"), timeout=150)
if p.returncode != 0:
return {"transport_rc": p.returncode, "transport_error": p.stderr.decode("utf-8", "replace")[:500]}
try:
outer = json.loads(p.stdout.decode("utf-8", "replace"))
except Exception:
return {"transport_rc": 0, "outer_json": False, "raw": p.stdout.decode("utf-8", "replace")[:1000]}
if outer.get("exitcode") != 0:
return {
"transport_rc": 0,
"guest_rc": outer.get("exitcode"),
"guest_out": outer.get("out-data", "")[:1000],
"guest_err": outer.get("err-data", "")[:1000],
}
out = outer.get("out-data", "")
marker = "PORTAL200_RCA_GUEST_JSON="
line = next((x for x in out.splitlines() if x.startswith(marker)), None)
if not line:
return {"transport_rc": 0, "guest_rc": 0, "marker": False, "guest_out": out[:1000]}
return json.loads(line[len(marker):])
def source_state(data):
src = data.get("source", {})
controller = (src.get("controller") or {}).get("sha256")
service = src.get("service")
templates = (src.get("templates_xml") or {}).get("sha256")
if controller == OLD["controller"] and service is None and templates == OLD["templates_xml"]:
return "OLD"
if (
controller == NEW["controller"]
and (service or {}).get("sha256") == NEW["service"]
and templates == NEW["templates_xml"]
):
return "NEW"
return "DRIFT"
def template_state(data):
rows = data.get("templates", [])
titles = [r.get("title") for r in rows]
has_html = any(bool(r.get("html_marker")) for r in rows)
has_css = any(bool(r.get("css_marker")) for r in rows)
if titles == ["newfi_portal"] and not has_html and not has_css:
return "OLD"
if titles == ["newfi_portal", "newfi_portal.less"] and has_html and has_css:
return "NEW"
return "DRIFT"
emit("NEWFI55_BEGIN")
emit("COMMAND_ID", CID)
emit("RCA_TARGET_FAILED_COMMAND", FAILED_CID)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("RCA_MUTATION_SCOPE", "NONE_READ_ONLY")
if WRAPPER.is_file() and not WRAPPER.is_symlink():
raw = WRAPPER.read_bytes()
emit("RCA_WRAPPER_BYTES", len(raw))
emit("RCA_WRAPPER_SHA256", hashlib.sha256(raw).hexdigest())
text = raw.decode("utf-8", "replace")
keep_prefixes = (
"CHAT_OUTPUT_BEGIN", "CHAT_OUTPUT_END", "COMMAND_ID=", "STATUS=", "RC=",
"RESULT_CONTRACT_", "COMMAND_RC=", "CHANGES_MADE=", "ROLLBACK_",
"MUTATION_OUTCOME=", "OUTPUT_BEGIN", "OUTPUT_END", "NEWFI200",
"PORTAL200_", "HOMELAB_RESULT_CONTRACT=", "STOP=", "Traceback",
)
selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]
emit("RCA_WRAPPER_SELECTED_LINE_COUNT", len(selected))
print("RCA_WRAPPER_SELECTED_BEGIN", flush=True)
for line in selected[-250:]:
print(line, flush=True)
print("RCA_WRAPPER_SELECTED_END", flush=True)
else:
emit("RCA_WRAPPER_MISSING", True)
_, branch, _ = git("branch", "--show-current")
_, head, _ = git("rev-parse", "HEAD")
_, remote_out, _ = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
_, status, _ = git("status", "--porcelain=v1", "--untracked-files=all")
remote = remote_out.split()[0] if remote_out else ""
git_state = {
"branch": branch,
"head": head,
"remote": remote,
"clean": (status == ""),
"status_lines": status.splitlines()[:20],
}
emit("RCA_GIT_STATE", git_state)
staging = qga("staging", INSPECT)
prod = qga("prod", INSPECT)
emit("RCA_STAGING_STATE", staging)
emit("RCA_PROD_STATE", prod)
s_src = source_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_src = source_state(prod) if isinstance(prod, dict) else "UNKNOWN"
s_tpl = template_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_tpl = template_state(prod) if isinstance(prod, dict) else "UNKNOWN"
summary = {
"git_old_head": head == OLD_HEAD and remote == OLD_HEAD,
"git_clean": status == "",
"staging_source": s_src,
"staging_templates": s_tpl,
"staging_error_max": staging.get("application", {}).get("error_max_id") if isinstance(staging, dict) else None,
"prod_source": p_src,
"prod_templates": p_tpl,
"prod_error_max": prod.get("application", {}).get("error_max_id") if isinstance(prod, dict) else None,
}
summary["clean_old_prestate"] = bool(
summary["git_old_head"]
and summary["git_clean"]
and s_src == "OLD"
and s_tpl == "OLD"
and p_src == "OLD"
and p_tpl == "OLD"
)
emit("RCA_STATE_SUMMARY", summary)
runner_path = pathlib.Path("/usr/local/sbin/homelab-chat-run")
if runner_path.is_file() and not runner_path.is_symlink():
runner_bytes = runner_path.read_bytes()
runner_text = runner_bytes.decode("utf-8", "replace")
emit("RCA_RUNNER_SHA256", hashlib.sha256(runner_bytes).hexdigest())
emit("RCA_RUNNER_BYTES", len(runner_bytes))
lines = runner_text.splitlines()
terms = (
"HOMELAB_RESULT_CONTRACT",
"rollback_restored",
"rollback_started",
"changes_made",
"status not in",
"ValueError",
)
indexes = set()
for i, line in enumerate(lines):
if any(t in line for t in terms):
for j in range(max(0, i - 3), min(len(lines), i + 4)):
indexes.add(j)
print("RCA_RUNNER_CONTRACT_SOURCE_BEGIN", flush=True)
for j in sorted(indexes):
line = lines[j]
if len(line) > 500:
line = line[:500] + "...[TRUNCATED]"
print(json.dumps({"line": j + 1, "text": line}, ensure_ascii=False, separators=(",", ":")), flush=True)
print("RCA_RUNNER_CONTRACT_SOURCE_END", flush=True)
else:
emit("RCA_RUNNER_SOURCE_MISSING", True)
emit("HOMELAB_RESULT_CONTRACT", {
"version": 1,
"command_id": CID,
"status": "OK",
"changes_made": False,
"rollback_started": False,
"rollback_restored": None
})
emit("NEWFI55_END")
CANDIDATE_LAYER_5_TEXT_END
CANDIDATE_LAYER_6_SOURCE=file:newfi-39-portal200-rca1.cmd::b64::zlib
CANDIDATE_LAYER_6_SHA256=08edd716820df9797dc01cbee765750d9924fc4f94855fde4aba2c25eacb76f2
CANDIDATE_LAYER_6_BYTES=9466
CANDIDATE_LAYER_6_TEXT_BEGIN
import hashlib
import json
import pathlib
import shlex
import subprocess
CID = "NEWFI-260907-PORTAL200-RCA1"
FAILED_CID = "NEWFI-260907-PORTAL200-APPLY1"
WT = pathlib.Path("/root/newfi-portal/.worktrees/implementation-newfi-closed-mvp-20260906")
BRANCH = "implementation/newfi-closed-mvp-20260906"
OLD_HEAD = "06bab5acd124e768f70305b3d7eca2f595871890"
FAILED_DIR = pathlib.Path("/root/newfi-run-" + FAILED_CID)
WRAPPER = FAILED_DIR / "wrapper.log"
OLD = {
"controller": "53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3",
"templates_xml": "c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf",
}
NEW = {
"controller": "619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d",
"service": "4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d",
"templates_xml": "1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861",
}
INSPECT = '<?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?>'
def emit(k, v=None):
if v is None:
print(k, flush=True)
elif isinstance(v, (dict, list, bool, int, float)):
print(k + "=" + json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")), flush=True)
else:
print(k + "=" + str(v), flush=True)
def run(cmd, *, input_bytes=None, timeout=120):
return subprocess.run(
cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, check=False
)
def git(*args):
p = run(["/usr/bin/git", "-C", str(WT), *args], timeout=90)
return p.returncode, p.stdout.decode("utf-8", "replace").strip(), p.stderr.decode("utf-8", "replace").strip()
def qga(target, php_source):
if target == "prod":
cmd = [
"/usr/sbin/qm", "guest", "exec", "210",
"--synchronous", "1", "--timeout", "120",
"--pass-stdin", "1", "--", "/usr/bin/php8.3"
]
else:
q = [
"/usr/sbin/qm", "guest", "exec", "211",
"--synchronous", "1", "--timeout", "120",
"--pass-stdin", "1", "--", "/usr/bin/php8.3"
]
remote = (
"test \"$(id -u):$(hostname -s)\" = 0:pve03 || exit 72; "
"for f in /etc/pve/HOMELAB_ASSISTANT_ERROR_REGISTER.md /etc/pve/31_HOMELAB_REFERENCE.md; do "
"test -r \"$f\" && test -s \"$f\" && cat -- \"$f\" >/dev/null || exit 73; done; exec "
+ " ".join(shlex.quote(x) for x in q)
)
cmd = [
"/usr/bin/ssh", "-T",
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", "UpdateHostKeys=no",
"-o", "ConnectTimeout=10",
"-o", "ConnectionAttempts=1",
"root@pve03", remote
]
p = run(cmd, input_bytes=php_source.encode("utf-8"), timeout=150)
if p.returncode != 0:
return {"transport_rc": p.returncode, "transport_error": p.stderr.decode("utf-8", "replace")[:500]}
try:
outer = json.loads(p.stdout.decode("utf-8", "replace"))
except Exception:
return {"transport_rc": 0, "outer_json": False, "raw": p.stdout.decode("utf-8", "replace")[:1000]}
if outer.get("exitcode") != 0:
return {
"transport_rc": 0,
"guest_rc": outer.get("exitcode"),
"guest_out": outer.get("out-data", "")[:1000],
"guest_err": outer.get("err-data", "")[:1000],
}
out = outer.get("out-data", "")
marker = "PORTAL200_RCA_GUEST_JSON="
line = next((x for x in out.splitlines() if x.startswith(marker)), None)
if not line:
return {"transport_rc": 0, "guest_rc": 0, "marker": False, "guest_out": out[:1000]}
return json.loads(line[len(marker):])
def source_state(data):
src = data.get("source", {})
controller = (src.get("controller") or {}).get("sha256")
service = src.get("service")
templates = (src.get("templates_xml") or {}).get("sha256")
if controller == OLD["controller"] and service is None and templates == OLD["templates_xml"]:
return "OLD"
if (
controller == NEW["controller"]
and (service or {}).get("sha256") == NEW["service"]
and templates == NEW["templates_xml"]
):
return "NEW"
return "DRIFT"
def template_state(data):
rows = data.get("templates", [])
titles = [r.get("title") for r in rows]
has_html = any(bool(r.get("html_marker")) for r in rows)
has_css = any(bool(r.get("css_marker")) for r in rows)
if titles == ["newfi_portal"] and not has_html and not has_css:
return "OLD"
if titles == ["newfi_portal", "newfi_portal.less"] and has_html and has_css:
return "NEW"
return "DRIFT"
emit("NEWFI39_BEGIN")
emit("COMMAND_ID", CID)
emit("RCA_TARGET_FAILED_COMMAND", FAILED_CID)
emit("PORTAL200_ROADMAP_REMAINS", 22)
emit("RCA_MUTATION_SCOPE", "NONE_READ_ONLY")
if WRAPPER.is_file() and not WRAPPER.is_symlink():
raw = WRAPPER.read_bytes()
emit("RCA_WRAPPER_BYTES", len(raw))
emit("RCA_WRAPPER_SHA256", hashlib.sha256(raw).hexdigest())
text = raw.decode("utf-8", "replace")
keep_prefixes = (
"CHAT_OUTPUT_BEGIN", "CHAT_OUTPUT_END", "COMMAND_ID=", "STATUS=", "RC=",
"RESULT_CONTRACT_", "COMMAND_RC=", "CHANGES_MADE=", "ROLLBACK_",
"MUTATION_OUTCOME=", "OUTPUT_BEGIN", "OUTPUT_END", "NEWFI200",
"PORTAL200_", "HOMELAB_RESULT_CONTRACT=", "STOP=", "Traceback",
)
selected = [line for line in text.splitlines() if line.startswith(keep_prefixes)]
emit("RCA_WRAPPER_SELECTED_LINE_COUNT", len(selected))
print("RCA_WRAPPER_SELECTED_BEGIN", flush=True)
for line in selected[-250:]:
print(line, flush=True)
print("RCA_WRAPPER_SELECTED_END", flush=True)
else:
emit("RCA_WRAPPER_MISSING", True)
_, branch, _ = git("branch", "--show-current")
_, head, _ = git("rev-parse", "HEAD")
_, remote_out, _ = git("ls-remote", "origin", f"refs/heads/{BRANCH}")
_, status, _ = git("status", "--porcelain=v1", "--untracked-files=all")
remote = remote_out.split()[0] if remote_out else ""
git_state = {
"branch": branch,
"head": head,
"remote": remote,
"clean": (status == ""),
"status_lines": status.splitlines()[:20],
}
emit("RCA_GIT_STATE", git_state)
staging = qga("staging", INSPECT)
prod = qga("prod", INSPECT)
emit("RCA_STAGING_STATE", staging)
emit("RCA_PROD_STATE", prod)
s_src = source_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_src = source_state(prod) if isinstance(prod, dict) else "UNKNOWN"
s_tpl = template_state(staging) if isinstance(staging, dict) else "UNKNOWN"
p_tpl = template_state(prod) if isinstance(prod, dict) else "UNKNOWN"
summary = {
"git_old_head": head == OLD_HEAD and remote == OLD_HEAD,
"git_clean": status == "",
"staging_source": s_src,
"staging_templates": s_tpl,
"staging_error_max": staging.get("application", {}).get("error_max_id") if isinstance(staging, dict) else None,
"prod_source": p_src,
"prod_templates": p_tpl,
"prod_error_max": prod.get("application", {}).get("error_max_id") if isinstance(prod, dict) else None,
}
summary["clean_old_prestate"] = bool(
summary["git_old_head"]
and summary["git_clean"]
and s_src == "OLD"
and s_tpl == "OLD"
and p_src == "OLD"
and p_tpl == "OLD"
)
emit("RCA_STATE_SUMMARY", summary)
emit("HOMELAB_RESULT_CONTRACT", {
"version": 1,
"command_id": CID,
"status": "OK",
"changes_made": False,
"rollback_started": False,
"rollback_restored": None
})
emit("NEWFI39_END")
CANDIDATE_LAYER_6_TEXT_END
RECOVERED_VERSION_IDS=
RECOVERED_VERSION_STRINGS==
RECOVERED_SHA256_COUNT=11
RECOVERED_SHA256=458c2cf0763e4790f7d2d3757efd883b25813a31aab4e8dce6f785b8dea48a6e,834973af57263b326ca6aee52b2ecb3fa2cf0fb486e5a77a62d4210395b8d9c3,28e855ff56c4844768f0e8606fe0edc2acb3f548a5c06fa15c0d80ae00e2bc1b,fc9fb1dbdb6141bfb43a8122dcf3db435a94da48d0a0c02fe9dfae019b1439c5,2f7977cbfaed7953bd70033c0cf92138eedf974f5886fadf4b4971f2d00a4143,b248a4c32c9cc64e5747e7dce6c7fc0a23f5124a77c71ce72e27a81aceae9d2d,619d489f7cc3b461479c702fa8825097ae22d3c6cfeb911e9f14ea1de9c36d6d,4da03f26294ab6d163a1a746dee4bb6a82d7d4f39e66b24246bc0eb4c455dd1d,1650c3d868d4bc47703f1b75f1f179cd25309b2d3b20a2b848e81a4423c00861,53909e753ea5a011550fd280ad1c342a74ca9949be092dfbd95244243cae92f3,c29c04f8b937284e821c87957653e87c2f6ac014fac30648a0f6b18e84392adf
RECOVERY_RESULT=STRONG_APPLY3_CANDIDATE_EVIDENCE_FOUND
LOCAL_APPLY3_CARRIER_RECOVERY5_END=true
OUTPUT_END
CHAT_OUTPUT_END