5753 lines
180 KiB
Plaintext
5753 lines
180 KiB
Plaintext
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
|