Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ff149c35 | ||
|
|
eff704c675 | ||
|
|
6e9c163047 | ||
|
|
871bde1abd | ||
|
|
5c26bbe8b5 | ||
|
|
3d7df25d00 | ||
|
|
4d589eee95 | ||
|
|
3c17e2eb3b | ||
|
|
54e30d4228 | ||
|
|
00b0af69c2 | ||
|
|
81ab7aac39 | ||
|
|
8748760594 | ||
|
|
9d553744a1 | ||
|
|
710c483f34 | ||
|
|
f19faf628e | ||
|
|
9fc269283b | ||
|
|
826c1d8ca5 | ||
|
|
9a98fafefb | ||
|
|
849c19e2ab | ||
|
|
1c7ddf2fcb | ||
|
|
e653f3b50e | ||
|
|
726b1ee745 | ||
|
|
aa6fabe970 | ||
|
|
7437aef346 | ||
|
|
849c7537ed | ||
|
|
ee03dc6cc7 | ||
|
|
4cf96e3fa9 | ||
|
|
ba9564fca2 | ||
|
|
3eba203009 | ||
|
|
3fd5695bb2 | ||
|
|
42b3aa09a7 | ||
|
|
7ece39f212 | ||
|
|
bb3de5ee0f | ||
|
|
1ca41f162e | ||
|
|
afb3c9aa03 | ||
|
|
c8d0086a9f | ||
|
|
6cab6fcc95 | ||
|
|
8ede9f2123 | ||
|
|
ca5036abfe | ||
|
|
1c2a468516 | ||
|
|
b50cfa818d | ||
|
|
0630a774cf | ||
|
|
4a7e907be7 | ||
|
|
96ed0949ca | ||
|
|
3d267afaee | ||
|
|
4c4b51270c | ||
|
|
940b6c93f9 | ||
|
|
a54fef6a0c | ||
|
|
d65b857c2a | ||
|
|
6be28418ab | ||
|
|
f8483badcf | ||
|
|
74aa167d16 | ||
|
|
f1573edfa7 | ||
|
|
f90161f380 | ||
|
|
9a5a1a1a69 | ||
|
|
05fc47d7c3 | ||
|
|
e40a32971d | ||
|
|
eff81c1504 | ||
|
|
779743fdb0 | ||
|
|
8f331a002f | ||
|
|
d428919b78 | ||
|
|
9d8ce6b67f | ||
|
|
852d639c45 | ||
|
|
b5adc09203 | ||
|
|
a153dc8765 | ||
|
|
3620bb734c | ||
|
|
830e1d0f0d | ||
|
|
fd28ed2201 | ||
|
|
a1bb3eab98 | ||
|
|
8bd2b53469 | ||
|
|
21325d4e5c | ||
|
|
48cb424afd | ||
|
|
e5a8326a5e | ||
|
|
770e7930d9 | ||
|
|
cb3ab76d2b | ||
|
|
01380d3c09 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
|
||||
COPY release-build.sh /usr/local/bin/release-build
|
||||
COPY validate-destination.sh /usr/local/bin/validate-destination.sh
|
||||
ENTRYPOINT ["/busybox/sh", "/usr/local/bin/release-build"]
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Publish a pinned Akyldash image
|
||||
description: Build and push one commit-tagged image with pinned rootless Kaniko
|
||||
inputs:
|
||||
workspace:
|
||||
description: Checked out source directory
|
||||
required: true
|
||||
dockerfile:
|
||||
description: Dockerfile path relative to workspace
|
||||
required: true
|
||||
destination:
|
||||
description: Commit-specific OCI tag
|
||||
required: true
|
||||
digest_file:
|
||||
description: Workspace path for Kaniko's resulting digest
|
||||
required: true
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
args:
|
||||
- ${{ inputs.workspace }}
|
||||
- ${{ inputs.dockerfile }}
|
||||
- ${{ inputs.destination }}
|
||||
- ${{ inputs.digest_file }}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/busybox/sh
|
||||
set -eu
|
||||
|
||||
workspace=$1
|
||||
dockerfile=$2
|
||||
destination=$3
|
||||
digest_file=$4
|
||||
: "${REGISTRY_USERNAME:?registry username secret is required}"
|
||||
: "${REGISTRY_PASSWORD:?registry password secret is required}"
|
||||
/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination"
|
||||
|
||||
mkdir -p /kaniko/.docker "$(dirname "$digest_file")"
|
||||
auth=$(printf '%s:%s' "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')
|
||||
umask 077
|
||||
printf '{"auths":{"git.vakanaut.ru":{"auth":"%s"}}}\n' "$auth" > /kaniko/.docker/config.json
|
||||
unset auth REGISTRY_USERNAME REGISTRY_PASSWORD
|
||||
/kaniko/executor \
|
||||
--context="dir://${workspace}" \
|
||||
--dockerfile="${workspace}/${dockerfile}" \
|
||||
--destination="$destination" \
|
||||
--digest-file="$digest_file" \
|
||||
--no-push-cache
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo 'ERROR: expected Dockerfile path and image destination' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
dockerfile=$1
|
||||
destination=$2
|
||||
case "$dockerfile" in
|
||||
deploy/production/Dockerfile.backend)
|
||||
repository=git.vakanaut.ru/admin/akyldash/backend
|
||||
;;
|
||||
deploy/production/Dockerfile.opensearch)
|
||||
repository=git.vakanaut.ru/admin/akyldash/opensearch
|
||||
;;
|
||||
*)
|
||||
echo 'ERROR: Dockerfile is outside the approved production image set' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$destination" in
|
||||
"$repository":*) tag=${destination#"$repository":} ;;
|
||||
*)
|
||||
echo 'ERROR: destination is outside the approved registry repository' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
commit=${tag%%-*}
|
||||
run_id=${tag#*-}
|
||||
case "$commit" in
|
||||
''|*[!0123456789abcdef]*)
|
||||
echo 'ERROR: destination commit must be a lowercase Git SHA-1' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ "${#commit}" -ne 40 ] || [ "$run_id" = "$tag" ]; then
|
||||
echo 'ERROR: destination tag must contain a 40-character commit and run ID' >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$run_id" in
|
||||
''|*[!0123456789]*)
|
||||
echo 'ERROR: destination run ID must be numeric' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
infra/bootstrap/verify.sh infra/bootstrap/verify-helper.sh infra/bootstrap/audit-host.sh \
|
||||
infra/deploy/install-helper.sh infra/deploy/compose-helper.sh infra/deploy/test-compose-helper.sh \
|
||||
infra/deploy/prepare-production-config.sh infra/deploy/test-production-config-guard.sh \
|
||||
.gitea/actions/kaniko-push/release-build.sh \
|
||||
infra/bootstrap/test-audit-host.sh infra/bootstrap/test-root-guard.sh \
|
||||
infra/bootstrap/test-verify-helper.sh infra/test/run-as-unprivileged.sh \
|
||||
infra/runner/install-runner.sh infra/runner/install-runner-lib.sh \
|
||||
@@ -52,6 +53,12 @@ jobs:
|
||||
infra/runner/test-ci-container-builds.sh
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deploy_release.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/create_release_manifest.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_handoff.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_refs.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_state.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deployment_lock.py').read_text())"
|
||||
- name: Run deployment contract and rollback tests
|
||||
run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v
|
||||
- name: Validate production Compose and Caddyfile
|
||||
@@ -75,3 +82,14 @@ jobs:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
destination: akyldash-opensearch:ci
|
||||
- name: Prepare a synthetic artifact compatibility marker
|
||||
run: |
|
||||
mkdir -p artifact-smoke
|
||||
printf 'Gitea artifact upload smoke test; no release data.\n' > artifact-smoke/marker.txt
|
||||
- name: Verify Gitea runtime artifact upload
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
|
||||
with:
|
||||
name: artifact-smoke-${{ github.run_id }}
|
||||
path: artifact-smoke/marker.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Production handoff status (not deployed)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release production images"]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deployment-status:
|
||||
name: NOT DEPLOYED - handoff status only
|
||||
if: >-
|
||||
gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'workflow_run' &&
|
||||
gitea.event.workflow_run.head_branch == 'main'
|
||||
runs-on: akyldash-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
- name: Verify source release identity before artifact lookup
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-handoff-source
|
||||
- name: Read artifact metadata with the installed Gitea helper when available
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: |
|
||||
if ! command -v gitea-api >/dev/null 2>&1; then
|
||||
echo 'DRY-RUN only: gitea-api is unavailable in this job; artifact metadata and host handoff are not verified.'
|
||||
python3 infra/deploy/release_handoff.py dry-run
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z ${GITEA_TOKEN:-} ]]; then
|
||||
echo 'DRY-RUN only: GITEA_TOKEN is unavailable; artifact metadata and host handoff are not verified.'
|
||||
python3 infra/deploy/release_handoff.py dry-run
|
||||
exit 0
|
||||
fi
|
||||
metadata=$(mktemp)
|
||||
trap 'rm -f "$metadata"' EXIT
|
||||
gitea-api GET "/repos/admin/akyldash/actions/runs/${SOURCE_RUN_ID}/artifacts" > "$metadata"
|
||||
python3 infra/deploy/release_handoff.py dry-run --artifact-metadata "$metadata"
|
||||
- name: State that no deployment was performed
|
||||
run: |
|
||||
echo 'DEPLOYMENT STATUS: NOT DEPLOYED'
|
||||
echo 'This workflow checks source provenance and may inspect artifact metadata only.'
|
||||
echo 'It does not download the descriptor ZIP, open SSH, or invoke the Windows receiver.'
|
||||
echo 'No Windows execution runner or automatic runtime binding has been verified.'
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Release production images
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
publish-images:
|
||||
if: >-
|
||||
gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'push' &&
|
||||
gitea.event.workflow_run.head_branch == 'main'
|
||||
runs-on: akyldash-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||
- name: Verify successful CI source before using registry credentials
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-ci-source
|
||||
- name: Require explicitly configured Gitea registry credentials
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
test -n "$REGISTRY_USERNAME" || { echo 'REGISTRY_USERNAME repository secret is required'; exit 1; }
|
||||
test -n "$REGISTRY_PASSWORD" || { echo 'REGISTRY_PASSWORD repository secret is required'; exit 1; }
|
||||
- name: Build and publish backend image
|
||||
uses: ./.gitea/actions/kaniko-push
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
with:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.backend
|
||||
destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
|
||||
digest_file: ${{ github.workspace }}/release/backend.digest
|
||||
- name: Build and publish OpenSearch image
|
||||
uses: ./.gitea/actions/kaniko-push
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
with:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
|
||||
digest_file: ${{ github.workspace }}/release/opensearch.digest
|
||||
- name: Create and validate release descriptor
|
||||
run: |
|
||||
python3 infra/deploy/create_release_manifest.py \
|
||||
--repository admin/akyldash \
|
||||
--commit '${{ gitea.event.workflow_run.head_sha }}' \
|
||||
--run-id '${{ gitea.run_id }}' \
|
||||
--source-run-id '${{ gitea.event.workflow_run.id }}' \
|
||||
--backend-digest release/backend.digest \
|
||||
--opensearch-digest release/opensearch.digest \
|
||||
--output release/release-manifest.json
|
||||
python3 infra/deploy/validate_release.py release/release-manifest.json \
|
||||
--policy infra/deploy/release-policy.json
|
||||
(cd release && sha256sum release-manifest.json > release-manifest.json.sha256)
|
||||
- id: descriptor
|
||||
name: Record release descriptor digest
|
||||
run: |
|
||||
(cd release && sha256sum -c release-manifest.json.sha256)
|
||||
digest=$(sha256sum release/release-manifest.json)
|
||||
digest=${digest%% *}
|
||||
printf 'descriptor_sha256=%s\n' "$digest" >> "$GITEA_OUTPUT"
|
||||
- name: Upload verified release descriptor
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
|
||||
with:
|
||||
name: release-descriptor-${{ gitea.run_id }}-${{ steps.descriptor.outputs.descriptor_sha256 }}
|
||||
path: |
|
||||
release/release-manifest.json
|
||||
release/release-manifest.json.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
- name: Confirm uploaded release descriptor
|
||||
run: |
|
||||
(cd release && sha256sum -c release-manifest.json.sha256)
|
||||
echo 'Release descriptor and checksum are available as this run artifact.'
|
||||
@@ -1,5 +1,6 @@
|
||||
# Local credentials and secrets
|
||||
credentials.json
|
||||
codex.json
|
||||
|
||||
# Python/runtime artifacts (for the upcoming bot implementation)
|
||||
__pycache__/
|
||||
@@ -17,3 +18,7 @@ data/
|
||||
|
||||
# Local runtime logs
|
||||
logs/
|
||||
|
||||
# Local generated reports; deliberately publish reviewed/sanitized evidence with git add -f
|
||||
/reports/
|
||||
/tmp/
|
||||
@@ -1,7 +1,11 @@
|
||||
# Акылдаш — инструкции для агента
|
||||
|
||||
Проект: юридическая информационно-аналитическая платформа; Search API на
|
||||
Python, SQLite и OpenSearch. Production пока не развернут.
|
||||
Python, SQLite и OpenSearch. На 2026-10-25 публичный `/review` и прямой origin
|
||||
отвечают HTTP 200; состав production-контейнеров, готовность индекса и
|
||||
проверенность backup/restore не подтверждены. Сверяй актуальное состояние с
|
||||
[`CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md), не считай HTTP 200
|
||||
доказательством готовности поиска.
|
||||
|
||||
- Начинай с [инфраструктурного состояния](docs/operations/CURRENT_STATE.md) и
|
||||
[правил доступа](docs/operations/ACCESS.md). Не запрашивай доступы повторно.
|
||||
@@ -37,6 +41,31 @@ Python, SQLite и OpenSearch. Production пока не развернут.
|
||||
- После каждого изменения инфраструктуры обновляй
|
||||
`docs/operations/CURRENT_STATE.md` по фактическим проверкам.
|
||||
|
||||
## Delivery First: проверка по уровню риска
|
||||
|
||||
- Перед работой укажи в PR уровень `LOW`, `MEDIUM` или `HIGH`; при сомнении
|
||||
выбери более высокий. Уровень добавляет нужную проверку, но никогда не
|
||||
отменяет CI, одобрение или защиту `main`.
|
||||
- Каждый PR сохраняет текущие проверки Gitea Actions: job IDs
|
||||
`tests-and-images` и `runner-smoke`. Не переименовывай и не обходи их, не
|
||||
удаляй тесты или проверки безопасности ради скорости.
|
||||
- `LOW`: документация, комментарии и тексты без изменения исполняемого
|
||||
поведения или контракта; проверяй diff и актуальность утверждений, затем
|
||||
дождись обычного CI.
|
||||
- `MEDIUM`: изменение поведения в одном компоненте; добавь регрессионный тест,
|
||||
выполни целевые проверки и обычный CI.
|
||||
- `HIGH`: доступ/авторизация, данные и ingestion, схемы/хранилища,
|
||||
backup/restore/recovery, production config, секреты, runner/CI, безопасность
|
||||
или deploy; выполни целевые тесты вместе с обычным CI и приложи проверяемое
|
||||
обоснование угроз, совместимости и rollback. Production-действия остаются
|
||||
за владельцем.
|
||||
- Ревью блокирует только конкретный воспроизводимый дефект, нарушение границы
|
||||
доступа/безопасности, несовместимость данных или контракта, пропущенную
|
||||
обязательную проверку либо отсутствие безопасного rollback. Идеи и стилистические
|
||||
замечания перечисляй отдельно как неблокирующие.
|
||||
- Полная процедура, метрики, Gitea-ограничения и проверенные временные бюджеты:
|
||||
[DELIVERY_POLICY.md](docs/development/DELIVERY_POLICY.md).
|
||||
|
||||
## Автономный workflow PR
|
||||
|
||||
- Маршруты ниже указаны из контекста оркестратора/root: оркестратор — thread
|
||||
|
||||
@@ -2,6 +2,85 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Все продуктовые требования Акылдаша собраны в одном документе
|
||||
[«Функциональность платформы»](docs/product/platform-functionality.md),
|
||||
включая границы публичного MVP, критерии выпуска и дальнейшее развитие.
|
||||
Заменённые планы и исходное Word-ТЗ доступны в истории Git.
|
||||
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
|
||||
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
|
||||
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
|
||||
может подготовить release B только при точном совпадении pinned runtime-файлов
|
||||
и release policy. Первый Windows task остаётся preparation-only; отдельные
|
||||
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
|
||||
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
|
||||
регистрация задач, SSH и production не выполнялись и не активировались.
|
||||
- Infrastructure: Windows release installer и external receiver читают владельцев
|
||||
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
|
||||
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
|
||||
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
|
||||
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
|
||||
менялись.
|
||||
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
|
||||
внешняя backup-retention политика и обязательный owner-approved maintenance
|
||||
window для cold backup. Передача корпуса требует атомарной публикации
|
||||
проверенного staging в `DATA_ROOT`; безопасное возобновление либо fail-closed
|
||||
при прерывании остаётся release gate. Механизм атомарной публикации ещё не
|
||||
реализован.
|
||||
- Backend `0.13.4` / Frontend `0.3.4`, алгоритм поиска `search-8`: для явно
|
||||
заданного multi-index поиска включена глобальная статистика DFS; signed review
|
||||
snapshots сохраняют полный index scope, а пустую выдачу можно сохранить с
|
||||
обязательным общим комментарием и точной проверкой рангов. Узкое расширение
|
||||
термина аренды сохранено без глобальных весов. На executable SHA
|
||||
`1c7ddf2` выполнен полный isolated прогон 7 500 запросов: изменились 537
|
||||
порядков относительно исходного baseline, 6 выдач остались пустыми; это
|
||||
наблюдения ранжирования, не экспертная оценка права. Три актуальных акта
|
||||
проверялись только в отдельном локальном supplement; production и исходный
|
||||
индекс не менялись.
|
||||
- Успешный CI на `push` в `main` запускает release workflow: при настроенных
|
||||
registry secrets он собирает образы rootless Kaniko, публикует digest
|
||||
references и сохраняет schema-v4 descriptor с SHA-256 в Gitea artifact;
|
||||
sidecar проверяется после извлечения из корня. Следующий workflow проверяет
|
||||
source-run identity и, при доступном `gitea-api`, метаданные артефакта только
|
||||
в dry-run и явно сообщает `NOT DEPLOYED`: ZIP не скачивается, SSH и host
|
||||
apply не выполняются. Добавлена отдельная root-verified status→fixed-tuple→
|
||||
Windows `--once` привязка через строгий SSH dispatcher; она не использует
|
||||
Gitea/PR runner и не требует Gitea token на Windows. Config и Task Scheduler
|
||||
template выключены, задача не зарегистрирована и production CD не активирован.
|
||||
Зелёный handoff не означает деплой. Подготовлены owner-run cutover и rollback с
|
||||
проверкой backup, image IDs, RU/KY canaries и recovery journal; CI проверяет
|
||||
synthetic artifact и завершается ошибкой при отсутствии файлов. Windows
|
||||
receiver прошёл 17 synthetic client checks, 8 protocol checks и проверку
|
||||
private owner ACL на disposable real-NTFS fixture. Inert Windows argv probe
|
||||
подтвердил передачу пути скрипта с пробелами; Task Scheduler COM проверил
|
||||
XML с `TASK_VALIDATE_ONLY` — задачу не регистрировали, не обновляли и не
|
||||
запускали. Production D: path, SSH, установка на VPS и полный backup/restore
|
||||
не проверены; cutover остаётся заблокирован до отдельного restore-теста и
|
||||
owner bootstrap. Принимаются только завершённые успешные Gitea runs; pending
|
||||
recovery journal записывается до остановки
|
||||
сервисов, повторно применённый release отклоняется до простоя, а проверка
|
||||
`main` использует явно заданный production checkout. Windows preflight
|
||||
проверяет owner ACL и требует свободное место под максимальный допустимый
|
||||
архив.
|
||||
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
|
||||
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
|
||||
кабинета убраны, а знак лаборатории приведён к основному бренду.
|
||||
- Добавлена политика Delivery First по уровням `LOW`/`MEDIUM`/`HIGH`: описаны
|
||||
обязательная глубина проверок, регрессионные тесты и доказательства угроз,
|
||||
совместимости и отката; обычные CI, одобрение и защита `main` остаются
|
||||
обязательными.
|
||||
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
|
||||
мобильной навигации документа; навигационные ссылки получили общие quiet и
|
||||
primary состояния.
|
||||
- Frontend `0.3.1`: уточнена компоновка поиска, лаборатории и кабинета;
|
||||
splitter сохраняет минимальную ширину панелей, а на средних экранах фильтры
|
||||
открываются доступной боковой панелью. Исправлено отображение футера на
|
||||
маршруте пользователей и browser fixture смены пароля.
|
||||
- Frontend `0.3.0` / Backend `0.13.1`: добавлен доступный splitter для
|
||||
изменения ширины результатов и документа. Язык поиска находится в фильтрах;
|
||||
состояния открытия, оглавление правовых заголовков, лаборатория и кабинет
|
||||
приведены к последовательной и компактной компоновке.
|
||||
- Добавлен план развития платформы со сравнением возможностей «Токтом Онлайн»,
|
||||
пробелов Акылдаша и этапов продуктовой проверки.
|
||||
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
|
||||
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
|
||||
промежуточных ширинах окна.
|
||||
|
||||
@@ -66,10 +66,11 @@ spacing:
|
||||
|
||||
## Overview
|
||||
|
||||
**Статус: утверждено пользователем 10 октября 2026 года.** Это целевая
|
||||
**Статус: утверждено пользователем 10 октября 2026 года.** Это нормативная
|
||||
дизайн-система портала и внутренней лаборатории проверки поисковой выдачи.
|
||||
Она ещё не внедрена в работающий интерфейс. Утверждение дизайна не означает
|
||||
готовность публичного сервиса или разрешение на deployment.
|
||||
Часть ресурсов и правил уже присутствует в исходном коде, но полное визуальное
|
||||
соответствие и доступность не подтверждаются одним наличием кода. Утверждение
|
||||
дизайна не означает готовность публичного сервиса или разрешение на deployment.
|
||||
|
||||
**Creative North Star: "Түндүк — спокойное пространство для работы с правом".**
|
||||
|
||||
@@ -80,20 +81,14 @@ spacing:
|
||||
|
||||
Это единственная нормативная спецификация дизайна проекта. Требования
|
||||
исходного brief перенесены сюда; отдельный brief и ранние варианты удалены.
|
||||
Визуальный ориентир — утверждённый пользователем
|
||||
[концептуальный экран](docs/design/tunduk-desktop-concept.png).
|
||||
Точные токены и правила этой спецификации важнее случайных деталей
|
||||
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
|
||||
ранних концепций не используется.
|
||||
Название продукта — **Акылдаш**; «УКУК» из ранних концепций не используется.
|
||||
|
||||
Изображение определяет целевую композицию, пропорции панелей и расположение
|
||||
элементов поискового экрана. Эта спецификация определяет семантические токены,
|
||||
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
|
||||
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
|
||||
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
|
||||
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
|
||||
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
|
||||
План не является второй дизайн-системой и не означает завершение реализации.
|
||||
Целевые редактируемые состояния, композиция, измерения и критерии приёмки
|
||||
собраны в [плане реализации и приёмки](docs/product/implementation-roadmap.md).
|
||||
Макеты задают визуальное направление, но не источник юридических данных и не
|
||||
доказательство работающих функций. Утверждённые токены не заменяются цветами,
|
||||
взятыми пипеткой из растрового изображения. Изменения постоянных правил
|
||||
вносятся сюда явно.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
@@ -141,8 +136,11 @@ spacing:
|
||||
**Заголовки документа и разделов:** Noto Serif, веса 600 и 700.
|
||||
**Интерфейс и основной текст:** Noto Sans, веса 400, 500 и 600.
|
||||
Шрифты подключаются штатным способом, предпочтительно локальными WOFF2
|
||||
с `font-display: swap`. До поставки проверить лицензии и символы
|
||||
`Ө ө Ү ү Ң ң`. Файлы шрифтов пока не добавлены.
|
||||
с `font-display: swap`. В исходном коде на сверенном `main` есть локальные
|
||||
Noto Sans/Serif WOFF2 и OFL-лицензии в `backend/search/static/fonts/`;
|
||||
`review.html` объявляет `font-display: swap`. Это подтверждает наличие ресурсов
|
||||
и CSS, но не их загрузку на production и не полный визуальный аудит символов
|
||||
`Ө ө Ү ү Ң ң`. См. [план реализации и приёмки](docs/product/implementation-roadmap.md).
|
||||
|
||||
| Элемент | Размер | Межстрочный интервал |
|
||||
|---|---|---|
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
# Акылдаш
|
||||
|
||||
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
|
||||
собирать правовые источники на законных основаниях, сохранять их происхождение
|
||||
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
|
||||
через API и пользовательский интерфейс со ссылками на первоисточники.
|
||||
Акылдаш — проект юридической информационно-аналитической платформы
|
||||
Кыргызской Республики. Целевые возможности и требования к выпуску ведутся в
|
||||
[функциональности платформы](docs/product/platform-functionality.md).
|
||||
|
||||
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.13.0` и frontend `0.2.3` с поисковым экраном и внутренней лабораторией.
|
||||
В исходном коде находятся Telegram-бот-секретарь версии `0.2.2`, Search API
|
||||
версии `0.13.4` (алгоритм поиска `search-8`) и frontend `0.3.4` с поисковым
|
||||
экраном, внутренней лабораторией и согласованным отображением доступных
|
||||
действий учетной записи.
|
||||
Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм, применяет узкий
|
||||
юридический вариант для общего словосочетания «договор аренды» и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль. Ограничения
|
||||
текущего индекса и подтверждённые тестовые ошибки описаны в
|
||||
[отчёте о релевантности](docs/product/search-relevance-findings-2026-11-03.md).
|
||||
Версии описывают код, а не установленный production-релиз; актуальное
|
||||
подтверждённое состояние среды см. в
|
||||
[`docs/operations/CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md).
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.13.0` | авторизация, поиск с фильтрами, ограниченный staging и статические ресурсы |
|
||||
| Frontend | `0.2.3` | поиск по правовой базе, просмотр редакций и внутренняя лаборатория |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| Telegram-бот | `0.2.2` | исходный код; последняя заметка о Synology указывает `0.2.1`, актуальность не перепроверена |
|
||||
| Search API | `0.13.4` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
|
||||
| Frontend | `0.3.4` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
|
||||
| Загрузчик архива Минюста (`backend/ingestion/minjust_cbd.py`) | `0.9.3` (`APP_VERSION`) | версия CLI и User-Agent в исходном коде |
|
||||
| Нормализатор корпуса (`backend/normalization/minjust_cbd.py`) | CLI `0.10.0` (`APP_VERSION`); формат/алгоритм `1.1.0` (`NORMALIZER_VERSION`) | версия инструмента и версия нормализатора в manifest — разные значения |
|
||||
| Оценка релевантности | формат набора `relevance-set-v1` | отдельная версия компонента не задана; CLI использует Search API `0.13.4` |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
## Структура репозитория
|
||||
@@ -41,11 +50,7 @@ backend/
|
||||
```
|
||||
|
||||
Внутренний frontend отдаётся существующим Search API без отдельного
|
||||
Node-сервиса. Отдельный публичный frontend остаётся за readiness gate в
|
||||
`docs/product/frontend-design-readiness-plan.md`.
|
||||
|
||||
Начать знакомство с проектом: [документация](docs/README.md) и
|
||||
[обзор продукта](docs/product/project-overview.md).
|
||||
Node-сервиса. Остальные материалы собраны в [оглавлении документации](docs/README.md).
|
||||
|
||||
## Конфиденциальные материалы
|
||||
|
||||
@@ -63,4 +68,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
|
||||
+30
-6
@@ -1,11 +1,16 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.13.0`
|
||||
Версия загрузчика `minjust_cbd.py`: `0.9.3`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
|
||||
из будущего планировщика backend без запуска отдельного процесса.
|
||||
Для передачи корпуса в production обязательна отдельная процедура
|
||||
[CORPUS_HANDOFF.md](../docs/operations/CORPUS_HANDOFF.md); она проверяет
|
||||
source/staging, inventory и checksum, решение по code 6, публикацию и
|
||||
готовность индекса. Этот runbook — release gate; команды передачи здесь не
|
||||
дублируются.
|
||||
|
||||
## Пробная загрузка
|
||||
|
||||
@@ -171,16 +176,31 @@ python3 backend/search/minjust_opensearch.py \
|
||||
После проверки production-индекса следует переключать alias, чтобы удалённые
|
||||
фрагменты не оставались в поиске.
|
||||
|
||||
Чтобы переключить alias атомарно только после успешной полной загрузки,
|
||||
передайте `--alias`:
|
||||
Alias переключается только после refresh, точного сравнения полного fragment
|
||||
count с inventory, обеих языковых canary-проверок и совпадения ожидаемого
|
||||
текущего индекса. Перед cutover обязательно укажите RU/KY запросы и точное имя
|
||||
старого target:
|
||||
|
||||
```bash
|
||||
python3 backend/search/minjust_opensearch.py \
|
||||
--url http://127.0.0.1:9200 \
|
||||
--index akyldash-fragments-v2 \
|
||||
--alias akyldash-fragments-current
|
||||
--alias akyldash-fragments-current \
|
||||
--expected-old-index akyldash-fragments-v1 \
|
||||
--canary-ru 'утверждённый русский запрос' \
|
||||
--canary-ky 'бекитилген кыргызча суроо'
|
||||
```
|
||||
|
||||
Wildcard removal, пустой индекс и alias с несколькими target запрещены. Если
|
||||
старый target успел измениться после проверки, OpenSearch отклонит атомарную
|
||||
операцию удаления точного старого alias. `--resume` повторно проверяет весь
|
||||
ожидаемый inventory, а не только количество документов текущего запуска.
|
||||
Если ответ на alias request потерян после успешного переключения или процесс
|
||||
остановился до записи завершённого checkpoint, повторный запуск проверит
|
||||
фактическую цель alias. Уже опубликованный candidate оставляется на месте, и
|
||||
checkpoint завершается; старый target позволяет безопасно повторить cutover,
|
||||
а любой другой target останавливает операцию.
|
||||
|
||||
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
|
||||
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
|
||||
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
|
||||
@@ -259,6 +279,9 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
Менять веса или анализаторы следует только после фиксации этого baseline и
|
||||
разбора ошибок выдачи.
|
||||
|
||||
Диагностический отчёт с измерениями, подтверждёнными ошибками и ограничениями
|
||||
текущего корпуса: [search relevance findings](../docs/product/search-relevance-findings-2026-11-03.md).
|
||||
|
||||
## Внутренняя лаборатория релевантности
|
||||
|
||||
Создайте первую учетную запись администратора:
|
||||
@@ -277,7 +300,8 @@ PYTHONPATH=backend python3 -m search.api \\
|
||||
|
||||
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
|
||||
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
|
||||
комментариями. Поиск и API доступны только после входа. Пользователь `admin`
|
||||
комментариями. Пустую выдачу можно сохранить как query-level оценку с
|
||||
обязательным общим комментарием. Поиск и API доступны только после входа. Пользователь `admin`
|
||||
управляет учетными записями; новые пользователи должны заменить временный
|
||||
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
|
||||
нужно резервировать отдельно от поискового индекса. Экспорт доступен
|
||||
@@ -296,4 +320,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
|
||||
Акылдаш · Search API v0.13.4 · Frontend v0.3.4 · Telegram-бот v0.2.2
|
||||
+33
-15
@@ -13,7 +13,7 @@ import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.auth import AuthError, AuthStore
|
||||
from search.auth import AuthError, AuthStore, can_manage_users
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.query import build_search_query
|
||||
@@ -21,7 +21,7 @@ from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-6"
|
||||
SEARCH_ALGORITHM_VERSION = "search-8"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
@@ -125,9 +125,12 @@ class Api:
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
|
||||
def query_opensearch(self, body: dict) -> dict:
|
||||
def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
|
||||
try:
|
||||
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
url = self.search_url("_search")
|
||||
if search_type is not None:
|
||||
url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
|
||||
return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
except RuntimeError as error:
|
||||
raise ApiError(502, "search backend is unavailable") from error
|
||||
|
||||
@@ -177,7 +180,8 @@ class Api:
|
||||
if sort == "date":
|
||||
body["sort"].append({"date_adopted": "desc"})
|
||||
body["sort"].append({"_score": "desc"})
|
||||
response = self.query_opensearch(body)
|
||||
search_type = "dfs_query_then_fetch" if "," in self.index else None
|
||||
response = self.query_opensearch(body, search_type=search_type)
|
||||
try:
|
||||
hits = response["hits"]["hits"]
|
||||
except (KeyError, TypeError) as error:
|
||||
@@ -185,7 +189,14 @@ class Api:
|
||||
results = [self.search_hit(hit, language) for hit in hits[:page_size]]
|
||||
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
|
||||
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
|
||||
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
|
||||
# A comma-separated index expression is the searched dataset scope;
|
||||
# hits may come from only one of its members (or there may be no hits).
|
||||
# For a single configured index/alias, keep the concrete-index provenance
|
||||
# when OpenSearch returns exactly one concrete target.
|
||||
if "," in self.index:
|
||||
index_name = self.index
|
||||
else:
|
||||
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
|
||||
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
|
||||
|
||||
@staticmethod
|
||||
@@ -283,9 +294,18 @@ class Api:
|
||||
raise ApiError(400, "overall_comment is too long")
|
||||
if not isinstance(submitted, list):
|
||||
raise ApiError(400, "results must be an array")
|
||||
if len(submitted) > MAX_PAGE_SIZE:
|
||||
raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
|
||||
if any(not isinstance(item, dict) for item in submitted):
|
||||
raise ApiError(400, "review result must be an object")
|
||||
ranks = [item.get("rank") for item in submitted]
|
||||
if any(type(rank) is not int for rank in ranks):
|
||||
raise ApiError(400, "review result rank is invalid")
|
||||
by_rank = {item["rank"]: item for item in snapshot["results"]}
|
||||
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
|
||||
if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
|
||||
raise ApiError(400, "all search results must be reviewed exactly once")
|
||||
if not snapshot["results"] and not overall_comment.strip():
|
||||
raise ApiError(400, "overall_comment is required for an empty search result")
|
||||
results = []
|
||||
for item in submitted:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
|
||||
@@ -300,8 +320,6 @@ class Api:
|
||||
if not isinstance(comment, str) or len(comment) > 4000:
|
||||
raise ApiError(400, "result comment is too long")
|
||||
results.append({**source, "rating": rating, "comment": comment.strip()})
|
||||
if not results:
|
||||
raise ApiError(400, "at least one result must be reviewed")
|
||||
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
|
||||
review_id = self.review_store.save(review)
|
||||
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
|
||||
@@ -345,21 +363,21 @@ class Api:
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
|
||||
return 200, {"users": self.auth_store.list_users()}
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.create_user(body.get("username"), body.get("password"))
|
||||
return 201, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.delete_user(parts[2])
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
try:
|
||||
self.auth_store.reset_password(parts[2], body.get("password"))
|
||||
return 200, {"ok": True}
|
||||
@@ -424,7 +442,7 @@ def handler(api: Api):
|
||||
raise ApiError(401, "authentication required")
|
||||
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
|
||||
raise ApiError(403, "change password before continuing")
|
||||
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
|
||||
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
|
||||
raise ApiError(403, "admin account required")
|
||||
body = None
|
||||
if method in {"POST", "DELETE"}:
|
||||
@@ -503,7 +521,7 @@ def main() -> int:
|
||||
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
|
||||
if arguments.create_admin or arguments.reset_admin_password:
|
||||
users = api.auth_store.list_users()
|
||||
admin_exists = any(user["username"].casefold() == "admin" for user in users)
|
||||
admin_exists = any(can_manage_users(user["username"]) for user in users)
|
||||
if arguments.create_admin and users:
|
||||
parser.error("accounts already exist")
|
||||
if arguments.reset_admin_password and not admin_exists:
|
||||
|
||||
@@ -22,6 +22,10 @@ class AuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def can_manage_users(username: str) -> bool:
|
||||
return isinstance(username, str) and username.casefold() == "admin"
|
||||
|
||||
|
||||
class AuthStore:
|
||||
def __init__(self, path: Path | str):
|
||||
if path != ":memory:":
|
||||
@@ -106,7 +110,8 @@ class AuthStore:
|
||||
|
||||
@staticmethod
|
||||
def _public(user: sqlite3.Row) -> dict:
|
||||
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
can_manage = can_manage_users(user["username"])
|
||||
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
|
||||
def user_for_token(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
@@ -151,7 +156,7 @@ class AuthStore:
|
||||
raise AuthError("login already exists") from error
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
if username.casefold() == "admin":
|
||||
if can_manage_users(username):
|
||||
raise AuthError("admin account cannot be deleted")
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
|
||||
|
||||
@@ -82,7 +82,7 @@ def main() -> int:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2", file=sys.stderr)
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.13.1"
|
||||
APP_VERSION = "0.13.4"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -342,10 +342,17 @@ def load_bulk(
|
||||
resume: bool = False,
|
||||
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
|
||||
alias: str | None = None,
|
||||
expected_old_index: str | None = None,
|
||||
canary_ru: str | None = None,
|
||||
canary_ky: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
base = url.rstrip("/")
|
||||
if alias is not None and (not alias or alias == index):
|
||||
raise ValueError("--alias must differ from --index")
|
||||
if alias is not None and (not expected_old_index or expected_old_index == index):
|
||||
raise ValueError("alias publication requires an exact --expected-old-index")
|
||||
if alias is not None and (not canary_ru or not canary_ky):
|
||||
raise ValueError("alias publication requires both Russian and Kyrgyz canary queries")
|
||||
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
|
||||
if resume:
|
||||
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
|
||||
@@ -364,6 +371,7 @@ def load_bulk(
|
||||
alias,
|
||||
)
|
||||
documents = document_count(input_root, limit, start_at)
|
||||
expected_fragments = sum(1 for _ in bulk_pairs(input_root, index, limit)) if alias else None
|
||||
if not resume:
|
||||
write_json_atomic(checkpoint, state)
|
||||
fragments = 0
|
||||
@@ -398,27 +406,100 @@ def load_bulk(
|
||||
write_json_atomic(checkpoint, state)
|
||||
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
|
||||
if alias:
|
||||
switch_alias(base, index, alias)
|
||||
if not expected_fragments:
|
||||
raise ValueError("refusing to publish an index with an empty expected fragment inventory")
|
||||
validate_candidate_index(base, index, expected_fragments, canary_ru, canary_ky)
|
||||
switch_alias(base, index, alias, expected_old_index)
|
||||
state["complete"] = True
|
||||
write_json_atomic(checkpoint, state)
|
||||
return documents, fragments
|
||||
|
||||
|
||||
def switch_alias(base: str, index: str, alias: str) -> None:
|
||||
def alias_target(base: str, alias: str) -> str:
|
||||
alias_url = f"{base.rstrip('/')}/_alias/{urllib.parse.quote(alias, safe='')}"
|
||||
try:
|
||||
result = request_json(alias_url, "GET", None, "application/json")
|
||||
except RuntimeError as error:
|
||||
if "failed with HTTP 404:" in str(error):
|
||||
raise ValueError(f"alias {alias} has no current target; explicit bootstrap is required") from error
|
||||
raise
|
||||
if not isinstance(result, dict) or len(result) != 1:
|
||||
raise ValueError(f"alias {alias} must point to exactly one index")
|
||||
target, definition = next(iter(result.items()))
|
||||
if not isinstance(definition, dict) or alias not in definition.get("aliases", {}):
|
||||
raise ValueError(f"alias {alias} response is malformed")
|
||||
return target
|
||||
|
||||
|
||||
def validate_candidate_index(
|
||||
base: str, index: str, expected_fragments: int, canary_ru: str, canary_ky: str
|
||||
) -> None:
|
||||
if expected_fragments <= 0:
|
||||
raise ValueError("expected fragment count must be positive")
|
||||
encoded_index = urllib.parse.quote(index, safe='')
|
||||
refreshed = request_json(f"{base.rstrip('/')}/{encoded_index}/_refresh", "POST", None, "application/json")
|
||||
shards = refreshed.get("_shards", {})
|
||||
if not isinstance(shards, dict) or shards.get("failed") != 0:
|
||||
raise RuntimeError(f"refresh failed for candidate index {index}")
|
||||
count_result = request_json(f"{base.rstrip('/')}/{encoded_index}/_count", "GET", None, "application/json")
|
||||
if count_result.get("count") != expected_fragments:
|
||||
raise RuntimeError(
|
||||
f"candidate index {index} has {count_result.get('count')} fragments; expected {expected_fragments}"
|
||||
)
|
||||
for language, query in (("ru", canary_ru), ("ky", canary_ky)):
|
||||
payload = json.dumps({"size": 1, "query": {"match": {f"text_{language}": query}}}).encode()
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/{encoded_index}/_search", "POST", payload, "application/json"
|
||||
)
|
||||
hits = result.get("hits", {}).get("hits", [])
|
||||
if not hits or not isinstance(hits[0].get("_source", {}).get(f"text_{language}"), str):
|
||||
raise RuntimeError(f"candidate index {index} failed the {language} canary query")
|
||||
|
||||
|
||||
def switch_alias(base: str, index: str, alias: str, expected_old_index: str) -> None:
|
||||
if not alias or alias == index:
|
||||
raise ValueError("--alias must differ from --index")
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/_aliases",
|
||||
"POST",
|
||||
json.dumps({
|
||||
"actions": [
|
||||
{"remove": {"index": "*", "alias": alias, "must_exist": False}},
|
||||
{"add": {"index": index, "alias": alias}},
|
||||
]
|
||||
}).encode(),
|
||||
"application/json",
|
||||
)
|
||||
if result.get("acknowledged") is not True:
|
||||
if not expected_old_index or expected_old_index == index:
|
||||
raise ValueError("--expected-old-index must name the exact previous index")
|
||||
actual_old_index = alias_target(base, alias)
|
||||
if actual_old_index == index:
|
||||
return
|
||||
if actual_old_index != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} currently targets {actual_old_index}, expected {expected_old_index}; refusing cutover"
|
||||
)
|
||||
try:
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/_aliases",
|
||||
"POST",
|
||||
json.dumps({
|
||||
"actions": [
|
||||
{"remove": {"index": expected_old_index, "alias": alias, "must_exist": True}},
|
||||
{"add": {"index": index, "alias": alias}},
|
||||
]
|
||||
}).encode(),
|
||||
"application/json",
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
current = alias_target(base, alias)
|
||||
except Exception:
|
||||
raise RuntimeError(f"alias switch outcome is unknown; inspect {alias} before resuming") from error
|
||||
if current == index:
|
||||
return
|
||||
if current != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
|
||||
) from error
|
||||
raise RuntimeError(f"alias switch failed; {alias} still targets {expected_old_index}") from error
|
||||
if not isinstance(result, dict) or result.get("acknowledged") is not True:
|
||||
current = alias_target(base, alias)
|
||||
if current == index:
|
||||
return
|
||||
if current != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
|
||||
)
|
||||
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
|
||||
|
||||
|
||||
@@ -430,6 +511,9 @@ def main() -> int:
|
||||
parser.add_argument("--limit", type=int)
|
||||
parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file")
|
||||
parser.add_argument("--alias", help="atomically point this alias at --index after a successful load")
|
||||
parser.add_argument("--expected-old-index", help="exact current alias target required for guarded cutover")
|
||||
parser.add_argument("--canary-ru", help="approved Russian query that must match the candidate index")
|
||||
parser.add_argument("--canary-ky", help="approved Kyrgyz query that must match the candidate index")
|
||||
parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING)
|
||||
parser.add_argument("--batch-mb", type=int, default=25)
|
||||
parser.add_argument("--resume", action="store_true", help="load into an existing index")
|
||||
@@ -460,12 +544,15 @@ def main() -> int:
|
||||
arguments.resume,
|
||||
checkpoint,
|
||||
arguments.alias,
|
||||
arguments.expected_old_index,
|
||||
arguments.canary_ru,
|
||||
arguments.canary_ky,
|
||||
)
|
||||
destination = arguments.url
|
||||
else:
|
||||
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
|
||||
destination = arguments.output
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.2.6")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+30
-3
@@ -22,6 +22,19 @@ LEGAL_FORM_ALIASES = {
|
||||
"жоопкерчилиги чектелген коом": ("жчк",),
|
||||
},
|
||||
}
|
||||
# The indexed Civil Code uses the legal term "имущественный наем" for a
|
||||
# договор аренды. Keep this exact legal phrase expansion narrow: it must not
|
||||
# rewrite residential hiring, employment, sublease, or leasing queries.
|
||||
LEASE_TERM_ALIAS = re.compile(
|
||||
r"(?<!\w)(договор(?:а|у|ом|е)?)[ \t]+аренд(?:а|ы|е|у|ой|ою)(?!\w)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
|
||||
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
|
||||
r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEASE_TERM_PHRASE_BOOST = 12
|
||||
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
|
||||
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
|
||||
BOILERPLATE_PREFIXES = {
|
||||
@@ -62,12 +75,22 @@ def relaxed_query_text(language: str, query: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def lease_term_variant(query: str) -> str | None:
|
||||
if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
|
||||
return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
|
||||
return None
|
||||
|
||||
|
||||
def query_variants(language: str, query: str) -> list[str]:
|
||||
variants = [query]
|
||||
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
|
||||
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
|
||||
if pattern.search(query):
|
||||
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
|
||||
if language == "ru":
|
||||
alias = lease_term_variant(query)
|
||||
if alias:
|
||||
variants.append(alias)
|
||||
return list(dict.fromkeys(variants))
|
||||
|
||||
|
||||
@@ -169,9 +192,13 @@ def build_search_query(language: str, query: str) -> dict:
|
||||
"boost": 0.1,
|
||||
}
|
||||
})
|
||||
lease_variant = lease_term_variant(query) if language == "ru" else None
|
||||
phrase_matches = [
|
||||
{"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
|
||||
for variant in query_variants(language, query)
|
||||
{"match_phrase": {f"text_{language}": {
|
||||
"query": variant,
|
||||
"boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
|
||||
}}}
|
||||
for variant in variants
|
||||
]
|
||||
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
|
||||
return {"bool": planned}
|
||||
@@ -208,7 +235,7 @@ def main() -> int:
|
||||
if arguments.top_k <= 0:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+345
-83
@@ -14,18 +14,18 @@
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic.woff2") format("woff2"); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-latin.woff2") format("woff2"); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
|
||||
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", serif; }
|
||||
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --sans: "Noto Sans", sans-serif; --serif: "Noto Serif", serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: var(--ink); background: #F7F2E8; }
|
||||
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: var(--surface); }
|
||||
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
|
||||
.review__header, .review__main { max-width: 1320px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
|
||||
.review__header { padding-block: 0 1.5rem; background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.review__header h1 { max-width: 24ch; margin: 1.5rem 0 .4rem; font: 700 clamp(1.75rem, 3vw, 2rem)/1.2 var(--serif); }
|
||||
.review__header h1 { max-width: none; margin: 1.5rem 0 .4rem; font: 700 clamp(1.75rem, 3vw, 2rem)/1.2 var(--serif); }
|
||||
.review__header p { max-width: 65ch; margin: 0; color: var(--muted); }
|
||||
.review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
|
||||
.review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--ink); font: 600 1.1rem/1.25 var(--serif); }
|
||||
.review__eyebrow::before { content: ''; width: 1.5rem; height: 1.5rem; border: 2px solid var(--brand); border-radius: 50%; background: linear-gradient(90deg, transparent 45%, var(--brand) 45% 55%, transparent 55%), linear-gradient(0deg, transparent 45%, var(--brand) 45% 55%, transparent 55%); }
|
||||
.review__eyebrow-mark { display: block; flex: 0 0 1.5rem; width: 1.5rem; height: 1.5rem; }
|
||||
.review__main { padding-block-end: 2rem; }
|
||||
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.review__field { display: grid; gap: .35rem; min-width: 0; }
|
||||
@@ -39,7 +39,8 @@
|
||||
button:active { scale: .96; }
|
||||
button:disabled { cursor: wait; opacity: .55; }
|
||||
.review__button--primary { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.review__status { min-height: 1.7rem; margin-block: 1rem; color: var(--muted); }
|
||||
.review__status { min-height: 0; margin-block: 0; color: var(--muted); }
|
||||
.review__status:not(:empty) { margin-block: .5rem; }
|
||||
.review__status--error { color: #9D2633; }
|
||||
.review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1rem; align-items: start; }
|
||||
.review__results, .review__document { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: clamp(1rem, 2vw, 1.5rem); }
|
||||
@@ -48,13 +49,13 @@
|
||||
.review__panel-hint { color: var(--muted); font-size: .875rem; }
|
||||
.review__results-list { display: grid; gap: .75rem; margin: 0; padding: 0; list-style: none; }
|
||||
.review__result { padding: 1rem 0; border: 0; border-block-end: 1px solid var(--line); border-radius: 0; background: var(--surface); transition-property: background-color, border-color; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
|
||||
.review__result--selected { background: var(--subtle); box-shadow: none; }
|
||||
.review__result--selected { padding: 8px 10px; background: var(--subtle); box-shadow: none; }
|
||||
.review__result:first-child { border-block-start: 1px solid var(--line); }
|
||||
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; min-height: 44px; text-align: start; font-weight: 700; border: 0; padding: 0; background: none; color: var(--brand-dark); }
|
||||
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
|
||||
.review__meta { margin-block: .5rem; color: var(--muted); font-size: .875rem; }
|
||||
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
|
||||
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .875rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
||||
.review__meta { margin-block: 4px; color: var(--muted); font-size: .875rem; }
|
||||
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .35rem; margin-block: 8px 6px; padding: 0; border: 0; }
|
||||
.review__rating legend { width: 100%; margin-block-end: 2px; font-size: .875rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
||||
.review__rating label { display: grid; place-items: center; min-width: 0; min-height: 44px; padding: .45rem .25rem; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); text-align: center; font-size: .875rem; cursor: pointer; }
|
||||
.review__rating label:has(input:checked) { border-color: var(--brand); background: var(--brand); color: #fff; }
|
||||
.review__rating label:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
@@ -110,7 +111,7 @@
|
||||
.search__layout { display: grid; grid-template-columns: 230px minmax(320px, .9fr) minmax(420px, 1.2fr); min-height: 65vh; border-block: 1px solid var(--line); background: var(--surface); }
|
||||
.search__filters, .search__results, .search__document { min-width: 0; padding: 1rem; }
|
||||
.search__filters, .search__results { border-inline-end: 1px solid var(--line); }
|
||||
.search__filters h2, .search__results h2, .search__document h2 { margin: 0; font: 600 1.5rem/1.3 var(--serif); }
|
||||
.search__filters > .review__panel-heading h2, .search__results > .review__panel-heading h2 { margin: 0; font: 600 1.25rem/1.3 var(--sans); }
|
||||
.search__filters .review__panel-heading, .search__results .review__panel-heading { align-items: center; }
|
||||
.search__filter { margin-block: 1.25rem; }
|
||||
.search__filter h3 { margin: 0 0 .4rem; font-size: 1rem; }
|
||||
@@ -161,9 +162,11 @@
|
||||
.review__workspace { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); }
|
||||
.review__document { position: static; max-height: min(68dvh, 860px); overflow: auto; overscroll-behavior: contain; }
|
||||
.review__document-body { max-width: 72ch; margin-inline: auto; }
|
||||
.review__document-body > h2:first-child { font: 600 30px/38px var(--serif); }
|
||||
.review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.review__document-body p:empty { display: none; }
|
||||
.review__document-body a { color: var(--brand-dark); text-underline-offset: .18em; }
|
||||
.review__document-meta a { color: var(--brand-dark); text-underline-offset: .18em; }
|
||||
.search__layout[data-filters-hidden="true"] { grid-template-columns: minmax(320px, .8fr) minmax(0, 1.5fr); }
|
||||
.search__layout[data-filters-hidden="true"] .search__filters { display: none; }
|
||||
.search__layout.is-expanded { position: fixed; z-index: 15; inset: 0; display: block; overflow: auto; background: var(--surface); }
|
||||
@@ -178,14 +181,15 @@
|
||||
.search__layout:not([data-has-document="true"]) .search__toc,
|
||||
.search__layout:not([data-has-document="true"]) #document-source { display: none; }
|
||||
.search__layout:not([data-has-document="true"]) .search__document-body { display: none; }
|
||||
.search__layout:not([data-has-document="true"]) .search__document-head h2 { display: none; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
.search__account button { display: inline-flex; width: auto; min-height: 44px; gap: 8px; align-items: center; padding: 8px 12px; }
|
||||
.search__account-label { display: inline; }
|
||||
.search__document-body table, .review__document-body table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.search__table-scroll { max-width: 100%; margin-block: 1rem; overflow-x: auto; overscroll-behavior-inline: contain; }
|
||||
.search__table-scroll table { width: max-content; min-width: 100%; border-collapse: collapse; }
|
||||
.search__filters .review__panel-heading { align-items: center; flex-wrap: wrap; }
|
||||
.search__filters .review__panel-heading h2 { flex: 1 1 auto; }
|
||||
.search__filters .review__panel-heading { align-items: center; flex-wrap: nowrap; justify-content: space-between; gap: 8px; }
|
||||
.search__filters .review__panel-heading h2 { flex: 0 1 auto; white-space: nowrap; }
|
||||
.search__filters .review__panel-heading button { flex: 0 0 auto; }
|
||||
.search__filter label { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 40px; }
|
||||
.search__filter label span { min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -212,6 +216,8 @@
|
||||
body.is-users-route { min-height: 100dvh; display: flex; flex-direction: column; }
|
||||
body.is-users-route > .review__header { display: block; width: 100%; padding-block-end: 0; }
|
||||
body.is-users-route > .review__header > .review__header-content { display: none; }
|
||||
body.is-users-route > .review__actions { display: none; }
|
||||
body.is-users-route > .search__footer { display: none; }
|
||||
body.is-users-route > footer.review__header { display: block; margin-block-start: auto; }
|
||||
.review__users-page { width: min(1200px, 100%); flex: 1; margin-inline: auto; padding: 28px clamp(16px, 4vw, 48px); }
|
||||
.review__users-page[hidden] { display: none !important; }
|
||||
@@ -233,7 +239,6 @@
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__dates,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > .search__date-label,
|
||||
.search__layout:not([data-filters-hidden="true"]) .search__filters > #filter-fields { grid-column: 1 / -1; }
|
||||
.search__filters .search__ornament { display: none; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body:has(.search:not([hidden])) { height: auto; min-height: 100dvh; overflow: visible; }
|
||||
@@ -281,12 +286,11 @@
|
||||
body:has(.search:not([hidden])) > .search__chrome { display: block; }
|
||||
.search__chrome { display: none; background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.search[hidden] { display: none !important; }
|
||||
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: minmax(390px, 430px) minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 26px; border-block-end: 1px solid var(--line); }
|
||||
.search__brand { display: flex; min-width: 0; align-items: center; gap: 12px; color: var(--ink); text-decoration: none; white-space: nowrap; }
|
||||
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: 166px minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 26px; border-block-end: 1px solid var(--line); }
|
||||
.search__brand { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; white-space: nowrap; }
|
||||
.search__brand-mark { width: 44px; height: 44px; flex: none; color: var(--brand); }
|
||||
.search__brand-name { font: 700 26px/1.1 var(--serif); }
|
||||
.search__brand-divider { width: 1px; height: 32px; background: var(--line); }
|
||||
.search__brand-description { color: var(--muted); font-size: 14px; }
|
||||
.search__brand-name { font: 700 22px/1.1 var(--serif); }
|
||||
.search__brand-divider, .search__brand-description { display: none; }
|
||||
.search__query { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 12px; align-items: end; min-width: 0; padding: 0; border: 0; background: transparent; }
|
||||
.search__query label { display: grid; gap: 4px; color: var(--muted); font-size: 14px; font-weight: 500; }
|
||||
.search__query label > span { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@@ -300,9 +304,10 @@
|
||||
.search__ui-language button[aria-pressed="true"] { color: var(--ink); border-block-end: 2px solid var(--brand); }
|
||||
.search__account { display: flex; align-items: center; gap: 6px; margin-inline-start: auto; }
|
||||
.search__account button { width: 40px; min-height: 40px; padding: 8px; }
|
||||
.search__account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 16px; }
|
||||
.search__account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 24px 0; }
|
||||
.search__subnav { min-height: var(--search-nav-height); display: flex; align-items: center; gap: 12px; padding: 6px 26px; overflow-x: auto; white-space: nowrap; }
|
||||
.search__subnav a, .search__subnav button { min-height: 36px; padding: 5px 14px; text-decoration: none; }
|
||||
.search__subnav a, .search__subnav button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 5px 14px; text-align: center; text-decoration: none; }
|
||||
.search__subnav a:not(.is-active) { color: var(--ink); }
|
||||
.search__mobile-filters, .search__mobile-back, .search__filter-close, .search__filter-overlay { display: none; }
|
||||
.search__subnav a.is-active { min-width: 97px; background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.search__subnav .search__nav-text { margin-inline: 11px 28px; padding-inline: 0; border: 0; color: var(--ink); font-size: 13px; }
|
||||
@@ -311,8 +316,7 @@
|
||||
.search__chips .search__chip { padding-inline: 4px; }
|
||||
.search__chips .search__chip button { width: 16px; }
|
||||
.search__language-chip { padding-inline: 4px; }
|
||||
.search__language-chip select { width: 78px; min-width: 0; min-height: 24px; border: 0; padding: 0 12px 0 0; background: transparent; font-size: 14px; }
|
||||
html[lang="ky"] .search__language-chip select { width: 100px; }
|
||||
.search__language-chip select { width: auto; min-width: 126px; min-height: 36px; border: 0; padding: 0 12px 0 0; background: transparent; font-size: 14px; }
|
||||
.search__chip button { min-height: 24px; width: 24px; padding: 0; border: 0; font-size: 18px; line-height: 1; }
|
||||
.search { width: 100%; max-width: none; min-height: 0; flex: 1; display: flex; flex-direction: column; margin: 0; }
|
||||
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@@ -320,7 +324,9 @@
|
||||
.search__layout { flex: 1; min-height: 0; grid-template-columns: minmax(210px, 226px) minmax(350px, 422px) minmax(400px, 1fr); border-block: 1px solid var(--line); }
|
||||
.search__filters, .search__results, .search__document { min-height: 0; padding: 14px 16px 20px; }
|
||||
.search__filters { position: relative; display: flex; flex-direction: column; overflow: auto; background: var(--surface-muted); padding-inline: 30px 16px; }
|
||||
.search__filters .review__panel-heading { margin-block-end: 18px; }
|
||||
.search__filters .review__panel-heading { margin-block-end: 8px; }
|
||||
#search-filters > .review__panel-heading { margin-block-end: 0; }
|
||||
#search-filters > .search__filter-language { margin-block-start: 0; }
|
||||
.search__filters .review__panel-heading button { min-height: 36px; padding: 2px; border: 0; color: var(--brand); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.search__filter { margin-block: 0 16px; }
|
||||
.search__filter--document-type { margin-block-end: 24px; }
|
||||
@@ -340,7 +346,6 @@
|
||||
.search__dates label:not(.has-value) input { color: transparent; }
|
||||
.search__date-placeholder { position: absolute; inset-inline-start: 9px; inset-block-start: 50%; color: var(--muted); font-size: 14px; pointer-events: none; transform: translateY(-50%); }
|
||||
.search__dates label.has-value .search__date-placeholder { display: none; }
|
||||
.search__ornament { width: calc(100% - 12px); height: 56px; flex: none; margin: auto 6px 0; color: var(--accent); pointer-events: none; }
|
||||
.search__results { overflow: auto; background: var(--surface-muted); }
|
||||
.search__results .review__panel-heading { align-items: start; margin-block-end: 0; }
|
||||
.search__results .review__panel-heading select { min-height: 40px; padding: 6px 10px; font-size: 14px; }
|
||||
@@ -363,7 +368,7 @@
|
||||
.search__document-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
|
||||
.search__breadcrumbs { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 14px; }
|
||||
.search__breadcrumbs span:first-child { color: var(--brand); }
|
||||
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 4px; color: var(--ink); font: 700 36px/1 var(--serif); }
|
||||
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 4px; color: var(--ink); font: 600 30px/38px var(--serif); }
|
||||
.search__document-head .search__expand { min-height: 32px; border: 0; padding: 0 4px; }
|
||||
.search__document-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; color: var(--muted); }
|
||||
.search__document-meta a { color: var(--brand); }
|
||||
@@ -374,7 +379,7 @@
|
||||
.search__document-toolbar .search__edition-label { display: block; }
|
||||
.search__document-toolbar .search__edition-label select { width: 160px; max-width: 160px; }
|
||||
.search__source-link { max-width: 150px; margin-inline-start: auto; color: var(--brand); font-size: 14px; white-space: nowrap; }
|
||||
.search__document-language { display: inline-flex !important; align-items: center; gap: 6px !important; margin-inline-start: auto; font-size: 12px !important; }
|
||||
.search__document-language { display: inline-flex; align-items: center; gap: 6px; margin-inline-start: auto; font-size: 12px; }
|
||||
.search__document-language select { min-height: 28px; max-width: 130px; padding: 4px 8px; font-size: 14px; }
|
||||
.search__toc { margin-block-end: 0; }
|
||||
.search__toc summary { width: fit-content; min-height: 42px; padding: 9px 8px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
@@ -428,8 +433,11 @@
|
||||
.search__layout[data-mobile-view="results"] .search__document { display: none; }
|
||||
.search__layout[data-mobile-view="results"] .search__results { display: block; }
|
||||
.search__layout[data-mobile-view="document"] .search__filters { display: none; }
|
||||
.search__ornament { display: none; }
|
||||
.search__document { min-height: 30rem; }
|
||||
.search__document-head { grid-template-columns: minmax(0, 1fr); }
|
||||
.search__document-head > div:nth-child(2) { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.search__breadcrumbs { min-width: 0; flex-wrap: wrap; }
|
||||
.search__breadcrumbs #document-breadcrumb { min-width: 0; overflow-wrap: anywhere; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.search__topbar { grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 10px 16px; }
|
||||
@@ -466,6 +474,7 @@
|
||||
body.is-users-route { width: 100%; height: auto; min-height: 100dvh; overflow: visible; display: flex; flex-direction: column; }
|
||||
body.is-users-route > header.review__header { display: block; width: 100%; padding-block-end: 0; }
|
||||
body.is-users-route > header.review__header > .review__header-content { display: none; }
|
||||
body.is-users-route > .review__actions { display: none; }
|
||||
body.is-users-route > footer.review__header { display: block; width: 100%; margin-block-start: auto; }
|
||||
.search__layout { grid-template-columns: clamp(256px, 19.45vw, 280px) clamp(360px, 30vw, 408px) minmax(560px, 1fr); }
|
||||
.search__account button { width: auto; min-width: 44px; min-height: 44px; padding: 8px 12px; }
|
||||
@@ -486,14 +495,134 @@
|
||||
.review__nav-links { order: 3; width: 100%; flex-wrap: wrap; }
|
||||
.review__nav-account { margin-inline-start: auto; }
|
||||
}
|
||||
/* Shared follow-up corrections: consistent navigation, compact public search,
|
||||
and a continuous workspace that keeps controls aligned to the same shell. */
|
||||
.review__nav { width: min(100%, 1440px); max-width: 1440px; }
|
||||
.review__nav-links a, .review__nav-links button, #search-review-nav {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-height: 44px;
|
||||
padding: 8px 14px; border: 1px solid var(--line); border-radius: 6px;
|
||||
color: var(--brand); background: var(--surface); text-decoration: none; font: inherit;
|
||||
}
|
||||
.review__nav-links a:hover, .review__nav-links button:hover, #search-review-nav:hover { border-color: var(--line); background: var(--surface-muted); color: var(--brand); }
|
||||
.review__nav-links .is-active, #search-review-nav.is-active { border-color: var(--brand); background: var(--brand); color: #fff; }
|
||||
.review__nav-links .is-active:hover, #search-review-nav.is-active:hover { border-color: var(--brand-dark); background: var(--brand-dark); color: #fff; }
|
||||
.review__nav-links a:focus-visible, .search__subnav a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.review__nav-links [hidden], .search__account-actions [hidden] { display: none !important; }
|
||||
.review__nav-account > span { color: var(--muted); }
|
||||
.review__header-content { width: 100%; max-width: none; padding-inline: 24px; }
|
||||
body:has(#review:not([hidden])) { min-height: 100dvh; height: 100dvh; overflow: hidden; }
|
||||
body:has(#review:not([hidden])) > .review__header { padding: 0; }
|
||||
body:has(#review:not([hidden])) > #review { width: 100%; max-width: none; min-height: 0; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
body:has(#review:not([hidden])) > .review__actions { width: 100%; min-height: 96px; flex: 0 0 96px; box-sizing: border-box; margin: 0; padding: 0 24px; }
|
||||
body:has(#review:not([hidden])) > footer.review__header { width: 100%; margin: 0; }
|
||||
.review__header-content, .review__actions-inner { width: 100%; max-width: none; }
|
||||
body:has(#review:not([hidden])) .review__nav { height: 64px; min-height: 64px; padding-block: 0; }
|
||||
body:has(#review:not([hidden])) .review__header-content { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; height: 64px; }
|
||||
body:has(#review:not([hidden])) .review__heading-copy { min-width: 0; display: grid; grid-template-rows: 32px 20px; align-content: center; }
|
||||
body:has(#review:not([hidden])) .review__header-top { grid-column: 2; grid-row: 1; align-self: center; padding: 0; }
|
||||
body:has(#review:not([hidden])) .review__header-content h1 { max-width: none; margin: 0; font: 600 26px/32px var(--sans); }
|
||||
body:has(#review:not([hidden])) .review__header-content p { max-width: none; margin: 0; font: 400 14px/20px var(--sans); white-space: nowrap; }
|
||||
.review__search { gap: 12px; padding: 12px 24px; border: 0; border-block-end: 1px solid var(--line); border-radius: 0; background: transparent; }
|
||||
#status.review__status { grid-column: 1 / -1; margin: 0; }
|
||||
#status.review__status:empty { display: none; }
|
||||
#status.review__status--success { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.review__workspace { min-height: 0; flex: 1 1 0; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 0; align-items: stretch; overflow: hidden; border-block: 1px solid var(--line); background: var(--surface); }
|
||||
.review__results, .review__document { min-height: 0; max-height: none; padding: 14px 24px; border: 0; border-radius: 0; background: var(--surface); }
|
||||
.review__results { overflow: auto; border-inline-end: 1px solid var(--line); }
|
||||
.review__document { position: static; overflow: auto; overscroll-behavior: contain; }
|
||||
.review__document-meta { overflow-wrap: anywhere; }
|
||||
.review__actions-inner { padding: 5px 24px; border: 0; border-block-start: 1px solid var(--line); border-radius: 0; background: transparent; }
|
||||
body:has(#review:not([hidden])) > footer.review__header { height: 44px; min-height: 44px; }
|
||||
body:has(#review:not([hidden])) > footer.review__header .review__header-content { display: flex; align-items: center; height: 44px; padding-block: 0; }
|
||||
.search__subnav { flex-wrap: wrap; overflow: visible; white-space: normal; }
|
||||
.search__subnav a.is-active { min-width: 0; text-align: center; }
|
||||
.search__nav-text { flex: 0 0 auto; }
|
||||
.search__chips { flex: 1 1 8rem; }
|
||||
.search__filter-language { display: grid; gap: 5px; margin-block: 0 16px; color: var(--muted); font-size: .875rem; font-weight: 600; }
|
||||
.search__filter-language select { min-height: 42px; width: 100%; padding: 7px 10px; color: var(--ink); background: var(--surface); border: 1px solid var(--line); }
|
||||
.search__layout { grid-template-columns: minmax(264px, clamp(264px, calc(20vw + 8px), 288px)) minmax(360px, 38.1fr) 8px minmax(560px, 61.9fr); }
|
||||
.search__layout[data-filters-hidden="true"] { grid-template-columns: minmax(360px, 38.1fr) 8px minmax(560px, 61.9fr); }
|
||||
.search__filters { margin-inline-end: 8px; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
.search__layout[data-filters-hidden="true"] .search__filters { display: none; }
|
||||
.search__splitter { position: relative; z-index: 1; align-self: stretch; justify-self: center; width: 12px; min-width: 12px; min-height: 100%; margin-inline: -2px; padding: 0; border: 0; border-radius: 0; background: linear-gradient(90deg, transparent 2px, var(--surface-muted) 2px 10px, transparent 10px); cursor: col-resize; touch-action: none; }
|
||||
.search__splitter::before { position: absolute; inset-block: 0; inset-inline-start: 50%; width: 1px; background: var(--line); content: ''; transform: translateX(-50%); }
|
||||
.search__splitter::after { position: absolute; inset-block-start: 50%; inset-inline-start: 50%; width: 3px; height: 40px; border-radius: 2px; background: var(--muted); content: ''; transform: translate(-50%, -50%); }
|
||||
.search__splitter:hover::after, .search__splitter:focus-visible::after { background: var(--brand); }
|
||||
body:has(#review:not([hidden])) > .review__header, body:has(#review:not([hidden])) > footer.review__header { width: 100%; max-width: none; margin: 0; padding-inline: 0; }
|
||||
body:has(#review:not([hidden])) .review__nav, body:has(#review:not([hidden])) .review__header-content { width: 100%; max-width: none; padding-inline: 24px; }
|
||||
body:has(#review:not([hidden])) .review__header-content h1 { font: 600 26px/32px var(--sans); }
|
||||
body:has(#review:not([hidden])) .review__panel-heading h2 { font: 600 20px/26px var(--sans); }
|
||||
body.is-users-route > .search__footer { display: none !important; }
|
||||
|
||||
.search__pagination[hidden] { display: none; }
|
||||
.search__document-language[hidden] { display: none; }
|
||||
.search__account-actions { display: grid; grid-template-columns: max-content max-content; justify-content: start; gap: 8px; }
|
||||
.search__account-actions a { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--brand-dark); font-weight: 600; text-decoration: none; }
|
||||
.search__account-actions a:hover { border-color: var(--brand); background: var(--subtle); }
|
||||
#search-logout { grid-column: 1 / -1; justify-self: start; min-height: 36px; margin-block-start: 2px; padding-inline: 0; border: 0; color: var(--muted); background: transparent; }
|
||||
#account-dialog dl { display: grid; grid-template-columns: minmax(7.5rem, max-content) minmax(0, 1fr); gap: 8px 16px; margin: 16px 0 24px; }
|
||||
#account-dialog { width: min(560px, calc(100vw - 32px)); max-width: none; }
|
||||
#account-dialog > .review__page { padding: 16px; }
|
||||
#account-dialog dt { color: var(--muted); }
|
||||
#account-dialog dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
#account-dialog .review__dialog-heading { inset-block-start: -16px; align-items: center; min-height: 44px; height: 44px; margin: -16px -16px 12px; padding: 0 16px; }
|
||||
.review__password-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
||||
.review__password-field input { min-width: 0; }
|
||||
.review__password-toggle { min-height: 44px; padding-inline: 10px; white-space: nowrap; }
|
||||
#account-dialog .review__dialog-heading h2 { max-width: none; padding: 0; font: 600 20px/28px var(--sans); }
|
||||
#account-dialog .review__dialog-heading button { display: grid; place-items: center; flex: 0 0 44px; width: 44px; min-height: 44px; padding: 0; }
|
||||
#public-document-body img, #document-body img { max-width: 100%; height: auto; object-fit: contain; }
|
||||
@media (min-width: 901px) and (max-width: 1191px) {
|
||||
#search-layout { grid-template-columns: minmax(360px, 38.1fr) minmax(560px, 61.9fr); }
|
||||
#search-layout .search__filters { display: none; }
|
||||
#search-layout .search__filters.is-open { position: fixed; z-index: 21; inset: 16px auto 16px 16px; display: block; width: min(360px, calc(100vw - 32px)); overflow: auto; padding: 20px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); box-shadow: 0 12px 40px rgb(34 28 28 / .2); }
|
||||
.search__filter-close { display: inline-flex; }
|
||||
.search__filter-overlay.is-open { position: fixed; z-index: 20; inset: 0; display: block; background: rgb(56 38 42 / .42); }
|
||||
.search__splitter { display: none; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body:has(#review:not([hidden])) { min-height: 100svh; height: auto; overflow: visible; }
|
||||
body:has(#review:not([hidden])) > #review { flex: none; padding-inline: 16px; }
|
||||
body:has(#review:not([hidden])) .review__nav, body:has(#review:not([hidden])) .review__header-content { padding-inline: 16px; }
|
||||
body:has(#review:not([hidden])) .review__header-content { display: flex; flex-direction: column; align-items: stretch; gap: 8px; height: auto; padding-block: 12px; }
|
||||
body:has(#review:not([hidden])) .review__heading-copy { display: block; }
|
||||
body:has(#review:not([hidden])) .review__header-top { align-self: flex-start; }
|
||||
.review__search { padding-inline: 16px; }
|
||||
body:has(#review:not([hidden])) .review__header-top { padding-block: 0 8px; }
|
||||
body:has(#review:not([hidden])) .review__header-content h1, body:has(#review:not([hidden])) .review__header-content p { display: block; }
|
||||
body:has(#review:not([hidden])) .review__header-content p { white-space: normal; }
|
||||
body:has(#review:not([hidden])) > footer.review__header { height: auto; min-height: 44px; }
|
||||
.review__workspace { min-height: 0; max-height: none; flex: none; display: grid; grid-template-columns: minmax(0, 1fr); overflow: visible; }
|
||||
.review__results, .review__document { max-height: none; overflow: visible; border-inline: 0; }
|
||||
.review__results { border-block-end: 1px solid var(--line); }
|
||||
.review__document { display: block; }
|
||||
.review__results, .review__document { padding-inline: 16px; }
|
||||
body:has(#review:not([hidden])) > .review__actions { flex: 0 0 auto; padding-inline: 0; }
|
||||
.review__actions-inner { padding-inline: 16px; }
|
||||
body:has(#review:not([hidden])) > footer.review__header { height: auto; min-height: 44px; }
|
||||
.search__layout, .search__layout[data-filters-hidden="true"] { display: block; }
|
||||
.search__splitter { display: none; }
|
||||
.search__filter-language { margin-block-end: 16px; }
|
||||
.search__filters { display: none; }
|
||||
.search__filters.is-open { display: block; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.search__subnav { gap: 4px; padding-inline: 12px; }
|
||||
.search__subnav a, .search__subnav button { padding-inline: 10px; }
|
||||
.search__chips { flex: 1 1 100%; order: 4; }
|
||||
.search__account { margin-inline-start: auto; }
|
||||
#account-dialog dl { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
#account-dialog dd { margin-block-end: 10px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||||
<header class="review__header">
|
||||
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
|
||||
<div class="review__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
|
||||
<h1 id="route-heading">Проверка качества поиска</h1><p id="route-description">Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
|
||||
<div class="review__nav"><div class="review__eyebrow"><svg class="review__eyebrow-mark" viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="3"/><path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37M17 7c2 8 12 8 14 0M17 41c2-8 12-8 14 0M7 17c8 2 8 12 0 14M41 17c-8 2-8 12 0 14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
|
||||
<div class="review__header-content"><div class="review__heading-copy"><h1 id="route-heading">Проверка качества поиска</h1><p id="route-description">Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div></div>
|
||||
</header>
|
||||
<header class="search__chrome" aria-label="Акылдаш">
|
||||
<div class="search__topbar">
|
||||
@@ -504,7 +633,7 @@
|
||||
<form class="search__query" id="public-search-form"><div><label for="public-query">Поиск по правовой базе</label><div class="search__query-input"><svg class="search__query-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="7" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m16 16 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="search__query-clear" id="clear-query" type="button" aria-label="Очистить запрос"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button></div></div><button class="review__button--primary" type="submit">Найти</button></form>
|
||||
<div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="ui-ky" type="button" aria-pressed="false">KY</button></div>
|
||||
</div>
|
||||
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><label class="search__chip search__language-chip"><span class="search__sr-only">Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span class="search__account-label">Кабинет</span></button></div></nav>
|
||||
<nav class="search__subnav" aria-label="Основная навигация"><a id="search-nav" class="is-active" href="/">Поиск</a><button id="sources-open" class="search__nav-text" type="button">Об источниках</button><a id="search-review-nav" href="/review">Лаборатория</a><button class="search__mobile-filters" id="mobile-filters-open" type="button" aria-controls="search-filters" aria-expanded="false">Фильтры</button><div class="search__chips" id="active-filters" aria-label="Активные фильтры"></div><div class="search__account"><span id="signed-in-user" class="search__sr-only"></span><button id="search-account-open" type="button" aria-label="Личный кабинет" title="Личный кабинет"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M5 20c.5-3.5 3-5.5 7-5.5s6.5 2 7 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg><span class="search__account-label">Кабинет</span></button></div></nav>
|
||||
</header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
@@ -512,8 +641,8 @@
|
||||
<label class="review__field review__field--small">Язык запроса<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
|
||||
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
|
||||
<button class="review__button--primary" type="submit">Показать результаты</button>
|
||||
<div class="review__status" id="status" role="status" aria-live="polite"></div>
|
||||
</form>
|
||||
<div class="review__status" id="status" role="status" aria-live="polite"></div>
|
||||
<div class="review__workspace">
|
||||
<section class="review__results" aria-labelledby="results-heading"><div class="review__panel-heading"><h2 id="results-heading">Результаты поиска</h2><span class="review__panel-hint">Оцените каждый документ</span></div><ol class="review__results-list" id="results"><li class="review__empty"><strong>Начните с запроса</strong>Введите юридический вопрос выше, чтобы увидеть результаты.</li></ol></section>
|
||||
<section class="review__document" id="document-panel" aria-labelledby="document-heading"><div class="review__panel-heading"><h2 id="document-heading">Просмотр документа</h2><span class="review__panel-hint">Откройте результат</span></div><div class="review__document-meta" id="document-meta">Выберите название в списке, чтобы прочитать документ.</div><article class="review__document-body" id="document-body"></article></section>
|
||||
@@ -522,16 +651,17 @@
|
||||
<main class="search" id="search-page" hidden>
|
||||
<h1 class="search__sr-only">Поиск по правовой базе</h1>
|
||||
<p class="search__sr-only" id="public-status" role="status" aria-live="polite"></p>
|
||||
<div class="search__layout" id="search-layout"><aside class="search__filters" id="search-filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button><button class="search__filter-close" id="mobile-filters-close" type="button">Закрыть фильтры</button></div><div id="filter-fields"></div><span class="search__date-label">Дата принятия</span><div class="search__dates"><label><span class="search__sr-only">Дата принятия от</span><input id="date-from" type="date" aria-label="Дата принятия от"><span class="search__date-placeholder" aria-hidden="true">От</span></label><label><span class="search__sr-only">Дата принятия до</span><input id="date-to" type="date" aria-label="Дата принятия до"><span class="search__date-placeholder" aria-hidden="true">До</span></label></div><svg class="search__ornament" viewBox="0 0 220 56" aria-hidden="true"><path d="M2 34h60c15 0 17 17 31 17s16-17 30-17h95M2 41h55c13 0 17 13 31 13 15 0 17-13 31-13h99M98 42c-15-16 7-17 13-29 6 12 28 13 13 29-8 8-18 8-26 0Zm13-29v34M106 39l15-15m-15 15L96 27" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></aside><button class="search__filter-overlay" id="filter-overlay" type="button" aria-label="Закрыть фильтры"></button>
|
||||
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label class="search__sort-label">Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs"><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите результат, чтобы прочитать доступную редакцию.</span><label class="search__document-language">Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
<div class="search__layout" id="search-layout"><aside class="search__filters" id="search-filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button><button class="search__filter-close" id="mobile-filters-close" type="button">Закрыть фильтры</button></div><label class="search__filter-language search__language-chip"><span>Язык поиска</span><select id="public-language" aria-label="Язык поиска"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label><div id="filter-fields"></div><span class="search__date-label">Дата принятия</span><div class="search__dates"><label><span class="search__sr-only">Дата принятия от</span><input id="date-from" type="date" aria-label="Дата принятия от"><span class="search__date-placeholder" aria-hidden="true">От</span></label><label><span class="search__sr-only">Дата принятия до</span><input id="date-to" type="date" aria-label="Дата принятия до"><span class="search__date-placeholder" aria-hidden="true">До</span></label></div></aside><button class="search__filter-overlay" id="filter-overlay" type="button" aria-label="Закрыть фильтры"></button>
|
||||
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count"></span></div><label class="search__sort-label">Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list" data-state="initial"><li class="review__empty">Введите запрос, чтобы начать поиск.</li></ol><div class="search__pagination" hidden><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
|
||||
<button class="search__splitter" id="search-splitter" type="button" role="separator" aria-orientation="vertical" aria-controls="public-results public-document-panel" aria-label="Изменить ширину списка результатов и документа" aria-valuemin="25" aria-valuemax="55" aria-valuenow="36" tabindex="0"></button>
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.2.6 <span aria-hidden="true">·</span> Backend v0.13.1</span></footer>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.4 <span aria-hidden="true">·</span> Backend v0.13.4</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><div class="review__account-tabs" role="tablist" aria-label="Разделы личного кабинета"><button id="profile-tab" type="button" role="tab" aria-controls="profile-panel" aria-selected="true">Профиль</button><button id="security-tab" type="button" role="tab" aria-controls="security-panel" aria-selected="false" tabindex="-1">Безопасность</button></div><section id="profile-panel" role="tabpanel" aria-labelledby="profile-tab"><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><button id="search-users-open" type="button" hidden>Пользователи</button><button id="search-logout" type="button">Выйти</button></nav><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl></section><section id="security-panel" role="tabpanel" aria-labelledby="security-tab" hidden><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="profile-confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button><button type="button" data-close="account-dialog">Отмена</button></div></form></section></section></dialog>
|
||||
<dialog id="account-dialog" aria-labelledby="account-heading"><section class="review__page"><h2 id="account-heading">Личный кабинет</h2><div class="review__account-tabs" role="tablist" aria-label="Разделы личного кабинета"><button id="profile-tab" type="button" role="tab" aria-controls="profile-panel" aria-selected="true">Профиль</button><button id="security-tab" type="button" role="tab" aria-controls="security-panel" aria-selected="false" tabindex="-1">Безопасность</button></div><section id="profile-panel" role="tabpanel" aria-labelledby="profile-tab"><dl><dt>Логин</dt><dd id="profile-username"></dd><dt>Роль</dt><dd id="profile-role">Администратор</dd><dt>Дата создания</dt><dd id="profile-created"></dd></dl><nav class="search__account-actions" aria-label="Действия учетной записи"><a id="review-nav" href="/review">Лаборатория</a><a id="search-users-open" href="/users" hidden>Управление пользователями</a><button id="search-logout" type="button">Выйти</button></nav></section><section id="security-panel" role="tabpanel" aria-labelledby="security-tab" hidden><form id="profile-password-form"><h3>Изменить пароль</h3><p>Новый пароль должен содержать не менее 12 символов.</p><label>Текущий пароль<input id="profile-current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="profile-new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="profile-confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="profile-error" role="alert" class="review__status review__status--error"></p><div class="review__page-actions"><button class="review__button--primary" type="submit">Изменить пароль</button></div></form></section></section></dialog>
|
||||
<main class="review__users-page" id="users-page" hidden>
|
||||
<div class="review__users-heading"><div><h2 id="users-heading">Пользователи</h2><p id="users-description">Права на действия определяются сервером.</p></div><div class="review__users-actions"><div class="search__ui-language" role="group" aria-label="Язык интерфейса"><button id="users-ui-ru" type="button" aria-pressed="true">RU</button><span aria-hidden="true">/</span><button id="users-ui-ky" type="button" aria-pressed="false">KY</button></div><button id="create-user-open" class="review__button--primary" type="button">Создать пользователя</button></div></div>
|
||||
<p id="users-list-error" role="alert" class="review__status review__status--error"></p>
|
||||
@@ -545,7 +675,7 @@
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
|
||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.6 · Backend v0.13.1 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.4 · Backend v0.13.4 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -567,10 +697,11 @@
|
||||
};
|
||||
const copy = (key) => searchCopy[uiLanguage][key];
|
||||
const modalCopy = {
|
||||
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
|
||||
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
|
||||
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', user: 'Пользователь', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
|
||||
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', user: 'Колдонуучу', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', showPassword: 'Сырсөздү көрсөтүү', hidePassword: 'Сырсөздү жашыруу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
|
||||
};
|
||||
const modalMessage = (key) => modalCopy[uiLanguage][key];
|
||||
const roleLabel = (user, language = uiLanguage) => user?.can_manage_users === true ? modalCopy[language].admin : modalCopy[language].user;
|
||||
const reviewRoute = location.pathname === '/review';
|
||||
const usersRoute = location.pathname === '/users';
|
||||
const searchRoute = !reviewRoute && !usersRoute;
|
||||
@@ -578,11 +709,11 @@
|
||||
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
|
||||
document.body.classList.toggle('is-review-route', reviewRoute);
|
||||
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.6 · Backend v0.13.1'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.2.6 · Backend v0.13.1'; }
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('review-signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.role === 'admin'; $('search-users-open').hidden = $('review-users-open').hidden = !canManageUsers; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = roleLabel(user); $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
|
||||
let filterCatalogs = {};
|
||||
let filterCatalogsLoaded = false;
|
||||
@@ -591,6 +722,7 @@
|
||||
let searchRequest = 0;
|
||||
let editionRequest = 0;
|
||||
let currentDocumentHtml = '';
|
||||
let applyResultsSplitter = null;
|
||||
let documentMatches = [];
|
||||
let activeDocumentMatch = -1;
|
||||
function sanitizeDocumentHtml(html) {
|
||||
@@ -725,10 +857,10 @@
|
||||
list.replaceChildren();
|
||||
if (results === null || !results.length) {
|
||||
const empty = document.createElement('li'); empty.className = 'review__empty';
|
||||
const failed = results === null;
|
||||
const heading = document.createElement('strong'); heading.textContent = failed ? 'Поиск временно недоступен' : copy('noResults');
|
||||
empty.append(heading, document.createTextNode(failed ? 'Проверьте подключение и повторите запрос.' : copy('noResultsHint'))); list.append(empty); return;
|
||||
list.dataset.state = results === null ? 'error' : 'empty';
|
||||
empty.textContent = results === null ? copy('searchError') : copy('noResults'); list.append(empty); return;
|
||||
}
|
||||
list.dataset.state = 'results';
|
||||
results.forEach((result) => {
|
||||
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || copy('noTitle');
|
||||
@@ -738,11 +870,28 @@
|
||||
row.append(title, meta, snippet); list.append(row);
|
||||
});
|
||||
}
|
||||
function clearOpenedMarkers() { document.querySelectorAll('.search__opened').forEach((marker) => marker.remove()); }
|
||||
function clearSelectedPublicDocument() {
|
||||
++editionRequest; selectedPublicDocument = null; currentDocumentHtml = '';
|
||||
$('search-layout').removeAttribute('data-has-document'); $('search-layout').removeAttribute('data-has-editions');
|
||||
$('document-breadcrumb').parentElement.hidden = true; $('public-document-heading').textContent = copy('document');
|
||||
$('document-facts').textContent = copy('chooseDocument'); $('public-document-body').replaceChildren();
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href');
|
||||
$('document-edition').replaceChildren(); $('document-edition').disabled = true;
|
||||
$('document-language').replaceChildren(); $('document-language').disabled = true; $('document-language').parentElement.hidden = true;
|
||||
$('document-find').value = ''; $('document-find').disabled = true; $('document-note').hidden = true;
|
||||
$('document-toc-list').replaceChildren(); $('document-find-prev').disabled = $('document-find-next').disabled = true;
|
||||
$('document-find-count').textContent = '';
|
||||
}
|
||||
async function runSearch(page = 1, updateUrl = true) {
|
||||
const query = $('public-query').value.trim();
|
||||
if (!query) { $('public-status').textContent = copy('queryRequired'); syncFilterChips(); return; }
|
||||
publicPage = Math.max(1, page);
|
||||
const requestId = ++searchRequest;
|
||||
clearOpenedMarkers(); clearSelectedPublicDocument();
|
||||
setMobileView('results');
|
||||
const loading = document.createElement('li'); loading.className = 'review__empty'; loading.textContent = copy('searching'); $('public-results').dataset.state = 'loading'; $('public-results').replaceChildren(loading);
|
||||
document.querySelector('.search__pagination').hidden = true;
|
||||
$('public-status').textContent = copy('searching');
|
||||
const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(publicPage), page_size: '20', sort: $('sort-order').value});
|
||||
for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value);
|
||||
@@ -762,24 +911,60 @@
|
||||
$('page-label').textContent = `${copy('page')} ${publicPage}`;
|
||||
$('page-prev').disabled = publicPage <= 1;
|
||||
$('page-next').disabled = !payload.has_next;
|
||||
document.querySelector('.search__pagination').hidden = !(publicPage > 1 || payload.has_next);
|
||||
} catch (error) {
|
||||
if (requestId !== searchRequest) return;
|
||||
$('public-status').textContent = error.message || copy('searchError');
|
||||
renderPublicResults(null);
|
||||
$('result-count').textContent = copy('noResultsCount');
|
||||
document.querySelector('.search__pagination').hidden = true;
|
||||
}
|
||||
}
|
||||
function buildDocumentToc() {
|
||||
const body = $('public-document-body');
|
||||
const headings = [...body.querySelectorAll('h1, h2, h3')];
|
||||
const entries = [];
|
||||
const structuralHeading = (paragraph) => {
|
||||
const normalized = paragraph.textContent.replace(/\s+/g, ' ').trim();
|
||||
const bold = [...paragraph.querySelectorAll('b, strong')].some((node) => node.textContent.replace(/\s+/g, ' ').trim() === normalized);
|
||||
if (!bold) return null;
|
||||
const patterns = [
|
||||
{level: 1, pattern: /^(?:раздел|бөлүм)\s+(?:№\s*)?[\divxlcdm]+(?:[\s.:—–-]+.*)?$/iu},
|
||||
{level: 2, pattern: /^глава\s+(?:№\s*)?[\divxlcdm]+(?:[\s.:—–-]+.*)?$/iu},
|
||||
{level: 3, pattern: /^(?:статья|берене|бап)\s+(?:№\s*)?[\divxlcdm]+(?:[\s.:—–-]+.*)?$/iu}
|
||||
];
|
||||
return patterns.find(({pattern}) => pattern.test(normalized))?.level ?? null;
|
||||
};
|
||||
body.querySelectorAll('h1, h2, h3, p').forEach((element) => {
|
||||
const level = /^H[123]$/.test(element.tagName) ? Number(element.tagName[1]) : structuralHeading(element);
|
||||
if (level) entries.push({element, level});
|
||||
});
|
||||
const root = $('document-toc-list'); root.replaceChildren();
|
||||
if (!headings.length) { const item = document.createElement('li'); item.textContent = copy('noSections'); root.append(item); return; }
|
||||
headings.forEach((heading, index) => {
|
||||
if (!heading.id) heading.id = `document-section-${index + 1}`;
|
||||
const item = document.createElement('li'); const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent.trim();
|
||||
link.addEventListener('click', (event) => { event.preventDefault(); heading.scrollIntoView({block: 'start'}); heading.focus({preventScroll: true}); });
|
||||
if (!heading.hasAttribute('tabindex')) heading.tabIndex = -1;
|
||||
item.append(link); root.append(item);
|
||||
if (!entries.length) { const item = document.createElement('li'); item.textContent = copy('noSections'); root.append(item); return; }
|
||||
const usedIds = new Set([...body.querySelectorAll('[id]')].map((element) => element.id));
|
||||
const lastItems = [];
|
||||
const childLists = new WeakMap();
|
||||
entries.forEach(({element, level}, index) => {
|
||||
const item = document.createElement('li');
|
||||
let targetList = root;
|
||||
if (level > 1) {
|
||||
const parent = lastItems[level - 2] || lastItems[0];
|
||||
if (parent) {
|
||||
targetList = childLists.get(parent);
|
||||
if (!targetList) { targetList = document.createElement('ol'); parent.append(targetList); childLists.set(parent, targetList); }
|
||||
}
|
||||
}
|
||||
let id = element.id;
|
||||
if (!id || [...body.querySelectorAll('[id]')].filter((candidate) => candidate.id === id).length > 1) {
|
||||
const baseId = `document-section-${index + 1}`;
|
||||
id = baseId;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(id)) id = `${baseId}-${suffix++}`;
|
||||
element.id = id; usedIds.add(id);
|
||||
}
|
||||
const link = document.createElement('a'); link.href = `#${id}`; link.textContent = element.textContent.replace(/\s+/g, ' ').trim();
|
||||
link.addEventListener('click', (event) => { event.preventDefault(); element.scrollIntoView({block: 'start'}); element.focus({preventScroll: true}); });
|
||||
if (!element.hasAttribute('tabindex')) element.tabIndex = -1;
|
||||
item.append(link); targetList.append(item); lastItems[level - 1] = item; lastItems.length = level;
|
||||
});
|
||||
}
|
||||
function highlightDocumentMatches() {
|
||||
@@ -832,8 +1017,58 @@
|
||||
$('search-layout').dataset.mobileView = view;
|
||||
if (view === 'document') { $('public-document-heading').tabIndex = -1; $('public-document-heading').focus(); }
|
||||
}
|
||||
function initResultsSplitter() {
|
||||
const layout = $('search-layout'); const splitter = $('search-splitter');
|
||||
const minimum = 25; const maximum = 55; const storageKey = 'akyldash-search-results-share';
|
||||
let value = 35.7; let userAdjusted = false;
|
||||
try { const saved = Number(localStorage.getItem(storageKey)); if (Number.isFinite(saved) && saved >= minimum && saved <= maximum) { value = saved; userAdjusted = true; } } catch (_) {}
|
||||
const apply = (next, persist = false) => {
|
||||
const filtersHidden = layout.dataset.filtersHidden === 'true';
|
||||
const wide = matchMedia('(min-width: 1192px)').matches;
|
||||
const compact = matchMedia('(min-width: 901px) and (max-width: 1191px)').matches;
|
||||
if (compact) { layout.style.gridTemplateColumns = ''; return; }
|
||||
if (!wide) return;
|
||||
const divider = 8;
|
||||
const filterTrack = filtersHidden ? 0 : Math.min(288, Math.max(264, layout.clientWidth * 0.2 + 8));
|
||||
const available = layout.clientWidth - filterTrack - divider;
|
||||
const minShare = Math.max(minimum, 360 / available * 100);
|
||||
const maxShare = Math.min(maximum, (available - 560) / available * 100);
|
||||
const preferredShare = (layout.clientWidth * 0.15 + 192) / available * 100;
|
||||
value = Math.max(minShare, Math.min(maxShare, userAdjusted ? next : preferredShare));
|
||||
layout.style.gridTemplateColumns = filtersHidden
|
||||
? `minmax(360px, ${value}fr) ${divider}px minmax(560px, ${100 - value}fr)`
|
||||
: `minmax(264px, ${filterTrack}px) minmax(360px, ${value}fr) ${divider}px minmax(560px, ${100 - value}fr)`;
|
||||
splitter.setAttribute('aria-valuemin', String(minShare)); splitter.setAttribute('aria-valuemax', String(maxShare));
|
||||
splitter.setAttribute('aria-valuenow', String(value));
|
||||
if (persist) { try { localStorage.setItem(storageKey, String(value)); } catch (_) {} }
|
||||
};
|
||||
apply(value);
|
||||
applyResultsSplitter = apply;
|
||||
window.addEventListener('resize', () => apply(value, true));
|
||||
let dragging = false;
|
||||
splitter.addEventListener('pointerdown', (event) => {
|
||||
if (matchMedia('(max-width: 900px)').matches) return;
|
||||
event.preventDefault(); dragging = true; splitter.setPointerCapture(event.pointerId);
|
||||
});
|
||||
splitter.addEventListener('pointermove', (event) => {
|
||||
if (!dragging) return;
|
||||
const rect = layout.getBoundingClientRect();
|
||||
const filterTrack = layout.dataset.filtersHidden === 'true' ? 0 : Math.min(288, Math.max(264, layout.clientWidth * 0.2 + 8));
|
||||
const available = layout.clientWidth - filterTrack - 8;
|
||||
if (available > 0) { userAdjusted = true; apply(((event.clientX - rect.left - filterTrack) / available) * 100); }
|
||||
});
|
||||
const finishDrag = (event) => { if (!dragging) return; dragging = false; apply(value, true); if (splitter.hasPointerCapture(event.pointerId)) splitter.releasePointerCapture(event.pointerId); };
|
||||
splitter.addEventListener('pointerup', finishDrag); splitter.addEventListener('pointercancel', finishDrag);
|
||||
splitter.addEventListener('keydown', (event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? Number(splitter.getAttribute('aria-valuemin')) : event.key === 'End' ? Number(splitter.getAttribute('aria-valuemax')) : value + (event.key === 'ArrowRight' ? 2 : -2);
|
||||
userAdjusted = true; apply(next, true);
|
||||
});
|
||||
return apply;
|
||||
}
|
||||
async function loadPublicEdition() {
|
||||
if (!selectedPublicDocument) return;
|
||||
if (!selectedPublicDocument) return false;
|
||||
const requestId = ++editionRequest;
|
||||
const {code} = selectedPublicDocument;
|
||||
const edition = $('document-edition').value;
|
||||
@@ -842,13 +1077,15 @@
|
||||
const languages = editionInfo?.available_languages || [];
|
||||
if (!languages.includes(language) && languages.length) { language = languages[0]; $('document-language').value = language; }
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href');
|
||||
$('document-language').parentElement.hidden = true;
|
||||
clearOpenedMarkers(); $('search-layout').removeAttribute('data-has-document');
|
||||
currentDocumentHtml = ''; $('public-document-body').replaceChildren();
|
||||
if (!languages.includes(language)) { $('document-facts').textContent = copy('editionMissing'); return; }
|
||||
if (!languages.includes(language)) { $('document-facts').textContent = copy('editionMissing'); return false; }
|
||||
$('document-facts').textContent = copy('loadingEdition');
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(code)}/editions/${encodeURIComponent(edition)}?language=${encodeURIComponent(language)}`);
|
||||
const payload = await response.json();
|
||||
if (requestId !== editionRequest || selectedPublicDocument?.code !== code || $('document-edition').value !== edition || $('document-language').value !== language) return;
|
||||
if (requestId !== editionRequest || selectedPublicDocument?.code !== code || $('document-edition').value !== edition || $('document-language').value !== language) return false;
|
||||
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
|
||||
const content = payload.content[language];
|
||||
const meta = $('document-facts'); meta.replaceChildren();
|
||||
@@ -864,25 +1101,33 @@
|
||||
$('document-expand').setAttribute('aria-expanded', 'false');
|
||||
$('document-find').disabled = false; $('document-note').hidden = true;
|
||||
highlightDocumentMatches();
|
||||
$('document-language').parentElement.hidden = languages.length < 2;
|
||||
const selectedRow = document.querySelector('.search__result--selected');
|
||||
if (selectedRow && selectedPublicDocument?.code === selectedRow.dataset.code) {
|
||||
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); selectedRow.append(opened);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestId !== editionRequest) return;
|
||||
if (requestId !== editionRequest) return false;
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href');
|
||||
currentDocumentHtml = '';
|
||||
$('document-facts').textContent = error.message || copy('editionUnavailable');
|
||||
$('public-document-body').replaceChildren();
|
||||
$('search-layout').removeAttribute('data-has-document');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function openPublicDocument(result, row) {
|
||||
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest; currentDocumentHtml = '';
|
||||
$('search-layout').removeAttribute('data-has-document'); $('search-layout').removeAttribute('data-has-editions');
|
||||
row.querySelector('.search__opened')?.remove();
|
||||
const opened = document.createElement('span'); opened.className = 'search__opened'; opened.textContent = copy('opened'); row.append(opened);
|
||||
clearOpenedMarkers();
|
||||
$('document-breadcrumb').textContent = result.name || copy('document');
|
||||
$('document-breadcrumb').parentElement.hidden = false;
|
||||
$('public-document-heading').textContent = result.name || copy('document'); $('public-document-body').replaceChildren();
|
||||
setMobileView('document');
|
||||
$('document-find').value = ''; $('document-find').disabled = true; $('document-note').hidden = true;
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href'); $('document-edition').replaceChildren(); $('document-language').replaceChildren();
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href'); $('document-edition').replaceChildren(); $('document-language').replaceChildren(); $('document-language').parentElement.hidden = true;
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions`);
|
||||
const payload = await response.json();
|
||||
@@ -902,10 +1147,11 @@
|
||||
$('search-layout').dataset.hasEditions = 'true';
|
||||
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
|
||||
await loadPublicEdition();
|
||||
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
|
||||
} catch (error) { if (selectedPublicDocument === result) { $('document-facts').textContent = error.message || 'Документ недоступен.'; $('document-language').parentElement.hidden = true; } }
|
||||
}
|
||||
async function initSearchPage() {
|
||||
if (!searchRoute) return;
|
||||
initResultsSplitter();
|
||||
const params = new URLSearchParams(location.search);
|
||||
$('public-query').value = params.get('q') || '';
|
||||
$('public-language').value = params.get('language') || 'ru';
|
||||
@@ -936,7 +1182,8 @@
|
||||
const setLabels = (selector, values) => document.querySelectorAll(selector).forEach((label, index) => { if (label.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${values[index]} `; });
|
||||
const setText = (id, key) => { if ($(id)) $(id).textContent = copy(key); };
|
||||
setText('search-nav', 'search'); setText('sources-open', 'sources'); setText('filters-heading', 'filters');
|
||||
setText('clear-filters', 'clear'); setText('mobile-filters-open', 'filters'); setText('mobile-filters-close', 'close');
|
||||
setText('clear-filters', 'clear'); setText('mobile-filters-close', 'close');
|
||||
$('mobile-filters-open').textContent = matchMedia('(max-width: 900px)').matches || $('search-layout').dataset.filtersHidden !== 'true' ? copy('filters') : (ky ? 'Чыпкаларды көрсөтүү' : 'Показать фильтры');
|
||||
setText('search-results-heading', 'results'); setText('document-print', 'print');
|
||||
$('search-account-open').setAttribute('aria-label', ky ? 'Жеке кабинет' : 'Личный кабинет'); $('search-account-open').title = $('search-account-open').getAttribute('aria-label');
|
||||
$('public-search-form').querySelector('button[type="submit"]').textContent = ky ? 'Издөө' : 'Найти';
|
||||
@@ -963,16 +1210,16 @@
|
||||
$('document-find-prev').setAttribute('aria-label', ky ? 'Мурунку дал келүү' : 'Предыдущее совпадение'); $('document-find-next').setAttribute('aria-label', ky ? 'Кийинки дал келүү' : 'Следующее совпадение');
|
||||
$('document-source').textContent = copy('source');
|
||||
const modal = modalCopy[uiLanguage];
|
||||
setText('account-heading', 'account'); $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.users; $('search-logout').textContent = modal.logout;
|
||||
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
|
||||
$('account-heading').textContent = modal.account; $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.usersHeading; $('search-logout').textContent = modal.logout;
|
||||
$('profile-role').textContent = account ? roleLabel(account) : '';
|
||||
document.querySelectorAll('.review__password-toggle').forEach((button) => { const visible = button.getAttribute('aria-pressed') === 'true'; const label = visible ? modal.hidePassword : modal.showPassword; button.textContent = label; button.setAttribute('aria-label', label); button.title = label; });
|
||||
$('profile-tab').textContent = ky ? 'Профиль' : 'Профиль'; $('security-tab').textContent = ky ? 'Коопсуздук' : 'Безопасность';
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.setAttribute('aria-label', modal.close));
|
||||
document.querySelectorAll('[data-close]').forEach((button) => { button.setAttribute('aria-label', modal.close); button.title = modal.close; });
|
||||
const accountLabels = document.querySelectorAll('#account-dialog dt'); accountLabels[1].textContent = modal.role; accountLabels[2].textContent = modal.created;
|
||||
$('profile-created').textContent = account?.created_at ? new Intl.DateTimeFormat(ky ? 'ky-KG' : 'ru-RU').format(new Date(account.created_at * 1000)) : '';
|
||||
document.querySelector('#profile-password-form h3').textContent = modal.changePassword; setLabels('#profile-password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]);
|
||||
document.querySelector('#profile-password-form p').textContent = modal.passwordRequirement;
|
||||
document.querySelector('#profile-password-form button[type="submit"]').textContent = modal.change;
|
||||
document.querySelector('[data-close="account-dialog"]').textContent = modal.close;
|
||||
$('users-heading').textContent = modal.usersHeading; setLabels('#create-user-form label', [modal.username, modal.temporaryPassword]);
|
||||
document.querySelector('#create-user-form p').textContent = modal.temporaryPasswordHelp;
|
||||
document.querySelector('#create-user-form button').textContent = modal.createUser;
|
||||
@@ -988,15 +1235,24 @@
|
||||
document.querySelectorAll('.search__opened').forEach((item) => { item.textContent = copy('opened'); });
|
||||
if (documentMatches.length) $('document-find-count').textContent = `${activeDocumentMatch + 1} ${ky ? 'ичинен' : 'из'} ${documentMatches.length}`;
|
||||
else if ($('document-find').value.trim()) $('document-find-count').textContent = copy('noMatches');
|
||||
const resultTotal = $('public-results').querySelector('.review__empty') ? 0 : $('public-results').children.length;
|
||||
if ($('public-query').value.trim()) {
|
||||
$('result-count').textContent = `${resultTotal} ${resultTotal === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = resultTotal ? `${copy('resultsFor')} «${$('public-query').value.trim()}».` : `«${$('public-query').value.trim()}»: ${copy('noDocuments')}`;
|
||||
const resultList = $('public-results');
|
||||
const resultTotal = resultList.querySelectorAll('[data-code]').length;
|
||||
const query = $('public-query').value.trim();
|
||||
const state = resultList.dataset.state || 'initial';
|
||||
if (state === 'initial') {
|
||||
$('result-count').textContent = ''; $('public-status').textContent = query ? copy('searching') : copy('queryRequired');
|
||||
resultList.querySelector('.review__empty').textContent = ky ? 'Издөө баштоо үчүн суроо киргизиңиз.' : 'Введите запрос, чтобы начать поиск.';
|
||||
} else if (state === 'loading') {
|
||||
$('result-count').textContent = copy('searching'); $('public-status').textContent = copy('searching');
|
||||
resultList.querySelector('.review__empty').textContent = copy('searching');
|
||||
} else if (state === 'error') {
|
||||
$('result-count').textContent = copy('noResultsCount'); $('public-status').textContent = copy('searchError');
|
||||
resultList.querySelector('.review__empty').textContent = copy('searchError');
|
||||
} else {
|
||||
setText('result-count', 'enterQuery'); setText('public-status', 'queryRequired');
|
||||
$('result-count').textContent = `${resultTotal} ${resultTotal === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = resultTotal ? `${copy('resultsFor')} «${query}».` : `«${query}»: ${copy('noDocuments')}`;
|
||||
if (state === 'empty') resultList.querySelector('.review__empty').textContent = copy('noResults');
|
||||
}
|
||||
const empty = $('public-results').querySelector('.review__empty');
|
||||
if (empty) { empty.querySelector('strong').textContent = copy('noResults'); empty.lastChild.textContent = copy('noResultsHint'); }
|
||||
const pageMatch = $('page-label').textContent.match(/\d+/); if (pageMatch) $('page-label').textContent = `${copy('page')} ${pageMatch[0]}`;
|
||||
if (filterCatalogsLoaded) {
|
||||
const selected = selectedFilters(); renderFilterCatalogs(filterCatalogs);
|
||||
@@ -1022,12 +1278,13 @@
|
||||
$('document-find').addEventListener('input', highlightDocumentMatches);
|
||||
$('document-find-prev').addEventListener('click', () => moveDocumentMatch(-1)); $('document-find-next').addEventListener('click', () => moveDocumentMatch(1));
|
||||
$('mobile-filters-open').addEventListener('click', () => {
|
||||
if (matchMedia('(max-width: 900px)').matches) openMobileFilters();
|
||||
if (matchMedia('(max-width: 1191px)').matches) openMobileFilters();
|
||||
else {
|
||||
const hidden = $('search-layout').dataset.filtersHidden !== 'true';
|
||||
$('search-layout').dataset.filtersHidden = String(hidden);
|
||||
$('mobile-filters-open').setAttribute('aria-expanded', String(!hidden));
|
||||
$('mobile-filters-open').textContent = hidden ? 'Показать фильтры' : 'Скрыть фильтры';
|
||||
$('mobile-filters-open').textContent = hidden ? (uiLanguage === 'ky' ? 'Чыпкаларды көрсөтүү' : 'Показать фильтры') : copy('filters');
|
||||
applyResultsSplitter?.(Number($('search-splitter').getAttribute('aria-valuenow')));
|
||||
}
|
||||
});
|
||||
$('mobile-filters-close').addEventListener('click', closeMobileFilters); $('filter-overlay').addEventListener('click', closeMobileFilters);
|
||||
@@ -1086,7 +1343,7 @@
|
||||
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
|
||||
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
|
||||
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.2.6 · Backend v0.13.1`;
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.4 · Backend v0.13.4`;
|
||||
if (usersRoute && account) loadUsers();
|
||||
};
|
||||
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
|
||||
@@ -1103,7 +1360,7 @@
|
||||
const row = document.createElement('li'); row.className = 'review__user-row'; row.setAttribute('role', 'row');
|
||||
const cell = (label, value, className = '') => { const item = document.createElement('span'); item.setAttribute('role', 'cell'); item.dataset.label = label; item.className = className; item.textContent = value; return item; };
|
||||
const words = userCopy[usersLanguage];
|
||||
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, user.role || words.role), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
|
||||
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, roleLabel(user, usersLanguage)), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
|
||||
const actions = document.createElement('div'); actions.className = 'review__user-actions'; actions.setAttribute('role', 'cell'); actions.dataset.label = 'Действия';
|
||||
const history = document.createElement('details'); history.className = 'review__user-history';
|
||||
const historySummary = document.createElement('summary'); historySummary.textContent = words.history;
|
||||
@@ -1142,7 +1399,6 @@
|
||||
$('create-user-open').addEventListener('click', () => { $('create-user-dialog').showModal(); $('create-user-heading').focus(); });
|
||||
$('create-user-form').addEventListener('submit', async (event) => { event.preventDefault(); $('users-error').textContent = ''; const response = await apiFetch('/admin/users', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({username: $('new-username').value, password: $('temporary-password').value})}); const payload = await response.json(); if (!response.ok) { $('users-error').textContent = payload.error || 'Не удалось создать учетную запись.'; return; } $('create-user-form').reset(); $('create-user-dialog').close(); await loadUsers(); });
|
||||
['review-account-open', 'search-account-open'].forEach((id) => $(id).addEventListener('click', () => { $('account-dialog').showModal(); $('profile-tab').focus(); }));
|
||||
$('search-users-open').addEventListener('click', () => { $('account-dialog').close(); location.assign('/users'); });
|
||||
[['profile-tab', 'security-tab'], ['security-tab', 'profile-tab']].forEach(([id, other]) => $(id).addEventListener('click', () => {
|
||||
const selected = $(id), target = id === 'profile-tab' ? $('profile-panel') : $('security-panel');
|
||||
const inactive = id === 'profile-tab' ? $('security-panel') : $('profile-panel');
|
||||
@@ -1163,10 +1419,16 @@
|
||||
close.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
heading.tabIndex = -1; heading.before(chrome); chrome.append(heading, close);
|
||||
});
|
||||
document.querySelectorAll('#profile-password-form input[type="password"]').forEach((input) => {
|
||||
const field = document.createElement('span'); field.className = 'review__password-field'; input.before(field); field.append(input);
|
||||
const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'review__password-toggle'; toggle.setAttribute('aria-pressed', 'false'); toggle.setAttribute('aria-label', modalCopy[uiLanguage].showPassword); toggle.title = modalCopy[uiLanguage].showPassword; toggle.textContent = modalCopy[uiLanguage].showPassword;
|
||||
toggle.addEventListener('click', () => { const visible = input.type === 'password'; input.type = visible ? 'text' : 'password'; toggle.setAttribute('aria-pressed', String(visible)); const label = visible ? modalCopy[uiLanguage].hidePassword : modalCopy[uiLanguage].showPassword; toggle.setAttribute('aria-label', label); toggle.title = label; toggle.textContent = label; });
|
||||
field.append(toggle);
|
||||
});
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
|
||||
['review-logout', 'search-logout'].forEach((id) => $(id).addEventListener('click', async () => { await fetch('/auth/logout', {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: '{}'}); account = null; document.body.classList.add('review__locked'); showLogin(); }));
|
||||
(async () => { const response = await fetch('/auth/me', {credentials: 'same-origin'}); if (!response.ok) { showLogin(); return; } const payload = await response.json(); if (payload.account.must_change_password) { $('password-dialog').showModal(); $('current-password').focus(); return; } enterWorkspace(payload.account); })();
|
||||
const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
|
||||
const setStatus = (text, error = false) => { const status = $('status'); const busy = !error && /(?:выполняется|загрузка|жүктөлүүдө)/i.test(text); status.textContent = text; status.classList.toggle('review__status--error', error); status.classList.toggle('review__status--busy', busy); status.classList.toggle('review__status--success', Boolean(text) && !error && !busy); status.setAttribute('role', error ? 'alert' : 'status'); status.setAttribute('aria-live', error ? 'assertive' : 'polite'); };
|
||||
const setError = (text) => setStatus(text, true);
|
||||
const escapeText = (value) => value == null ? '' : String(value);
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(draftStorageKey()) || 'null'); } catch (_) { return null; } };
|
||||
@@ -1201,12 +1463,12 @@
|
||||
let tutorialStep = 0; let tutorialReturnFocus = null;
|
||||
function clearTourTarget() { $('tutorial-spotlight').hidden = true; $('document-panel').classList.remove('review__document--tour-visible'); }
|
||||
function renderTutorial() {
|
||||
const step = TOUR_STEPS[tutorialStep]; const target = $(step.target);
|
||||
const step = TOUR_STEPS[tutorialStep]; const mobileDocumentTarget = step.target === 'document-panel' && matchMedia('(max-width: 800px)').matches; const target = mobileDocumentTarget ? $('document-heading') : $(step.target);
|
||||
$('document-panel').classList.toggle('review__document--tour-visible', step.target === 'document-panel' && matchMedia('(max-width: 800px)').matches);
|
||||
const dialog = $('tutorial-dialog');
|
||||
const focusedControl = dialog.open && dialog.contains(document.activeElement) ? document.activeElement.id : 'tutorial-next';
|
||||
if (dialog.open) dialog.close();
|
||||
target.scrollIntoView({block: 'nearest', inline: 'nearest'});
|
||||
target.scrollIntoView({block: mobileDocumentTarget ? 'center' : 'nearest', inline: 'nearest'});
|
||||
const bounds = target.getBoundingClientRect();
|
||||
$('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`;
|
||||
$('tutorial-spotlight').hidden = false;
|
||||
@@ -1238,9 +1500,9 @@
|
||||
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
|
||||
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
|
||||
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token && state.results.length ? 'Сохранить оценку' : 'Нет данных для сохранения'; $('save').disabled = !state.token || !state.results.length; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу сохранить нельзя.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); $('overall-comment').setAttribute('aria-required', String(!state.results.length)); $('overall-comment').placeholder = state.results.length ? 'Что важно учесть? (необязательно)' : 'Объясните, почему выдача пуста (обязательно)'; setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token ? (state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу') : 'Нет данных для сохранения'; $('save').disabled = !state.token; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу можно сохранить с комментарием.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); $('overall-comment').setAttribute('aria-required', 'false'); $('overall-comment').placeholder = 'Что важно учесть? (необязательно)'; setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } if (!state.results.length && !$('overall-comment').value.trim()) { setError('Добавьте комментарий к пустой выдаче.'); $('overall-comment').focus(); return; } const reviewer = $('reviewer').value.trim(); const results = state.results.map((result, index) => { const item = $('results').children[index]; return {rank: index + 1, code: result.code, rating: item?.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item?.querySelector('textarea')?.value || ''}; }); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
if (reviewRoute) $('tutorial-open').hidden = false;
|
||||
</script>
|
||||
|
||||
@@ -70,21 +70,178 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
self.assertEqual(flags, {"920292": True, "920492": False})
|
||||
|
||||
def test_switches_alias_atomically_after_successful_load(self):
|
||||
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
|
||||
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"akyldash-fragments-v1": {"aliases": {"akyldash-fragments-current": {}}}},
|
||||
{"acknowledged": True},
|
||||
]) as request:
|
||||
switch_alias(
|
||||
"http://127.0.0.1:9200/", "akyldash-fragments-v2",
|
||||
"akyldash-fragments-current", "akyldash-fragments-v1",
|
||||
)
|
||||
|
||||
self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}})
|
||||
self.assertEqual(request.call_args_list[1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
|
||||
body = json.loads(request.call_args_list[1].args[2])
|
||||
self.assertEqual(body["actions"][0], {"remove": {"index": "akyldash-fragments-v1", "alias": "akyldash-fragments-current", "must_exist": True}})
|
||||
self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}})
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "differ"):
|
||||
switch_alias("http://127.0.0.1:9200", "same", "same")
|
||||
switch_alias("http://127.0.0.1:9200", "same", "same", "old")
|
||||
with self.assertRaisesRegex(ValueError, "differ"):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "")
|
||||
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "", "old")
|
||||
with patch("search.minjust_opensearch.request_json", return_value={
|
||||
"akyldash-fragments-v3": {"aliases": {"alias": {}}}
|
||||
}) as request:
|
||||
with self.assertRaisesRegex(ValueError, "currently targets"):
|
||||
switch_alias("http://127.0.0.1:9200", "new", "alias", "akyldash-fragments-v1")
|
||||
request.assert_called_once()
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}}, {"acknowledged": False},
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "alias")
|
||||
switch_alias("http://127.0.0.1:9200", "index", "alias", "old")
|
||||
|
||||
def test_switch_alias_recovers_when_post_succeeds_but_response_is_lost(self):
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
RuntimeError("response lost after OpenSearch applied request"),
|
||||
{"candidate": {"aliases": {"alias": {}}}},
|
||||
]) as request:
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
self.assertEqual(request.call_count, 3)
|
||||
self.assertEqual(request.call_args_list[1].args[1], "POST")
|
||||
self.assertEqual(request.call_args_list[2].args[1], "GET")
|
||||
|
||||
def test_switch_alias_is_idempotent_after_process_stops_before_checkpoint(self):
|
||||
with patch("search.minjust_opensearch.request_json", return_value={
|
||||
"candidate": {"aliases": {"alias": {}}}
|
||||
}) as request:
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
request.assert_called_once()
|
||||
|
||||
def test_switch_alias_refuses_to_retry_after_unexpected_target(self):
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
RuntimeError("response lost"),
|
||||
{"other": {"aliases": {"alias": {}}}},
|
||||
]):
|
||||
with self.assertRaisesRegex(ValueError, "unexpected index"):
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
|
||||
def test_resume_completes_checkpoint_when_alias_switched_before_previous_process_stopped(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
input_root = root / "normalized"
|
||||
document_root = input_root / "documents/1"
|
||||
document_root.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
connection.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
(document_root / "document.json").write_text(
|
||||
json.dumps({"schema_version": "1", "source_code": "1", "editions": [
|
||||
{"source_code": "1", "source_order": 0},
|
||||
]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
text = "текст закона"
|
||||
fragment = document_root / "editions/1/ru/fragments.json"
|
||||
fragment.parent.mkdir(parents=True)
|
||||
fragment.write_text(json.dumps([{
|
||||
"id": "document:1:edition:1:lang:ru:fragment:1",
|
||||
"document_code": "1", "edition_code": "1", "language": "ru", "position": 1,
|
||||
"type": "paragraph", "text": text,
|
||||
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
|
||||
"source_path": "documents/1/editions/1/ru.html", "source_sha256": "b" * 64,
|
||||
}]), encoding="utf-8")
|
||||
checkpoint = root / "checkpoint.json"
|
||||
state = {
|
||||
"schema_version": 2,
|
||||
"url": "http://os:9200",
|
||||
"cluster_uuid": "cluster",
|
||||
"index": "candidate",
|
||||
"index_uuid": "index-uuid",
|
||||
"input": str(input_root.resolve()),
|
||||
"manifest_sha256": hashlib.sha256((input_root / "manifest.sqlite3").read_bytes()).hexdigest(),
|
||||
"limit": None,
|
||||
"alias": "current",
|
||||
"last_document_code": "1",
|
||||
"complete": False,
|
||||
}
|
||||
checkpoint.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"cluster_uuid": "cluster"},
|
||||
{"candidate": {"settings": {"index": {"uuid": "index-uuid"}}}},
|
||||
{"errors": False, "items": [{"index": {}}]},
|
||||
{"candidate": {"aliases": {"current": {}}}},
|
||||
]) as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
):
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
input_root, "http://os:9200", "candidate", resume=True, checkpoint=checkpoint,
|
||||
alias="current", expected_old_index="previous", canary_ru="закон", canary_ky="мыйзам",
|
||||
),
|
||||
(1, 1),
|
||||
)
|
||||
self.assertTrue(json.loads(checkpoint.read_text(encoding="utf-8"))["complete"])
|
||||
self.assertEqual(request.call_args_list[-1].args[1], "GET")
|
||||
validate_candidate.assert_called_once_with("http://os:9200", "candidate", 1, "закон", "мыйзам")
|
||||
|
||||
def test_zero_fragment_candidate_never_reaches_alias_switch(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary) / "normalized"
|
||||
document = root / "documents/1"
|
||||
document.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as connection:
|
||||
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
connection.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
(document / "document.json").write_text(
|
||||
json.dumps({"source_code": "1", "editions": []}), encoding="utf-8"
|
||||
)
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{}, {"cluster_uuid": "cluster"},
|
||||
{"test-index": {"settings": {"index": {"uuid": "index"}}}},
|
||||
]) as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
patch("search.minjust_opensearch.switch_alias") as switch,
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "empty expected fragment inventory"):
|
||||
load_bulk(
|
||||
root, "http://127.0.0.1:9200", "test-index",
|
||||
checkpoint=Path(temporary) / "checkpoint.json", alias="current",
|
||||
expected_old_index="old-index", canary_ru="закон", canary_ky="мыйзам",
|
||||
)
|
||||
self.assertEqual(request.call_count, 3)
|
||||
validate_candidate.assert_not_called()
|
||||
switch.assert_not_called()
|
||||
|
||||
def test_candidate_index_requires_exact_count_and_both_language_canaries(self):
|
||||
from search.minjust_opensearch import validate_candidate_index
|
||||
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"_shards": {"failed": 0}},
|
||||
{"count": 2},
|
||||
{"hits": {"hits": [{"_source": {"text_ru": "тест"}}]}},
|
||||
{"hits": {"hits": [{"_source": {"text_ky": "сыноо"}}]}},
|
||||
]) as request:
|
||||
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
|
||||
self.assertEqual(request.call_count, 4)
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"_shards": {"failed": 0}}, {"count": 1}
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "expected 2"):
|
||||
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"_shards": {"failed": 0}}, {"count": 2},
|
||||
{"hits": {"hits": [{"_source": {"text_ru": "тест"}}]}},
|
||||
{"hits": {"hits": []}},
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "ky canary"):
|
||||
validate_candidate_index("http://os:9200", "candidate", 2, "тест", "сыноо")
|
||||
|
||||
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
@@ -160,13 +317,16 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
document_codes(root / "normalized", 1, "8")
|
||||
|
||||
checkpoint = root / "checkpoint.json"
|
||||
with patch("search.minjust_opensearch.request_json") as request:
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json") as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
patch("search.minjust_opensearch.switch_alias") as switch,
|
||||
):
|
||||
request.side_effect = [
|
||||
{},
|
||||
{"cluster_uuid": "cluster-1"},
|
||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
|
||||
{"acknowledged": True},
|
||||
]
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
@@ -176,11 +336,19 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
maximum_bytes=4096,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
),
|
||||
(2, 2),
|
||||
)
|
||||
self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson")
|
||||
self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
|
||||
self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson")
|
||||
validate_candidate.assert_called_once_with(
|
||||
"http://127.0.0.1:9200", "test-index", 2, "закон", "мыйзам"
|
||||
)
|
||||
switch.assert_called_once_with(
|
||||
"http://127.0.0.1:9200", "test-index", "test-current", "test-index-old"
|
||||
)
|
||||
state = json.loads(checkpoint.read_text(encoding="utf-8"))
|
||||
self.assertEqual(state["last_document_code"], "7")
|
||||
self.assertTrue(state["complete"])
|
||||
@@ -191,7 +359,11 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
legacy["last_document_code"] = "8"
|
||||
legacy["complete"] = False
|
||||
checkpoint.write_text(json.dumps(legacy), encoding="utf-8")
|
||||
with patch("search.minjust_opensearch.request_json") as request:
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json") as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
patch("search.minjust_opensearch.switch_alias"),
|
||||
):
|
||||
request.side_effect = [
|
||||
{"cluster_uuid": "cluster-1"},
|
||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||
@@ -226,6 +398,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
)
|
||||
with patch("search.minjust_opensearch.request_json") as request:
|
||||
request.side_effect = [
|
||||
@@ -256,12 +431,18 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
)
|
||||
with patch("search.minjust_opensearch.request_json") as request:
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json") as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
patch("search.minjust_opensearch.switch_alias"),
|
||||
):
|
||||
request.side_effect = [
|
||||
{"cluster_uuid": "cluster-1"},
|
||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||
{"acknowledged": True},
|
||||
]
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
@@ -272,11 +453,16 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
),
|
||||
(1, 0),
|
||||
)
|
||||
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1]))
|
||||
self.assertEqual(request.call_args_list[-1].args[1], "POST")
|
||||
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list))
|
||||
validate_candidate.assert_called_once_with(
|
||||
"http://127.0.0.1:9200", "test-index", 2, "закон", "мыйзам"
|
||||
)
|
||||
|
||||
state["last_document_code"] = "9"
|
||||
state["complete"] = False
|
||||
@@ -295,6 +481,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
)
|
||||
|
||||
failed_checkpoint = root / "failed-checkpoint.json"
|
||||
@@ -341,6 +530,9 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
expected_old_index="test-index-old",
|
||||
canary_ru="закон",
|
||||
canary_ky="мыйзам",
|
||||
)
|
||||
|
||||
http_error = urllib.error.HTTPError(
|
||||
|
||||
@@ -3,6 +3,7 @@ import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -59,6 +60,34 @@ class SearchApiTest(unittest.TestCase):
|
||||
admin_api = get("/admin/users")
|
||||
admin_api.send_response.assert_called_once_with(401)
|
||||
|
||||
def test_account_capability_matches_the_admin_users_api_guard(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
api.auth_store.create_admin("Fixture-only-admin-password-2026")
|
||||
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
|
||||
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
|
||||
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
|
||||
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
|
||||
request_handler = handler(api)
|
||||
|
||||
def get(path, token):
|
||||
request = request_handler.__new__(request_handler)
|
||||
request.path = path
|
||||
request.headers = {"Cookie": f"akyldash_session={token}"}
|
||||
request.wfile = io.BytesIO()
|
||||
request.send_response = Mock()
|
||||
request.send_header = Mock()
|
||||
request.end_headers = Mock()
|
||||
request.do_GET()
|
||||
return request
|
||||
|
||||
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
|
||||
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
|
||||
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
|
||||
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
|
||||
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
|
||||
get("/admin/users", user_token).send_response.assert_called_once_with(403)
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
@@ -162,6 +191,44 @@ class SearchApiTest(unittest.TestCase):
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
|
||||
alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
|
||||
single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
|
||||
one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
|
||||
empty = {"hits": {"hits": []}}
|
||||
alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
|
||||
aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
|
||||
with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
|
||||
multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias.filters({"language": ["ru"]})
|
||||
|
||||
calls = request.call_args_list
|
||||
multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
|
||||
multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
|
||||
self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
|
||||
self.assertEqual(multi_ky_empty["results"], [])
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
|
||||
for call in calls[2:]:
|
||||
parsed = urllib.parse.urlsplit(call.args[0])
|
||||
self.assertEqual(parsed.query, "")
|
||||
self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
|
||||
self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
|
||||
multi_body = json.loads(calls[0].args[2])
|
||||
alias_body = json.loads(calls[2].args[2])
|
||||
self.assertEqual(multi_body, alias_body)
|
||||
self.assertNotIn("search_type", multi_body)
|
||||
self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,5 +37,29 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
|
||||
|
||||
|
||||
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
|
||||
class SearchRankingOpenSearchTest(unittest.TestCase):
|
||||
def test_generic_lease_query_ranks_general_civil_code_first(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
|
||||
api = Api(base_url, index, Path("."))
|
||||
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
|
||||
self.assertEqual(result["results"][0]["code"], "5")
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
|
||||
"set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
|
||||
)
|
||||
class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
|
||||
def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
|
||||
api = Api(base_url, index, Path("."))
|
||||
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
|
||||
self.assertIsInstance(result["results"], list)
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.evaluate_relevance import evaluate, load_queries
|
||||
from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
|
||||
from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
|
||||
|
||||
|
||||
class SearchRelevanceTest(unittest.TestCase):
|
||||
@@ -49,6 +49,36 @@ class SearchRelevanceTest(unittest.TestCase):
|
||||
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
|
||||
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
|
||||
|
||||
def test_russian_lease_phrase_expands_only_the_exact_legal_construction(self):
|
||||
original = "расторжение договора аренды"
|
||||
variants = query_variants("ru", original)
|
||||
self.assertEqual(variants[0], original)
|
||||
self.assertIn("расторжение договора имущественного найма", variants)
|
||||
body = json.loads(build_search_body("ru", original, 10))
|
||||
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||
full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
|
||||
self.assertEqual(full_text, variants)
|
||||
phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
|
||||
self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
|
||||
|
||||
for query in (
|
||||
"аренда квартиры",
|
||||
"расторжение договора аренды квартиры",
|
||||
"договор аренды земельного участка",
|
||||
"договор поднайма",
|
||||
"договор аренды с правом поднайма",
|
||||
"наем работника",
|
||||
"договор лизинга",
|
||||
):
|
||||
self.assertEqual(query_variants("ru", query), [query])
|
||||
|
||||
self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
|
||||
|
||||
self.assertEqual(
|
||||
query_variants("ru", "договору аренды"),
|
||||
["договору аренды", "договору имущественного найма"],
|
||||
)
|
||||
|
||||
def test_relaxed_match_drops_ru_template_prefix_and_stays_low_weight(self):
|
||||
body = json.loads(build_search_body("ru", "правовое регулирование расторжение договора аренды", 10))
|
||||
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||
|
||||
@@ -3,33 +3,140 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
|
||||
|
||||
|
||||
class SearchReviewTest(unittest.TestCase):
|
||||
def test_multi_index_snapshot_and_saved_review_preserve_configured_scope(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "A,B", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
cases = (
|
||||
("no hits", []),
|
||||
("A only", [{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}}]),
|
||||
("B only", [{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}}]),
|
||||
("mixed", [
|
||||
{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}},
|
||||
{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}},
|
||||
]),
|
||||
)
|
||||
for label, hits in cases:
|
||||
with self.subTest(case=label):
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": hits}}):
|
||||
result = api.handle("GET", "/search?q=test&language=ru")[1]
|
||||
token = result["review_token"]
|
||||
self.assertEqual(api.review_snapshots.verify(token)["index_name"], "A,B")
|
||||
review_results = [
|
||||
{"rank": rank, "code": item["code"], "rating": 3}
|
||||
for rank, item in enumerate(result["results"], 1)
|
||||
]
|
||||
api.handle("POST", "/search-reviews", {
|
||||
"review_token": token,
|
||||
"reviewer": "Юрист",
|
||||
"results": review_results,
|
||||
"overall_comment": "Проверена выдача для настроенного scope.",
|
||||
})
|
||||
saved = api.handle("GET", "/search-reviews/export")[1]["reviews"][-1]
|
||||
self.assertEqual(saved["index_name"], "A,B")
|
||||
|
||||
def test_review_must_cover_each_snapshot_rank_once(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": []})
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
|
||||
|
||||
def test_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
|
||||
response = {"hits": {"hits": [
|
||||
{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
|
||||
{"_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}},
|
||||
]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
token = result["review_token"]
|
||||
|
||||
malformed = [
|
||||
{"rank": True, "code": "7", "rating": 3},
|
||||
{"rank": [1], "code": "7", "rating": 3},
|
||||
{"rank": {"value": 1}, "code": "7", "rating": 3},
|
||||
{"rank": None, "code": "7", "rating": 3},
|
||||
{"rank": "1", "code": "7", "rating": 3},
|
||||
{"rank": 1.0, "code": "7", "rating": 3},
|
||||
None,
|
||||
]
|
||||
for item in malformed:
|
||||
with self.subTest(item=item):
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
for submitted in (
|
||||
[],
|
||||
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
|
||||
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
|
||||
[{"rank": 1, "code": "7", "rating": 3}],
|
||||
{"rank": 1, "code": "7", "rating": 3},
|
||||
):
|
||||
with self.subTest(submitted=submitted):
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "search-20260827")
|
||||
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
|
||||
self.assertEqual(saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[0]["index_name"], "search-20260827")
|
||||
self.assertEqual(exported[0]["top_result_code"], "7")
|
||||
self.assertEqual(exported[0]["results"][0]["rating"], 3)
|
||||
with self.assertRaisesRegex(ApiError, "does not match"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
|
||||
|
||||
def test_saves_signed_empty_snapshot_with_query_level_comment(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": []}}):
|
||||
result = api.handle("GET", "/search?q=редкий+запрос&language=ru")[1]
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "current")
|
||||
body = {
|
||||
"review_token": result["review_token"],
|
||||
"reviewer": "Юрист",
|
||||
"overall_comment": "Пустая выдача подтверждена.",
|
||||
"results": [],
|
||||
}
|
||||
with self.assertRaisesRegex(ApiError, "overall_comment is required"):
|
||||
api.handle("POST", "/search-reviews", {**body, "overall_comment": ""})
|
||||
saved = api.handle("POST", "/search-reviews", body)
|
||||
self.assertEqual(saved[0], 201)
|
||||
review = api.handle("GET", "/search-reviews/export")[1]["reviews"][0]
|
||||
self.assertIsNone(review["top_result_code"])
|
||||
self.assertEqual(review["results"], [])
|
||||
self.assertEqual(review["overall_comment"], body["overall_comment"])
|
||||
|
||||
def test_review_page_enables_query_level_review_for_empty_results(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
page = api.review_page()
|
||||
self.assertIn("state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу'", page)
|
||||
self.assertIn("aria-required', String(!state.results.length)", page)
|
||||
self.assertIn("const results = state.results.map((result, index) => { const item", page)
|
||||
|
||||
def test_snapshot_and_review_page_are_available(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
@@ -41,6 +148,27 @@ class SearchReviewTest(unittest.TestCase):
|
||||
self.assertNotIn('id="error"', page)
|
||||
self.assertIn("localStorage", page)
|
||||
|
||||
def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
|
||||
result = api.handle("GET", "/search?q=test&language=ru")[1]
|
||||
current = api.review_snapshots.verify(result["review_token"])
|
||||
self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
|
||||
self.assertEqual(current["algorithm_version"], "search-8")
|
||||
|
||||
legacy_token = api.review_snapshots.create(
|
||||
"legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
|
||||
)
|
||||
legacy_saved = api.handle("POST", "/search-reviews", {
|
||||
"review_token": legacy_token,
|
||||
"reviewer": "Юрист",
|
||||
"results": [{"rank": 1, "code": "7", "rating": 3}],
|
||||
})
|
||||
self.assertEqual(legacy_saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[-1]["algorithm_version"], "search-7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -110,3 +110,55 @@ Follow the procedures in [`BACKUP.md`](../../docs/operations/BACKUP.md),
|
||||
|
||||
Automated production deployment is not enabled. Deployments remain owner-run;
|
||||
record the release and verify service health before considering it complete.
|
||||
|
||||
## Image release and owner-run cutover
|
||||
|
||||
PR CI still builds both production images with Kaniko `--no-push`. The separate
|
||||
`Release production images` workflow is manual and runs only when dispatched
|
||||
from protected `main`. It requires repository secrets named
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`; without them it fails before any
|
||||
build. Configure those secrets only after the owner confirms a dedicated
|
||||
Gitea account/token with write access to the two repository packages and no
|
||||
unneeded administration rights. Do not reuse a runner or production host
|
||||
credential. The workflow uses the Gitea host and fixed package namespace
|
||||
`git.vakanaut.ru/admin/akyldash/{backend,opensearch}`, pushes unique
|
||||
commit/run-ID tags, captures registry digests, validates the release
|
||||
descriptor, and uploads the JSON plus SHA-256 sidecar as a Gitea Actions
|
||||
artifact using a full-SHA-pinned Gitea-compatible v3 action. PR CI also uploads
|
||||
a tiny synthetic marker with one-day retention to verify the runner's runtime
|
||||
artifact path without registry credentials. The runner cache stays disabled;
|
||||
artifact compatibility is based on the runtime API supported by act_runner
|
||||
0.2.13/Gitea 1.25.5 and will be empirically checked by PR CI. Registry and
|
||||
artifact retention must preserve release digests and descriptors for the full
|
||||
rollback window; the owner must confirm both policies.
|
||||
|
||||
The schema-v3 descriptor records source repository/ref/commit, workflow run ID,
|
||||
pinned Kaniko builder and both image digests. It is an unsigned trace record,
|
||||
not a cryptographic attestation; deployment relies on retrieving it from the
|
||||
authenticated Gitea run artifact and verifying its checksum and repository
|
||||
allowlist. Do not treat the sidecar alone as proof of authenticity.
|
||||
|
||||
For a future owner-approved cutover, use the root-owned checkout and run
|
||||
`infra/deploy/deploy_release.py` as root with the downloaded manifest, its
|
||||
matching verified cold backup, and `infra/deploy/release-policy.json`, plus
|
||||
owner-verified non-empty Russian and Kyrgyz canary queries, for example
|
||||
`--canary-ru '<known Russian query>' --canary-ky '<known Kyrgyz query>'`.
|
||||
The helper checks both queries against the public Search API through local
|
||||
HTTPS after Compose reports healthy, and requires at least one result with a
|
||||
document code before recording the new release state. Select queries only
|
||||
after corpus/index canaries have been verified; do not use generic terms that
|
||||
can match unrelated test data. The helper requires the backup image references and image IDs to match the live
|
||||
stack, serializes deploy and rollback with `/run/akyldash-deploy.lock`,
|
||||
atomically switches both protected env image refs, and always runs Compose with
|
||||
`--no-build`. It records recovery state before changing references. If ref
|
||||
replacement reports an error after an atomic rename, recovery rereads the
|
||||
actual file and restores the prior pair before restarting; an unexpected or
|
||||
unverifiable pair stops recovery and leaves a secret-free journal. A failed
|
||||
cutover refuses a subsequent release until an owner resolves the journal.
|
||||
Rollback accepts a digest-pinned previous release
|
||||
only when that backup matches the separately recorded previous refs and image
|
||||
IDs. Legacy tag rollback remains supported. Neither the helper nor registry
|
||||
secrets are installed on the VPS by this PR; deployment remains an owner-only
|
||||
operation and must wait for host/data/backup review. The exact sanitized
|
||||
evidence required from the owner is listed in
|
||||
[`DEPLOYMENT_OWNER_PACKET.md`](../../docs/operations/DEPLOYMENT_OWNER_PACKET.md).
|
||||
+10
-7
@@ -4,12 +4,10 @@
|
||||
|
||||
- [Дизайн-система «Түндүк»](../DESIGN.md) — утверждённые палитра,
|
||||
типографика, компоненты и правила адаптивности портала и лаборатории.
|
||||
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||
правила работы с данными.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [Функциональность платформы](product/platform-functionality.md) — единая
|
||||
спецификация возможностей, границы публичного MVP и условия выпуска.
|
||||
- [План реализации и приёмки](product/implementation-roadmap.md) — реестр
|
||||
UI-аудита, технические критерии приёмки и порядок подготовки deployment.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
@@ -30,6 +28,11 @@
|
||||
состояние production и оставшиеся проверки.
|
||||
- [Инфраструктура и эксплуатация](operations/INFRASTRUCTURE.md) — схема,
|
||||
доступы, deployment, backup, rollback и recovery.
|
||||
- [Операционные процедуры](operations/DEPLOYMENT.md) — связанные инструкции по
|
||||
deployment, [backup](operations/BACKUP.md), [rollback](operations/ROLLBACK.md),
|
||||
[recovery](operations/RECOVERY.md), обязательная процедура
|
||||
[передачи корпуса](operations/CORPUS_HANDOFF.md) и
|
||||
[пакет владельца](operations/DEPLOYMENT_OWNER_PACKET.md).
|
||||
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
|
||||
роли и этапы развития командного окружения.
|
||||
|
||||
@@ -45,4 +48,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
|
||||
@@ -0,0 +1,177 @@
|
||||
# Delivery First: проверка изменений по риску
|
||||
|
||||
Эта политика помогает быстро принимать небольшие изменения и добавляет
|
||||
проверки там, где ошибка затрагивает пользователей, данные или границы
|
||||
безопасности. Класс риска выбирает глубину проверки; он не позволяет снять
|
||||
обязательную проверку, одобрение или защиту `main`.
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. До реализации укажи в PR `LOW`, `MEDIUM` или `HIGH` и коротко объясни
|
||||
причину. Если изменение пересекает несколько областей, выбери самый высокий
|
||||
класс. При неопределённости используй более высокий класс.
|
||||
2. Сначала проследи путь изменения от пользовательского/API входа до данных,
|
||||
внешних систем и отката. Для изменения поведения добавь регрессионный тест,
|
||||
который подтверждает конкретный контракт или прежний дефект.
|
||||
3. Запусти проверку именно затронутого поведения и обычный Gitea CI. Для UI
|
||||
дополнительно выполни `cd tools/frontend-visual-review && npm ci && npm test`;
|
||||
этот браузерный набор сейчас не входит в Gitea CI.
|
||||
4. В PR перечисли класс риска, команды и результаты, остаточные ограничения,
|
||||
а для high-risk — совместимость, угрозы и безопасный rollback. Не заявляй
|
||||
проверку, которой фактически не было.
|
||||
5. Ревьюер отделяет блокирующие дефекты от улучшений. Блокер должен указывать
|
||||
конкретное место, условие воспроизведения и последствие. Стилистические
|
||||
предпочтения и предложения на будущее фиксируются отдельно и не задерживают
|
||||
PR.
|
||||
|
||||
## Классы риска
|
||||
|
||||
| Класс | Когда применять | Требуемое подтверждение |
|
||||
|---|---|---|
|
||||
| `LOW` | Документация, комментарии, переименование без изменения поведения, форматирование без изменения конфигурационной семантики. Нет изменения контракта, доступа, данных или исполнения. | Проверить diff и сверить утверждения/команды с исходным кодом и действующей процедурой. Выполнить обычный CI. Если документация меняет операторский шаг, проверить его в безопасном локальном или synthetic окружении. |
|
||||
| `MEDIUM` | Ограниченное изменение поведения одного компонента, внутреннего API, UI или локального скрипта без изменения модели доверия и формата хранимых данных. | Целевые тесты затронутого компонента и регрессионная проверка, если меняется поведение; обычный CI. Для UI приложить результаты браузерного набора и synthetic визуальной проверки, если они относятся к изменённому экрану/потоку. Проверить обратную совместимость публичных запросов и ответов. |
|
||||
| `HIGH` | Авторизация и роли; secrets и credential flow; ingestion и происхождение данных; формат/миграция хранилища; backup/restore/recovery; production config; runner, CI и supply chain; безопасность; сетевые/host-границы; deploy и rollback. | Обычный CI плюс целевые проверки каждого затронутого trust/data boundary; regression-тест; проверка негативного пути и совместимости; независимое ревью конкретных угроз и последствий. Для операций с данными использовать synthetic/staging данные и план отката. Изменение production допускается только владельцем после отдельного согласования, проверки backup и готового rollback. |
|
||||
|
||||
Нельзя понижать класс из-за малого diff, срочности или простоты реализации:
|
||||
маленькое изменение правила доступа или восстановления всё равно `HIGH`.
|
||||
Нельзя считать отсутствие найденных тестов доказательством отсутствия риска.
|
||||
|
||||
## CI и ограничения Gitea
|
||||
|
||||
Проверенная конфигурация проекта:
|
||||
|
||||
- Репозиторий `admin/akyldash`, Gitea API вернул `1.25.5` 2026-10-24.
|
||||
- Workflow `.gitea/workflows/ci.yaml` запускается на `pull_request` и `push` в
|
||||
`main`; job ID, который следует сохранять для совместимости статусов, —
|
||||
`tests-and-images`.
|
||||
Он последовательно выполняет backend, Telegram, backup/restore, shell guards,
|
||||
deployment tests, Compose/Caddy validation и две production image builds.
|
||||
- `.gitea/workflows/runner-smoke.yaml` сохраняет отдельный job ID `runner-smoke`
|
||||
и проверяет runtime-изоляцию job-контейнера: отсутствие системного и
|
||||
rootless Docker socket, production env/secrets и host checkout. Smoke дополняет
|
||||
статические проверки runner/CI и не дублирует их.
|
||||
- Инфраструктурная конфигурация репозитория закрепляет официальный
|
||||
`act_runner 0.2.13`, label `akyldash-ci`, capacity 1 и ограничения job 2 CPU,
|
||||
3 GiB RAM, 512 процессов, 15 GiB store. Версия runner и лимиты приведены по
|
||||
IaC; в рамках этой задачи VPS не проверялся. Job-контейнеры не должны получать
|
||||
ни один Docker socket.
|
||||
- Текущая политика `main` из branch API: ветка защищена и требует одного
|
||||
одобрения. На момент проверки API также сообщил `enable_status_check=false`
|
||||
и пустой список status contexts. Это PR не меняет branch protection.
|
||||
Если владелец отдельно включит обязательные статусы, сначала проверьте их
|
||||
точные контексты: `CI / tests-and-images (pull_request)` и
|
||||
`Runner smoke test / runner-smoke (pull_request)`. Не переименовывайте job IDs.
|
||||
|
||||
Функции и границы текущих проверок:
|
||||
|
||||
| Блок workflow | Назначение и риск пропуска | Перекрытие |
|
||||
|---|---|---|
|
||||
| Checkout и Python setup | Подготовка повторяемой среды; пропуск может дать ложный успех на неподходящем образе. | Инфраструктурный шаг, не покрывается тестами приложения. |
|
||||
| Backend и Telegram tests | Регрессии API, логики поиска/аккаунтов и Telegram bot. Риск продуктовый; для auth/data изменений — `HIGH`. | Разные пакеты и контракты; отдельная ветка тестов. |
|
||||
| Backup/restore tests | Целостность backup, восстановление и отказные сценарии; риск потери данных — `HIGH`. | Не дублируется тестами backend. |
|
||||
| Bootstrap/recovery/verifier/host-audit/production-config guards | Отказ от root и защиты host identity, аргументов и production paths; обход может дать административное действие или затронуть host — `HIGH`. | Несколько guard-тестов проверяют разные скрипты и условия; считать их дублями без анализа каждого контракта нельзя. |
|
||||
| Runner config, installer guards и CI container isolation | Ограничения runner, repeatability установки и запрет socket/privileged/host mounts; риск — доступ к host/production — `HIGH`. | Статические/синтетические проверки дополняются реальным runtime smoke, это разные уровни. |
|
||||
| Shell syntax и Python AST parse | Раннее обнаружение ошибок парсинга; риск пропуска — поломка запуска скрипта/проверки. | Это синтаксис, а не функциональные тесты. |
|
||||
| Deployment contract/rollback tests, Compose config и Caddy validation | Совместимость deploy contract/rollback и конфигурации production stack/proxy; риск — deploy outage/data impact — `HIGH`. | Compose проверяет интерпретацию конфигурации, Caddy — синтаксис proxy, Python suite — contract/rollback logic. Функции отличаются. |
|
||||
| Две Kaniko image builds | Проверка Dockerfile и воспроизводимости backend/OpenSearch production images; риск — сломанная сборка/небезопасный путь supply chain — `HIGH`. | Два Dockerfile — две разные целевые images; build-only, оба `--no-push`. |
|
||||
| Runner smoke (отдельный workflow) | Runtime-проверка известного системного socket path, production paths/secrets и host checkout отсутствия в job; риск — нарушение изоляции — `HIGH`. | Дополняет статические runner/CI guards; не повторяет их. |
|
||||
|
||||
Gitea API не предоставляет пригодный список steps для завершённого run в этой
|
||||
версии; длительность каждой строки таблицы поэтому `N/A`. Не распределяй общий
|
||||
CI runtime между блоками пропорционально числу тестов или строк кода.
|
||||
|
||||
Gitea 1.25 документация подтверждает используемые события `push`,
|
||||
`pull_request` и `workflow_dispatch`. Она также документирует ограничения,
|
||||
которые запрещают строить безопасное ускорение на привычных GitHub Actions
|
||||
предположениях:
|
||||
|
||||
- `runs-on` поддерживает статическую метку или список меток; текущая
|
||||
`akyldash-ci` — статическая метка.
|
||||
- Выражения доступны ограниченно; в сравнении с GitHub Actions Gitea 1.25
|
||||
обещает функцию `always()`, поэтому workflow не должен зависеть от
|
||||
`if:`-выражений для динамического выбора набора тестов по diff или уровню
|
||||
риска.
|
||||
- `jobs.<job_id>.timeout-minutes`, `permissions`, `concurrency` и
|
||||
`jobs.<job_id>.environment` в Gitea 1.25 игнорируются. Не используйте их как
|
||||
защиту, срок исполнения, обязательный approval или средство экономии.
|
||||
- У runner capacity 1. Разделение текущей последовательной работы на
|
||||
параллельные jobs не даёт измеримого параллельного исполнения на этом runner
|
||||
и может добавить запуск/очередь. Сохраняйте проверку сборки обеих production
|
||||
images: она подтверждает Dockerfiles, но использует Kaniko с `--no-push` и не
|
||||
публикует образы.
|
||||
|
||||
Источники семантики: [Gitea 1.25 Actions comparison](https://docs.gitea.com/1.25/usage/actions/comparison/),
|
||||
[Gitea 1.25 Actions FAQ](https://docs.gitea.com/1.25/usage/actions/faq/) и
|
||||
[act_runner guide](https://docs.gitea.com/1.25/usage/actions/act-runner/).
|
||||
Перед использованием новой workflow-семантики сверяй её с документацией
|
||||
установленной версии и проверяй фактическим run. Не добавляй проверки самой
|
||||
этой политики или статические тесты, утверждающие её текст.
|
||||
|
||||
## Временные ориентиры и фактические измерения
|
||||
|
||||
Ориентиры — цель для поиска затрат, а не разрешение убрать тесты:
|
||||
|
||||
- Обычный полный CI: до 5 минут от старта job до завершения. При превышении
|
||||
сначала измерить и оптимизировать узкий участок, сохраняя тесты и изоляцию.
|
||||
- Расширенный путь для `HIGH` (обычный CI плюс целевые проверки): целевой
|
||||
суммарный интервал 10–15 минут. Это пока ориентир, а не отдельный
|
||||
автоматический workflow или установленный timeout.
|
||||
|
||||
Срез 2026-10-24 получен из документированного [Gitea Actions task API](https://docs.gitea.com/api/1.25/operations/list-action-tasks/)
|
||||
`/repos/admin/akyldash/actions/tasks?limit=50`. Взяты 20 последних завершённых
|
||||
`tests-and-images`; длительность оценена как `updated_at - run_started_at`:
|
||||
|
||||
| Метрика | Измерение |
|
||||
|---|---:|
|
||||
| CI completed sample | 20 прогонов, 20 success |
|
||||
| CI median | 147.5 s (2:27.5) |
|
||||
| CI p90 | 172 s (2:52) |
|
||||
| CI max | 192 s (3:12) |
|
||||
| Runner smoke median / max | 2 s / 3 s, последние 20 прогонов |
|
||||
|
||||
Прежней reconciliation или оценок длительности в доступной документации
|
||||
репозитория, workflows, PR и issues не найдено; этот срез является первой
|
||||
подтверждённой базой, и сравнить его с прежней оценкой нельзя.
|
||||
Все 20 измеренных полных CI прогонов ниже пяти минут; текущая конфигурация уже
|
||||
достигает обычного ориентира. Отдельного extended workflow нет, поэтому
|
||||
10–15-минутная цель пока не имеет фактической базы. В выборке нет breakdown по
|
||||
steps: документированный endpoint для jobs завершённого run возвратил пустой
|
||||
список; это известное ограничение Gitea 1.25.5 ([upstream issue #38286](https://github.com/go-gitea/gitea/issues/38286)).
|
||||
Не распределяй время между тестами и image builds по предположению. При будущих
|
||||
изменениях workflow записывай доступные timestamps и точный размер выборки;
|
||||
после изменения Gitea/runner перепроверь, стал ли доступен надёжный step timing.
|
||||
|
||||
## Метрики потока поставки
|
||||
|
||||
Для каждого релиза/месячного обзора отдельно собирать:
|
||||
|
||||
| Метрика | Определение и источник |
|
||||
|---|---|
|
||||
| Ready → merge | Время с момента, когда PR реально готов к ревью, до merge. Не подменять датой создания draft PR; при отсутствии точного ready timestamp записывать `N/A`. |
|
||||
| Merge → deploy | Merge timestamp до фактического production deploy timestamp. Deploy пока не автоматизирован; пока нет подтверждённого deploy evidence, записывать `N/A`, не ноль. |
|
||||
| CI duration | `run_started_at` → task completion (`updated_at`) по Actions API; отдельно указывать медиану, p90, максимум, период и число прогонов. |
|
||||
| Повторные прогоны | Число неуспешных/повторных прогонов на commit до первого успеха; отличать rerun от нового commit/push. |
|
||||
| Блокирующие замечания | Число ревью-находок, удовлетворяющих критериям blockers выше; неблокирующие считать отдельно. |
|
||||
| Вышедшие регрессии | Подтверждённые после merge дефекты, связанные с изменением; фиксировать компонент, тяжесть и пропущенный сигнал. |
|
||||
| Rollback | Количество фактически выполненных rollback, причина и результат проверки данных/сервиса. Не считать написанный план фактическим rollback. |
|
||||
|
||||
На момент этого аудита PR/API не дают надёжного ready timestamp для старых
|
||||
изменений; production deployment не автоматизирован, а задачи этого PR не
|
||||
содержат deploy telemetry. Поэтому ready→merge, merge→deploy, escaped
|
||||
regressions и rollback baseline остаются `N/A`, пока не появится проверяемая
|
||||
запись. Не придумывай значения из `created_at` или даты последнего update.
|
||||
|
||||
## Критерий блокирующего ревью
|
||||
|
||||
Блокирующие замечания ограничены случаями:
|
||||
|
||||
- воспроизводимый дефект с конкретным условием и последствием;
|
||||
- расширение доступа, утечка секрета или обход границы runner/production;
|
||||
- потеря/искажение данных или несовместимость API/storage без безопасного пути;
|
||||
- обязательный тест или подтверждение из таблицы риска отсутствует/не проходит;
|
||||
- опасная операция не имеет проверяемого backup/rollback плана.
|
||||
|
||||
Каждый блокер содержит файл/участок, шаги воспроизведения или точное условие,
|
||||
последствие и минимальный безопасный fix. Остальные замечания — отдельный
|
||||
неблокирующий список. Review и approval остаются за людьми; агент не начинает
|
||||
review сам и не сливает PR.
|
||||
+67
-118
@@ -2,130 +2,79 @@
|
||||
|
||||
## Current status
|
||||
|
||||
No production data or backup existed during the 2026-10-11 audit. The repository
|
||||
now contains `infra/backup/archive.py`; it is not installed or run on the VPS,
|
||||
and `/var/backups/akyldash` remains a planned root-only location.
|
||||
The owner requires that a complete production archive never be written to the
|
||||
VPS, even temporarily. The reviewed implementation in PR #59 streams stopped
|
||||
Compose state directly from the root receiver to the owner's Windows client;
|
||||
the client stores it only under `D:\Dev\akyldash\.backups`. The VPS retains
|
||||
only a secret-free identity (backup ID, size, archive and manifest hashes,
|
||||
manifest) in root-owned release state. The Windows rerun passed 12 synthetic
|
||||
client checks, 8 protocol checks, and a disposable real-NTFS private owner-ACL
|
||||
fixture. The production D: path, SSH connection, VPS installation, and
|
||||
full production backup remain unverified. There is no verified production
|
||||
backup, so production deployment remains blocked.
|
||||
|
||||
## Required release policy
|
||||
## External backup flow
|
||||
|
||||
- Create a backup before every production change, including any future schema
|
||||
or index migration.
|
||||
- Include the normalized corpus, `search-users.sqlite3`,
|
||||
`search-reviews.sqlite3`, the OpenSearch index/snapshot, deployment digest,
|
||||
Caddy's certificate data, and required secret file in the protected backup
|
||||
set. Store secrets and Caddy private keys with equivalent access controls.
|
||||
- Verify archive readability, checksums, and SQLite integrity before relying
|
||||
on it. Never overwrite or delete the only usable backup.
|
||||
- Keep one current pre-release backup. If deployment fails, retain the backup
|
||||
required to restore the previous state.
|
||||
- Test restore into an isolated target before relying on the backup. Do not
|
||||
restore over production as a test.
|
||||
- The release receiver takes the shared deployment lock and stops the stack.
|
||||
- It streams a tar/gzip archive through a bounded binary SSH session. It does
|
||||
not create a local archive or temporary full-size copy on the VPS.
|
||||
- The Windows receiver requires the exact D: repository, a real NTFS path,
|
||||
restrictive ACLs, and the exact `/.backups/` rule in that checkout's local
|
||||
`.git/info/exclude`. Before SSH connects, it also requires at least 100 GiB
|
||||
plus 1 MiB of free D: space, matching the protocol's maximum archive size.
|
||||
The ACL owner and every allowed full-control entry must be the current user,
|
||||
SYSTEM, or local Administrators; other and deny entries fail closed.
|
||||
It writes into a private staging directory, checks the
|
||||
stream size and SHA-256, verifies the archive and manifest, then publishes
|
||||
the complete backup directory without overwriting an existing ID. It checks
|
||||
the published ACL before acknowledging; on failure it removes only the same
|
||||
verified staging directory, and refuses cleanup if its identity or contents
|
||||
have changed.
|
||||
- Only after Windows confirms the stored archive identity does the VPS proceed
|
||||
with image cutover. If backup transport or verification fails, it attempts
|
||||
to restart the previous services and records a recovery journal if restart
|
||||
fails.
|
||||
- Rollback first challenges Windows with the root-pinned previous backup
|
||||
identity. Windows verifies the local archive and streams it back; the VPS
|
||||
compares its exact size and SHA-256 before stopping services. Rollback then
|
||||
creates a fresh external backup of the current state before changing image
|
||||
references. It restores images only, never application data or OpenSearch
|
||||
state.
|
||||
|
||||
## Clean first deployment
|
||||
Windows PowerShell is the intended client. The Windows checks exercised the
|
||||
destination and ACL rules, including a disposable real-NTFS private owner-ACL
|
||||
fixture. The actual production D: destination and SSH path remain unverified.
|
||||
An owner must separately test archive restore on an isolated target before
|
||||
relying on this path. Do not restore over production as a test.
|
||||
|
||||
No backup is needed only when Compose has no containers and all four
|
||||
persistent mounts are confirmed empty. For a first install with a preloaded
|
||||
corpus and not-yet-created named volumes, use `--initial-deployment` to create
|
||||
the pre-start backup. This flag is accepted only with no Compose containers and
|
||||
when `DATA_ROOT` contains exactly `minjust-normalized/`; it archives absent
|
||||
named volumes as empty components. In every normal backup, an absent volume is
|
||||
an error because it could mean that a previous installation lost data.
|
||||
Missing mount definitions, inaccessible Docker state, or partial service
|
||||
containers are also errors and must stop deployment. Do not create placeholder
|
||||
containers to make backup pass.
|
||||
## Backup contents and limits
|
||||
|
||||
For the corpus-only initial case, run this before the first `up` from the
|
||||
approved checkout. Replace the timestamp with one real UTC timestamp:
|
||||
The existing `infra/backup/archive.py` manifest records the Git revision,
|
||||
deployment-file hashes, image references and IDs, and the components included
|
||||
in the archive: application data, OpenSearch, Caddy data/configuration,
|
||||
`production.env`, and the review secret. Secret-bearing files and SQLite files
|
||||
are archived with restrictive modes. The archive verifier rejects unsafe
|
||||
members and checks SQLite integrity. SSH encrypts archive bytes in transit.
|
||||
The destination contains secrets and private keys; D: encryption at rest (for
|
||||
example, BitLocker) has not been verified. This PR adds no client-side
|
||||
encryption or signing, so the owner must verify storage encryption and retain
|
||||
the restrictive ACLs before using it.
|
||||
|
||||
```bash
|
||||
cd /opt/akyldash/current
|
||||
sudo python3 infra/backup/archive.py create \
|
||||
--compose-file deploy/production/compose.yaml \
|
||||
--env-file /etc/akyldash/production.env \
|
||||
--initial-deployment \
|
||||
--output /var/backups/akyldash/akyldash-initial-corpus-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
sudo python3 infra/backup/archive.py verify /var/backups/akyldash/akyldash-initial-corpus-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
```
|
||||
The external protocol caps control frames at 64 KiB, chunks at 1 MiB, and the
|
||||
archive at 100 GiB. SSH provides transport encryption and server host-key
|
||||
verification. The receiver uses a fixed SSH command and does not accept a
|
||||
caller-supplied path or shell command. It relies on the existing owner SSH
|
||||
configuration; no key, host entry, sudo rule, or VPS setting is added here.
|
||||
|
||||
## Cold backup procedure
|
||||
## Restore
|
||||
|
||||
The script requires every Compose container to be stopped. Schedule downtime,
|
||||
then run from the approved checkout on the VPS:
|
||||
Use `infra/backup/archive.py restore` only with an archive already present in a
|
||||
protected owner-controlled location, and target a new staging directory on an
|
||||
isolated recovery host. Review [recovery](RECOVERY.md) and
|
||||
[deployment](DEPLOYMENT.md) first. This PR does not perform or authorize a
|
||||
production restore, data migration, or VPS operation. Keep the previous
|
||||
external archive until the owner has verified the replacement and explicitly
|
||||
approved its retention period.
|
||||
|
||||
```bash
|
||||
cd /opt/akyldash/current
|
||||
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml stop
|
||||
sudo python3 infra/backup/archive.py create \
|
||||
--compose-file deploy/production/compose.yaml \
|
||||
--env-file /etc/akyldash/production.env \
|
||||
--output /var/backups/akyldash/akyldash-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
sudo python3 infra/backup/archive.py verify /var/backups/akyldash/akyldash-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
```
|
||||
|
||||
Replace the timestamp placeholder with one real UTC timestamp before running.
|
||||
On a clean first deployment, `create` exits with a clear message that backup
|
||||
can be skipped. With existing data and all expected volumes present, it
|
||||
archives the storage even when no Compose containers exist. A preloaded corpus
|
||||
with no named volumes requires the explicit `--initial-deployment` mode;
|
||||
otherwise missing volumes are rejected. Partial service containers or running
|
||||
services are rejected as unsafe. The create command archives the application data,
|
||||
OpenSearch volume, Caddy data/config volumes, production environment file, and
|
||||
review secret. It records the checked-out Git revision, deployment-file hashes,
|
||||
and image references/IDs for each stopped Compose service, writes a SHA-256
|
||||
sidecar, and refuses to overwrite an existing archive. It also runs SQLite
|
||||
`quick_check`. Start the stack again only after verification succeeds:
|
||||
|
||||
```bash
|
||||
cd /opt/akyldash/current
|
||||
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml up -d
|
||||
```
|
||||
|
||||
## Initial post-setup baseline
|
||||
|
||||
After the first index has been loaded and validated, the initial `admin` account
|
||||
has been created, and `/review` responds over HTTPS, create a second backup.
|
||||
This one captures the initialized index, account database, and newly created
|
||||
Caddy/OpenSearch volumes. Stop all services, create and verify the archive,
|
||||
then start the stack again:
|
||||
|
||||
```bash
|
||||
cd /opt/akyldash/current
|
||||
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml stop
|
||||
sudo python3 infra/backup/archive.py create \
|
||||
--compose-file deploy/production/compose.yaml \
|
||||
--env-file /etc/akyldash/production.env \
|
||||
--output /var/backups/akyldash/akyldash-initial-baseline-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
sudo python3 infra/backup/archive.py verify /var/backups/akyldash/akyldash-initial-baseline-YYYYMMDDTHHMMSSZ.tar.gz
|
||||
sudo docker compose --env-file /etc/akyldash/production.env -f deploy/production/compose.yaml up -d
|
||||
```
|
||||
|
||||
Use one real UTC timestamp in both archive paths. Do not treat initial
|
||||
production setup as complete until this baseline archive verifies. Keep the
|
||||
pre-start corpus archive as well until the baseline is verified and copied to
|
||||
separately managed encrypted storage.
|
||||
|
||||
Keep the local archive root-only, then copy it to separately managed encrypted
|
||||
storage. A local archive alone does not protect against loss of the VPS disk.
|
||||
Restore extracts only to a new `akyldash-restore-*` directory under `/tmp` or
|
||||
`/var/tmp` and validates the checksum and SQLite databases. Applying the staged
|
||||
contents requires the separate recovery-target tool; it refuses the inventoried
|
||||
production host, existing data/volumes, and an unmarked host. Example isolated
|
||||
restore:
|
||||
|
||||
```bash
|
||||
sudo python3 infra/backup/archive.py restore /var/backups/akyldash/backup.tar.gz \
|
||||
--target-dir /var/tmp/akyldash-restore-test
|
||||
```
|
||||
|
||||
This validates and stages the archive. On the replacement host only, after
|
||||
running bootstrap with `--recovery-target` and checking out the matching Git
|
||||
revision, apply the staged data:
|
||||
|
||||
```bash
|
||||
sudo python3 infra/backup/apply_restore.py \
|
||||
--staging-dir /var/tmp/akyldash-restore-test
|
||||
```
|
||||
|
||||
The apply command restores data and named volumes but leaves services stopped.
|
||||
Test the full procedure on a separate recovery host before relying on it. Do
|
||||
not run an unreviewed restore over existing data.
|
||||
The former `/var/backups/akyldash` create/verify procedure is superseded and
|
||||
must not be used for a full production archive on the VPS.
|
||||
@@ -59,10 +59,11 @@ Dockerfiles are built in a separate pinned Kaniko container action with explicit
|
||||
job container receives no Docker socket. Kaniko uses `--no-push` and
|
||||
`--no-push-cache`, so CI validates the full backend and OpenSearch builds but
|
||||
does not publish images or export build output. The two builds run sequentially
|
||||
within one job. CI run 69 completed both builds on the runner. Actual per-action
|
||||
within one job. CI run 69 completed both builds historically; the latest
|
||||
verified main CI run 175 and smoke run 176 succeeded on `b5adc092`. Actual per-action
|
||||
resource-limit enforcement and peak memory or disk usage were not independently
|
||||
measured or verified. No registry credentials,
|
||||
production secrets, elevated container options, or arbitrary host mounts are configured.
|
||||
measured or verified. No registry credentials, production service secrets,
|
||||
elevated container options, or arbitrary host mounts are configured in PR CI.
|
||||
|
||||
The Kaniko action uses the `linux/amd64` v1.28.5-debug manifest
|
||||
`sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412`,
|
||||
@@ -70,7 +71,41 @@ listed in the [official GitHub Packages entry](https://github.com/osscontainerto
|
||||
The pinned Caddy image digest remains the same as the former `docker run`
|
||||
validation. act_runner starts these container actions; they do not expose its
|
||||
rootless Docker socket to workflow steps.
|
||||
Production deployment is not automated.
|
||||
Production deployment is not enabled. `release-images.yaml` now starts only
|
||||
after a successful `CI` workflow run for a push to `main`; it checks the source
|
||||
workflow, event, branch, conclusion, and SHA and checks that the SHA is still
|
||||
the current `main` before exposing Registry credentials. It checks out that
|
||||
exact source SHA and uses the release workflow's own run ID for image tags and
|
||||
the schema-v3 descriptor. PR CI remains no-push and receives no Registry or
|
||||
production credentials.
|
||||
|
||||
Image publication still requires the existing `REGISTRY_USERNAME` and
|
||||
`REGISTRY_PASSWORD` Actions secrets, which are not configured or verified.
|
||||
When configured, they must be package-write-only for the two approved image
|
||||
names. The release descriptor is unsigned. The SHA-256 is included in the
|
||||
artifact name so a later workflow can identify the expected digest from
|
||||
artifact metadata without treating job output as trusted input. The existing
|
||||
SHA-256 sidecar remains inside the artifact. The workflow has not yet run with
|
||||
Registry credentials, and the sequential Kaniko build's peak resources have not
|
||||
been measured beyond the configured 2 CPU, 3 GiB RAM, 512-process job limits.
|
||||
|
||||
`deploy-handoff.yaml` runs after a successful release on `main`. It rechecks the
|
||||
source run and stale-SHA guard. Its current mode is explicitly dry-run: it may
|
||||
read JSON artifact metadata using the installed `gitea-api` helper when present,
|
||||
but it never downloads the ZIP or opens an SSH connection. If the helper is not
|
||||
present in the job image, it reports that artifact metadata and host handoff
|
||||
remain unverified. No SSH, sudo, production secret, or Docker socket is passed
|
||||
to the runner.
|
||||
|
||||
Gitea 1.25.5 documents a run-artifact metadata endpoint and a ZIP endpoint that
|
||||
redirects to a blob URL. The currently audited `gitea-api` helper supports JSON
|
||||
GET requests but has no binary output option and does not follow redirects, so
|
||||
it cannot yet fetch and independently verify the host-side artifact. Do not
|
||||
enable a live handoff until an owner-approved binary-safe retrieval capability
|
||||
validates HTTPS redirects and never forwards the API token across origins. The
|
||||
host must independently validate the successful `main` release run, artifact
|
||||
name and ZIP sidecar before any guarded deployment. The owner must also verify
|
||||
artifact and Registry retention covers the rollback window.
|
||||
|
||||
## Installation and registration
|
||||
|
||||
@@ -160,10 +195,11 @@ that token; do not put it in command-line arguments or environment variables.
|
||||
present in the job container.
|
||||
3. Confirm the run reports the expected runner, succeeds, and the runner
|
||||
service and health timer remain active.
|
||||
4. Inspect the `CI` workflow run and confirm Caddy plus both Kaniko actions pass
|
||||
on `akyldash-ci`. The Kaniko jobs are intentionally build-only: publishing
|
||||
images requires a separately reviewed registry workflow and credentials.
|
||||
Do not add production deploy workflows or secrets to this runner.
|
||||
4. Inspect the `CI` workflow run and confirm Caddy plus both no-push Kaniko
|
||||
actions pass on `akyldash-ci`. Image publication is a distinct manual
|
||||
main-only workflow with package-only credentials; it does not deploy.
|
||||
Never add production service secrets, Docker socket access, or host mounts
|
||||
to this runner.
|
||||
|
||||
## Upgrade
|
||||
|
||||
|
||||
@@ -168,15 +168,42 @@ backed up, resolve the raw code `6` finding, and approve the exact normalized
|
||||
corpus to use. Transfer only `minjust-normalized/`; keep the raw source archive
|
||||
offline. Do not preserve the source's Windows/WSL file permission bits. Copy
|
||||
to a new staging location with owner-approved permissions, verify the copied
|
||||
manifest and files there, and only then consider placing it under `DATA_ROOT`.
|
||||
Never overwrite an existing corpus or OpenSearch volume.
|
||||
manifest and files there, and only then consider publishing it under
|
||||
`DATA_ROOT/minjust-normalized`.
|
||||
|
||||
The current bulk importer checks individual Bulk API item results, but it can
|
||||
switch an alias after a zero-fragment input and does not enforce a refresh,
|
||||
count comparison, or representative-query check before that switch. Until a
|
||||
separate change adds those gates, do not pass `--alias` during production
|
||||
loading. Load into a new versioned index only after the corpus transfer is
|
||||
verified; require a nonzero fragment count, successful item results, an
|
||||
explicit refresh, a matching OpenSearch document count, and representative
|
||||
Russian/Kyrgyz queries before an owner performs an alias change. Take and
|
||||
verify the required backup before any production index or service change.
|
||||
### Atomic publication gate for `DATA_ROOT`
|
||||
|
||||
The verified staging tree must be published to
|
||||
`DATA_ROOT/minjust-normalized` as one all-or-nothing operation, using an
|
||||
owner-approved atomic mechanism or a separately documented equivalent. An
|
||||
in-place copy into the live target is not an acceptable publication method.
|
||||
Preserve the current published corpus until the candidate is fully verified and
|
||||
ready for the atomic switch; never overwrite its contents in place.
|
||||
The publication mechanism and its interrupted-operation recovery have not yet
|
||||
been specified or tested; this is a release gate, not a claim that atomic corpus
|
||||
publication is implemented. Until that gate is completed, do not publish or
|
||||
replace a corpus in `DATA_ROOT`.
|
||||
|
||||
If preparation is interrupted before publication, keep the current target
|
||||
untouched. Resume only from the isolated staging tree after a full rehash and
|
||||
source comparison; if that cannot be established, fail closed and prepare a
|
||||
new staging candidate. If interruption makes the outcome of the final
|
||||
publication ambiguous, do not retry or replace anything automatically. Verify
|
||||
the target against the approved manifest and resolve the state with the owner
|
||||
before any retry. Never overwrite an existing corpus or OpenSearch volume.
|
||||
|
||||
The atomic filesystem publication of the approved corpus into `DATA_ROOT` and
|
||||
the OpenSearch alias switch are separate gates. A successful corpus publish
|
||||
does not authorize indexing or alias changes; do not conflate the two.
|
||||
|
||||
The bulk importer now refuses alias publication until all Bulk item results
|
||||
are successful, the full staged index is refreshed, its fragment count exactly
|
||||
matches the complete normalized inventory (including on resume), and approved
|
||||
Russian and Kyrgyz canaries both return text. The alias must currently target
|
||||
the exact `--expected-old-index`; wildcard removals and implicit first-time
|
||||
alias creation are rejected. OpenSearch performs removal of that exact target
|
||||
and addition of the candidate in one alias request. A mismatch or validation
|
||||
failure leaves the existing alias untouched. This is a code-level gate only:
|
||||
no production index or corpus was changed, and the live inventory and canary
|
||||
terms still require owner verification. See `backend/README.md` for the
|
||||
operator flags and use a new versioned index for every candidate.
|
||||
@@ -1,10 +1,193 @@
|
||||
# Infrastructure current state
|
||||
|
||||
Read-only host recheck: 2026-10-18. Source: SSH as `codex`, local repository
|
||||
inspection, and Gitea API. Root-only Docker state, UFW rule details, and
|
||||
protected file metadata remain unverified.
|
||||
## Windows ACL SID resolution — 2026-11-03
|
||||
|
||||
## VPS facts
|
||||
- Read-only PowerShell 5.1 inspection found that display-name conversion of
|
||||
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
|
||||
is readable. The installer and external-receiver guards now enumerate access
|
||||
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
|
||||
principals remain visible to the existing fail-closed policy. Regression
|
||||
coverage exercises synthetic unmapped SIDs through the actual installer guard
|
||||
functions. These repository changes are pending review; no Windows ACL,
|
||||
installer, task, VPS, or production state was changed. The native PowerShell
|
||||
harness still requires execution on Windows before this behavior is confirmed
|
||||
in that runtime.
|
||||
|
||||
## External deployment runtime binding — 2026-11-03
|
||||
|
||||
- PR #59 is merged to `main`; it contains the root-only read-only status
|
||||
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
|
||||
status path bounds the Gitea run list to ten entries, verifies the selected
|
||||
run/workflow/current `main`,
|
||||
validates artifact metadata and ZIP bytes, and returns only a release tuple.
|
||||
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
|
||||
compares protected Compose/Caddy bytes and installed release policy with
|
||||
Gitea content pinned to the release SHA, verifying each Git blob hash and
|
||||
size. This permits application-only release B after bootstrap A without
|
||||
advancing the root checkout or executing new root code. A runtime config or
|
||||
policy change fails closed and requires a reviewed bootstrap update. The
|
||||
Windows response state is `prepared`, never `candidate`; the `--once` task
|
||||
stores a protected local tuple and cannot call deploy. A separate disabled
|
||||
auto-apply task and config gate require the tuple to match a fresh status
|
||||
response from a previous poll. Status and apply retain current-main,
|
||||
successful CI/release provenance, descriptor digest, external backup,
|
||||
cutover, canary, and rollback checks. The receiver repeats the config check
|
||||
before journaling/stopping services and again after backup immediately before
|
||||
switching refs.
|
||||
- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию
|
||||
выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias —
|
||||
`akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи
|
||||
запускаются от интерактивного пользователя с минимальными правами, не допускают
|
||||
параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command,
|
||||
helper и trigger не устанавливались и не запускались. Путь canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и
|
||||
enabled-состояние не читались. Метаданные Windows подтверждают
|
||||
`C:\Python313\python.exe`, действительную Authenticode-подпись Python Software
|
||||
Foundation и защищённые ACL интерпретатора.
|
||||
- Linux fixtures прошли для Python import и command-path poisoning, включая очистку
|
||||
унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`.
|
||||
Installer получает ограниченный bundle из точной вершины `main`: упаковщик
|
||||
читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет
|
||||
SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до
|
||||
записи и не запускает Git. ACL проверяются явными масками мутаций и подмены;
|
||||
текущая учётная запись не может владеть кодовыми путями, а право создать
|
||||
соседнюю папку не считается подменой предка. Windows ACL/install проверки пока
|
||||
только прошли код-ревью; installer и launcher на Windows не запускались.
|
||||
Production CD остаётся выключенным.
|
||||
- Remaining owner prerequisites are installation of the reviewed root helper,
|
||||
exact forced-command/SSH host-key setup, a protected Windows client and its
|
||||
private state/config ACLs, private D: backup ACLs, and all previously listed
|
||||
backup/restore and production readiness gates. Neither the status endpoint nor client advances
|
||||
the host checkout or updates root helper code.
|
||||
|
||||
- Read-only Gitea check for fresh `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
|
||||
run #708 failed at the explicit registry credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
|
||||
skipped. No secret values were read. Image publication remains owner-blocked.
|
||||
|
||||
Owner decision recorded 2026-10-26: a complete backup must not be stored on
|
||||
the VPS, including temporarily. The local archive/full cold-backup commands in
|
||||
`BACKUP.md` are superseded and unauthorized. PR #59 now contains a proposed
|
||||
Windows-controlled external stream receiver and root-side deploy/rollback
|
||||
code. The Windows rerun passed 12 synthetic client checks and 8 protocol
|
||||
checks, plus a disposable real-NTFS fixture for the private owner ACL. The
|
||||
production D: path, SSH connection, VPS installation, full backup, and
|
||||
separate-target restore remain unverified. No verified external backup or
|
||||
separate-target restore exists, so production deployment remains blocked by
|
||||
the backup gate.
|
||||
|
||||
## Code-only deployment work — 2026-10-31
|
||||
|
||||
- Work is being prepared in the existing PR #59 branch from the then-current
|
||||
`main`; no merge, VPS operation, live release, or workflow dispatch was
|
||||
performed for this work.
|
||||
- The release descriptor now binds both the release run and its successful CI
|
||||
source run. Host-side source verification uses the installed `gitea-api`
|
||||
helper contract; the PR carries the inspected helper fixture plus a bounded
|
||||
binary-download patch. The installer verifies the fixture hash before
|
||||
applying the patch.
|
||||
- Backup bytes are streamed through a bounded SSH protocol to the fixed
|
||||
Windows destination. Root release state stores only a secret-free backup
|
||||
identity. The rollback handshake requires the exact previously pinned
|
||||
archive and creates a fresh external backup before image references change.
|
||||
Local VPS archive paths fail closed.
|
||||
- Review fixes in this PR require Gitea `status=completed` with
|
||||
`conclusion=success`, resolve `main` from `/opt/akyldash/current`, write a
|
||||
durable pending journal before stopping services, and reject an already
|
||||
applied release before downtime. The Windows client checks D: capacity,
|
||||
NTFS/path/ACL owner and allowed entries, and the local ignore rule before
|
||||
SSH; it requires 100 GiB plus 1 MiB free. The Windows rerun passed 12
|
||||
synthetic client checks, 8 protocol checks, and a disposable real-NTFS
|
||||
private owner-ACL fixture; actual D:, SSH, and VPS remain unverified.
|
||||
- Receiver cleanup after a post-publication ACL failure is identity-bound to
|
||||
the owned staging directory and permits only the expected archive files. A
|
||||
replaced, reparse, or unexpected target is left for manual review without an
|
||||
ACK; this path is covered by local fixtures and the Windows rerun.
|
||||
- Synthetic tests cover archive/protocol integrity, helper download rules,
|
||||
artifact/run provenance, receiver publication, release schema, and rollback
|
||||
refusal. The 12 synthetic Windows client checks, 8 protocol checks, and
|
||||
disposable real-NTFS private owner-ACL fixture passed. The actual D: path,
|
||||
SSH alias, Gitea helper against
|
||||
the live instance, root helper installation, artifact redirect, production
|
||||
backup/restore, and actual release/rollback remain unverified.
|
||||
- Owner bootstrap blockers remain: verify a root-controlled SSH route for the
|
||||
fixed commands using existing host-key checking; provision only the
|
||||
necessary read-only Gitea token, exact redirect origin if needed, and
|
||||
approved RU/KY canaries; confirm registry pull and retention policy; test
|
||||
the Windows client and a separate-target restore. No new key, sudo rule,
|
||||
Docker access, credential, or VPS setting is added by the code change.
|
||||
|
||||
Read-only deployment recheck: 2026-10-25. Source: SSH as `codex`, direct-origin
|
||||
and public HTTPS probes, local `main` checkout, and Gitea API. No VPS files,
|
||||
services, firewall, containers, volumes, production data, or credentials were
|
||||
changed or inspected. Root-only Docker state, UFW rule details, protected file
|
||||
metadata, current backup/restore validity, and the live corpus/index remain
|
||||
unverified. The 2026-10-18 inventory below is a historical snapshot where it
|
||||
conflicts with this dated recheck.
|
||||
|
||||
## Latest read-only recheck — 2026-10-25
|
||||
|
||||
- `https://akyldash.kg/review` returned HTTP 200 with valid TLS through the
|
||||
public Cloudflare edge and through direct HTTPS to origin `206.223.249.118`.
|
||||
This verifies that route only; it does not prove search or index readiness.
|
||||
- TCP listeners now include 22, 80, and 443. The older no-listener/Cloudflare
|
||||
521 observations below are historical, not current state.
|
||||
- VPS reports 4 CPUs, 7.7 GiB RAM (about 3.7 GiB available), and 31 GiB free
|
||||
on `/`. Current runner-mount free space was not rechecked.
|
||||
- `/opt/akyldash/current` exists, is a root-owned directory mode `0755`.
|
||||
Reading its Git SHA failed with Git's `dubious ownership` safety guard; no
|
||||
`safe.directory` setting was added, so the installed revision is unknown.
|
||||
- `akyldash-act-runner.service` is active under `akyldash-runner`; installed
|
||||
`act_runner` is `0.2.13`. The separate `akyldash-runner-restart.service`
|
||||
remains failed since 2026-10-14; its cause could not be read from the
|
||||
unprivileged journal view. No restart was attempted.
|
||||
- `akyldash-verify`, `akyldash-compose`, and `akyldash-rollback` are absent
|
||||
from PATH in the unprivileged check; no root helper or Docker operation was
|
||||
run.
|
||||
- Gitea is `1.25.5`; `main` is `b5adc09203cbbbd298054248206b9f47ae4a44ca`.
|
||||
CI run 175 and runner smoke 176 succeeded on that commit. Package write
|
||||
access and runner inventory remain outside this API principal's permissions.
|
||||
- PR #59 head `852d639c455ff1c60202405e112d47b8128d66c6` received formal
|
||||
review #308 with five requested fixes. CI run 177 failed in
|
||||
`test_digest_rollback_fails_before_stopping_or_changing_references` because
|
||||
the test's global `Path.stat` mock made `Path.exists()` report a false
|
||||
recovery-journal hit under CI Python 3.12; runner smoke 178 succeeded. A
|
||||
fix batch in this PR isolates state paths, handles ambiguous alias/cutover
|
||||
outcomes, verifies rollback image IDs, and adds a synthetic artifact upload
|
||||
check. On 2026-10-25 CI run 179 and runner smoke 180 succeeded at PR head
|
||||
`9d8ce6b6`; CI took 2m43s and the synthetic upload step reported success.
|
||||
The run API identifies run ID 567 and reports `run_attempt: 0`, Gitea's
|
||||
legacy sentinel. The release workflow therefore uses the unique run ID and
|
||||
does not depend on the unavailable attempt value. The run's artifact link was
|
||||
downloaded from Gitea UI as `artifact-smoke-567-`: the ZIP was 191 bytes,
|
||||
SHA-256 `ae0a2262147f222aacb4fdd1d12459f7160c67b1d0ee5d7ae2db1de8958f120f`,
|
||||
and contained the expected 51-byte `marker.txt`. The API artifact listing
|
||||
available to this principal returned no entries, but the downloaded file
|
||||
verifies artifact availability. No live release or VPS change was made.
|
||||
- The release descriptor SHA-256 sidecar now records `release-manifest.json`
|
||||
relative to the `release` directory, matching the flattened layout observed
|
||||
for downloaded Gitea artifacts. A local synthetic regression verifies the
|
||||
checksum from the artifact extraction root; the credentialed release
|
||||
workflow remains unrun.
|
||||
- Owner-only outstanding evidence: sanitized container/volume and protected
|
||||
file metadata; current backup and separate-target restore verification;
|
||||
live corpus/index counts and RU/KY canaries; Cloudflare TLS mode; registry
|
||||
writer identity and digest retention. Never include secret values in that
|
||||
evidence packet. The checklist is in
|
||||
[`DEPLOYMENT_OWNER_PACKET.md`](DEPLOYMENT_OWNER_PACKET.md).
|
||||
|
||||
The deployment PR adds a manual main-only image-publish workflow and an
|
||||
owner-run `--no-build` cutover path, but neither has been executed against the
|
||||
VPS. Its release descriptor is an unsigned trace record; cryptographic image
|
||||
attestation remains a separate owner decision.
|
||||
|
||||
## Historical host snapshot — 2026-10-18
|
||||
|
||||
Source: SSH as `codex`, local repository inspection, and Gitea API. Root-only
|
||||
Docker state, UFW rule details, and protected file metadata were not verified.
|
||||
|
||||
## Historical VPS facts — 2026-10-18
|
||||
|
||||
- Host: `avprogs.hlab.kz`, `206.223.249.118`, SSH port `22`, user `codex`.
|
||||
- OS: Ubuntu Server `24.04.5 LTS`; kernel `6.8.0-142-generic` from the prior
|
||||
@@ -42,13 +225,11 @@ protected file metadata remain unverified.
|
||||
and `git.vakanaut.ru` resolves to `195.209.48.28`. From the VPS,
|
||||
`https://git.vakanaut.ru/api/v1/version` returned Gitea `1.25.5`; runner
|
||||
registration and Gitea Actions protocol connectivity are not confirmed.
|
||||
- `akyldash.kg` resolves to Cloudflare IPv4/IPv6 edge addresses. From the VPS,
|
||||
HTTPS returned Cloudflare `521` with TLS verification result `0` on
|
||||
2026-10-18. TCP listeners were SSH `22` and local DNS only, so no origin
|
||||
listener on `80`/`443` was observed. Origin TLS and Cloudflare Full (strict)
|
||||
are not verified.
|
||||
- `akyldash.kg` resolved to Cloudflare IPv4/IPv6 edge addresses. HTTPS returned
|
||||
Cloudflare `521` on this historical 2026-10-18 check; current recheck is
|
||||
recorded above. Cloudflare Full (strict) mode remains unverified.
|
||||
|
||||
## Application and Gitea facts
|
||||
## Historical application and Gitea facts — 2026-10-18
|
||||
|
||||
- Repository: `admin/akyldash`, default branch `main`; Gitea version `1.25.5`.
|
||||
- The repository API reports push permission, Actions and packages enabled, and
|
||||
@@ -87,23 +268,46 @@ protected file metadata remain unverified.
|
||||
The mode has not been run on a VPS.
|
||||
- The repository defines a root-owned read-only verifier, Compose `config` and
|
||||
`status` helper, guarded rollback helper, and installer with exact sudoers
|
||||
command rules. None is installed on the VPS. CI contains no production
|
||||
deployment job; starting/stopping services remains owner-run.
|
||||
- Rollback preflight validates digest-pinned image references against archived
|
||||
image IDs and local availability, then refuses before stopping services or
|
||||
creating a backup because the current flow cannot safely switch immutable
|
||||
digest references. This behavior is covered by synthetic local tests; it has
|
||||
not been exercised on the VPS.
|
||||
- This branch adds an offline release-manifest validator and tests for the
|
||||
repository allowlist intended for a future root-owned host integration. The
|
||||
manifest format does not prove source provenance. No registry, host policy,
|
||||
publish credential, or deployment integration is configured or installed.
|
||||
command rules. None was installed on the VPS at this inventory. The current
|
||||
PR adds fixed root-side release/rollback receivers without adding a `codex`
|
||||
sudoers rule or Docker access. Neither receiver has been installed or run.
|
||||
- The former local-VPS rollback path has been superseded and now fails closed.
|
||||
The PR rollback path requires a root-pinned external archive identity,
|
||||
rechecks its exact bytes through the owner client, creates a fresh external
|
||||
backup before changing refs, and preserves a secret-free recovery journal on
|
||||
failure. Synthetic tests only; no production rollback was performed.
|
||||
- The image release workflow now starts after successful CI for a push to
|
||||
`main`. It checks the source workflow/event/branch/conclusion and exact source
|
||||
SHA, checks out that SHA, and uses both release and source run IDs in the
|
||||
schema-v4 descriptor. Registry secrets `REGISTRY_USERNAME` and
|
||||
`REGISTRY_PASSWORD` remain unconfigured/unverified; no values were created or
|
||||
inspected. PR image builds remain no-push. The descriptor SHA-256 is included
|
||||
in the artifact name, and the artifact also retains its checksum sidecar.
|
||||
The descriptor remains unsigned trace metadata, not cryptographic
|
||||
attestation.
|
||||
- A separate `deploy-handoff.yaml` runs after a successful release and checks
|
||||
the source run and stale-main guards. Its job is explicitly
|
||||
`NOT DEPLOYED - handoff status only`; a green result is not a deployment. It
|
||||
reads artifact metadata through `gitea-api` only if that helper is available
|
||||
in the job image; otherwise it reports metadata and host handoff as
|
||||
unverified. It does not download artifact ZIPs, connect to SSH, or apply a
|
||||
release. No repository workflow binding to a Windows runtime starts the
|
||||
receiver. PR #59 now proposes a separate disabled local `--once` trigger
|
||||
that queries a root-verified tuple; it is not installed or active. Image
|
||||
release is automated, but production deployment is not yet automatic CD. The original
|
||||
audited `gitea-api` helper lacks binary output and safe 302 handling; the
|
||||
current PR carries a tested patch and host-side artifact verifier, but
|
||||
neither is installed. No deploy key, fixed SSH entry, extra sudo rule, or
|
||||
Gitea CD variable has been configured. Production apply remains blocked on
|
||||
owner bootstrap, external backup and separate-target restore, and the other
|
||||
gates above.
|
||||
- A repository-scoped official `act_runner` `0.2.13` installation is
|
||||
configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB
|
||||
ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU,
|
||||
3 GiB RAM, 512 processes), account slice limits (2.5 CPU, 4 GiB RAM, 2048
|
||||
tasks), rotated file logs, and a systemd health timer. Jobs target the
|
||||
`akyldash-ci` label and do not receive either Docker socket. On 2026-10-18,
|
||||
`akyldash-ci` label and do not receive either Docker socket. Latest verified
|
||||
main CI run 175 and smoke run 176 succeeded on `b5adc092`. On 2026-10-18,
|
||||
SSH showed `akyldash-act-runner.service` active under `akyldash-runner`, with
|
||||
zero restarts and about 115 MiB current memory. Its rootless Docker store is
|
||||
mounted from `/dev/loop0` as ext4 with `rw,nosuid,nodev`; the 15 GiB mount had
|
||||
|
||||
+219
-108
@@ -1,126 +1,237 @@
|
||||
# Deployment
|
||||
|
||||
## Current status
|
||||
## Status and authorization
|
||||
|
||||
Production deployment is not enabled or tested on the VPS. The owner completed
|
||||
the root bootstrap: Docker/Compose are active and target directories exist.
|
||||
The `codex` account cannot access the Docker socket. No root-owned checkout or
|
||||
restricted helpers are installed; protected env/secret metadata and Docker
|
||||
container/volume state remain unverified because they require root.
|
||||
The unprivileged audit sees no listener on `80`, `443`, or `1500`; Cloudflare
|
||||
still returns `521`. Do not deploy until the owner has reviewed root-only host
|
||||
state, supplied protected configuration and the approved corpus/index, and
|
||||
backup/restore have been tested.
|
||||
The release and fixed-receiver code from PR #59 is on `main`, but has not been
|
||||
installed or exercised against the VPS. This PR's compatibility and Windows
|
||||
bootstrap changes are still proposed. No release, backup, restore, or
|
||||
production service operation has been performed by this change. Production
|
||||
deployment remains blocked until the owner has verified host state, registry
|
||||
access, protected configuration,
|
||||
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
|
||||
separate target. See [current state](CURRENT_STATE.md) and the
|
||||
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
|
||||
|
||||
## First host preparation
|
||||
The existing `codex` account has no Docker socket access and receives no new
|
||||
privilege in this change. The external receiver requires an owner-controlled
|
||||
SSH entry that runs the fixed root command; no SSH key, `authorized_keys`
|
||||
entry, sudo rule, or root alias is created here. If `akyldash_vps` logs in as
|
||||
`codex`, it cannot run the root-only receiver and the client fails closed.
|
||||
Do not grant general sudo or Docker access to make the flow work.
|
||||
|
||||
1. The initial root bootstrap is complete. After reviewing and merging the
|
||||
infrastructure PR, the owner must install that approved `main` checkout at
|
||||
`/opt/akyldash/current` (the path was absent during the unprivileged audit):
|
||||
## Release verification and image publication
|
||||
|
||||
```bash
|
||||
sudo git clone --branch main --single-branch https://git.vakanaut.ru/admin/akyldash.git /opt/akyldash/current
|
||||
```
|
||||
`.gitea/workflows/ci.yaml` remains the required test and image-build workflow.
|
||||
The release workflow runs after successful main CI, verifies the source run and
|
||||
commit, and publishes digest-pinned images plus a small release descriptor.
|
||||
The descriptor records both the release workflow run ID and the successful CI
|
||||
source run ID. PR builds do not publish images. Production runtime secrets are
|
||||
not supplied to CI.
|
||||
|
||||
The checkout is root-owned by default. Then run
|
||||
`sudo bash /opt/akyldash/current/infra/bootstrap/audit-host.sh`. This
|
||||
read-only audit reports Docker state, protected-file metadata, UFW rules,
|
||||
listeners, and ISPmanager without printing secret contents. Resolve any
|
||||
existing containers, volumes, or data before proceeding; do not overwrite
|
||||
or delete them.
|
||||
2. After confirming the existing env and secret do not exist and reviewing the
|
||||
tracked defaults, run
|
||||
`sudo bash /opt/akyldash/current/infra/deploy/prepare-production-config.sh`.
|
||||
It creates `/etc/akyldash/production.env` from the tracked example and
|
||||
generates `/etc/akyldash/review_secret`; both are root-owned mode `0600`.
|
||||
It refuses to overwrite either file and does not start services or create
|
||||
volumes. Review `DATA_ROOT` and follow the
|
||||
[corpus inventory and handoff gates](CORPUS_HANDOFF.md) before placing the
|
||||
approved normalized corpus there.
|
||||
3. After reviewing the root-owned checkout and audit output, run
|
||||
`sudo bash /opt/akyldash/current/infra/deploy/install-helper.sh`. It installs
|
||||
the backup reader, read-only verifier, `akyldash-compose config|status`, and
|
||||
rollback helper with exact sudoers command rules. The Compose helper has no
|
||||
start/stop/build operation and grants no direct Docker access. Actual
|
||||
deployments remain owner-run through the documented Compose commands.
|
||||
4. Confirm DNS points through Cloudflare to this origin. The owner reports UFW
|
||||
already allows TCP `80` and `443`; read and document the full rules before
|
||||
any firewall decision. TCP `1500` is allowed by owner report but has no
|
||||
observed listener and no ISPmanager package/service was found; the owner
|
||||
must identify its purpose. Do not change UFW as part of this procedure. The
|
||||
Compose Caddy service obtains and renews HTTPS certificates automatically;
|
||||
Cloudflare Full (strict) still needs end-to-end verification.
|
||||
5. For a clean first deployment, skip backup only when Compose confirms there
|
||||
are no containers and all persistent mounts are empty. If any data exists,
|
||||
create and verify a backup before starting a new version. For a preloaded
|
||||
corpus with absent named volumes, use `--initial-deployment`; it is limited
|
||||
to a container-free host whose `DATA_ROOT` contains only
|
||||
`minjust-normalized/`. Normal backups reject absent volumes. An uncertain
|
||||
or partial state stops deployment. Then start services, load and validate
|
||||
the OpenSearch index, create the initial admin interactively, verify
|
||||
`/review` over HTTPS, and create the required post-setup baseline backup
|
||||
described in `BACKUP.md`. Record the backend image ID for drift checks:
|
||||
Read-only Gitea check on 2026-11-03 for current `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
|
||||
run #708 failed at the explicit registry-credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
|
||||
#709 was skipped. No secret values were read. Image publication therefore
|
||||
remains blocked until the owner provisions the required registry secrets.
|
||||
|
||||
```bash
|
||||
sudo sh -c 'docker compose --env-file /etc/akyldash/production.env -f /opt/akyldash/current/deploy/production/compose.yaml ps --quiet backend | xargs -r docker inspect --format "{{.Image}}" > /opt/akyldash/deployed-image.txt'
|
||||
sudo chmod 0600 /opt/akyldash/deployed-image.txt
|
||||
```
|
||||
On the host, the fixed root receiver uses the installed `gitea-api` helper to
|
||||
re-fetch and verify the exact successful CI and release runs, current `main`,
|
||||
artifact name, checksum, manifest schema, builder pin, image policy, and commit
|
||||
binding. Its binary `DOWNLOAD` extension accepts HTTPS and at most one
|
||||
allowlisted HTTPS redirect, does not forward the token across origins, bounds
|
||||
the response, and refuses to overwrite an existing file. The installer checks
|
||||
the exact audited helper fixture hash before applying its patch. The helper
|
||||
and source code are not installed on the VPS by this PR.
|
||||
|
||||
Application deployment remains owner-run. The repository-scoped `act_runner`
|
||||
`0.2.13` service is active under `akyldash-runner` and uses rootless Docker on a
|
||||
separate loop-backed filesystem. CI run 75 and smoke run 76 succeeded on main
|
||||
head `0e276ae6`. Runner job containers have neither Docker socket nor production
|
||||
secrets. CI has no image publishing or production deployment step. The
|
||||
repository defines sudo helpers for fixed verification, read-only Compose
|
||||
`config`/`status`, and guarded rollback, but none is installed on the VPS. They
|
||||
do not start or stop services.
|
||||
## One-shot runtime binding and bootstrap compatibility
|
||||
|
||||
## Release image contract (preparation only)
|
||||
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
The dispatcher accepts only the literal status or deploy command in
|
||||
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
|
||||
evaluation. Status verifies successful current-main provenance, artifact
|
||||
metadata/ZIP, and host runtime compatibility before returning only a run ID,
|
||||
commit, and descriptor digest in a `prepared` response. It fails closed on a
|
||||
recovery journal, incomplete Gitea run listing, invalid release data, changed
|
||||
host runtime configuration, or an already applied release. The root receiver
|
||||
independently repeats release verification and the existing backup/ACK/cutover
|
||||
guards.
|
||||
|
||||
`infra/deploy/validate_release.py` checks an offline JSON release manifest
|
||||
against an explicitly supplied repository allowlist. Schema version 1 requires
|
||||
exactly `schema_version`, `source_ref`, `commit`, and `images`; the source ref
|
||||
must be `refs/heads/main`, the commit must be a 40-character lowercase Git
|
||||
SHA-1, and the images must contain exactly `backend` and `opensearch` as
|
||||
`repository@sha256:<64 lowercase hex>` references. The policy has schema
|
||||
version 1 and one non-empty repository allowlist for each service. Unknown or
|
||||
duplicate JSON keys are rejected.
|
||||
The Windows `--once` mode only polls the fixed read-only status endpoint and
|
||||
stores a protected local `PREPARED` tuple. It never sends a deploy request.
|
||||
Automatic deployment uses a second task and a separate
|
||||
`release-auto-apply.json` gate; both config files and both scheduler XML
|
||||
templates default to disabled. The apply task acts only when its current
|
||||
verified tuple exactly matches a tuple saved by a previous poll. An owner can
|
||||
also use `--apply-prepared`, which requires an interactive exact-SHA
|
||||
confirmation. Both deploy paths re-enter the existing root receiver and all
|
||||
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
|
||||
командой. Планировщик запускает задачи от интерактивного владельца с минимальными
|
||||
правами, пропускает повторный запуск при активном экземпляре и не будит компьютер.
|
||||
Этот PR не зарегистрировал и не включил ни одну задачу.
|
||||
|
||||
The validator takes the manifest path and an explicit policy path:
|
||||
Сначала подготовьте bundle под обычной учётной записью из точной локальной
|
||||
вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает
|
||||
только Git objects указанного полного commit SHA, сверяет Git blob identity
|
||||
исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не
|
||||
копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256.
|
||||
Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива,
|
||||
manifest и Git blob IDs в памяти, затем копирует только проверенные байты.
|
||||
Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`.
|
||||
На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout
|
||||
не используются. Не изменяйте ZIP между подготовкой и установкой.
|
||||
Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer
|
||||
проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка
|
||||
упаковщика и фактической функции mode-валидации на Windows используйте
|
||||
`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`;
|
||||
`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический
|
||||
Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и
|
||||
проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает
|
||||
installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику
|
||||
выполнения PowerShell менять для него не следует.
|
||||
|
||||
```bash
|
||||
python3 infra/deploy/validate_release.py release.json --policy /etc/akyldash/release-policy.json
|
||||
```
|
||||
Проверенный bootstrap installer проверяет подпись PSF у
|
||||
`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в
|
||||
SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию.
|
||||
Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем
|
||||
могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись
|
||||
задачи допускается владельцем только для отдельного private state/config.
|
||||
Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись,
|
||||
добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для
|
||||
родительских каталогов проверяются права, способные подменить защищённый путь;
|
||||
разрешение создать посторонний соседний каталог подменой не считается.
|
||||
Installer сохраняет существующий canonical-файл
|
||||
`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй
|
||||
disabled auto-apply config создаётся только при отсутствии. Installer формирует
|
||||
два disabled task XML для проверки владельцем, но не регистрирует и не включает
|
||||
задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только
|
||||
стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение
|
||||
импортированных модулей. До импорта receiver он заменяет унаследованный command
|
||||
lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и
|
||||
проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути.
|
||||
Для самих бинарников и их родительских каталогов проверяются reparse points,
|
||||
владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths
|
||||
выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба
|
||||
config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore
|
||||
и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner.
|
||||
|
||||
The policy shape is `{"schema_version":1,"repositories":{"backend":["registry.invalid/akyldash/backend"],"opensearch":["registry.invalid/akyldash/opensearch"]}}`.
|
||||
These `registry.invalid` names are format examples only; this change does not
|
||||
create a policy file or approve a registry.
|
||||
The root status/deploy helper remains the reviewed root-installed bootstrap
|
||||
code; it never checks out or executes target release code. A newer app release
|
||||
is eligible without manually advancing `/opt/akyldash/current` only when the
|
||||
host's Compose file, Caddyfile, and installed release policy match the exact
|
||||
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
|
||||
the Git blob SHA and size are verified. Status checks before preparing the
|
||||
tuple; the receiver repeats the check before creating a journal or stopping
|
||||
services, and cutover checks again after the verified external backup and
|
||||
immediately before switching image references. A Compose/Caddy mismatch
|
||||
returns `runtime_config_changed`; a policy mismatch returns
|
||||
`release_policy_changed` and requires a reviewed owner bootstrap update. No
|
||||
host checkout or root helper is automatically changed. The release must still
|
||||
be current `main`; CI/release provenance, digest, backup, canary, and rollback
|
||||
checks remain required. Gitea's filtered workflow-run listing is bounded to
|
||||
ten entries; if Gitea reports more entries or an inconsistent page, status
|
||||
returns unavailable rather than guessing.
|
||||
|
||||
The repository name in the policy must be approved for the registry. No real
|
||||
registry namespace or root-owned host policy is configured by this change; do
|
||||
not use the synthetic `registry.invalid` names in tests as production values.
|
||||
The host integration must load policy only from its fixed root-owned path.
|
||||
This validator checks format and allowlist only: a manifest can claim a main
|
||||
ref and commit, but is not proof of source, build provenance, signature, or
|
||||
trusted publication. CI still has no publish credentials, image publication,
|
||||
host helper, or deploy trigger.
|
||||
## Automation boundary: deployment is not active
|
||||
|
||||
Compose merges an image override with the existing service `build` entries.
|
||||
The regression test renders fake digest references and confirms both entries
|
||||
remain; any future deploy command must use `docker compose up --no-build` so
|
||||
the host cannot silently build a different image. This does not make digest
|
||||
rollback available: the current rollback helper expects local tags. A separate
|
||||
host-side deploy/rollback design, owner-approved registry permissions and
|
||||
retention, and manual backup/health/rollback end-to-end validation are still
|
||||
required before publishing or enabling CD.
|
||||
Until the owner separately activates both Windows tasks and the auto-apply
|
||||
configuration after the backup/restore and production gates, the chain ends at
|
||||
release publication. After that explicit one-time owner authorization, the
|
||||
prepared-state poll and separate apply task can process later matching
|
||||
application releases without advancing `/opt/akyldash/current` or approving
|
||||
each commit individually. A release that changes protected runtime files still
|
||||
requires a reviewed bootstrap update.
|
||||
|
||||
## CI
|
||||
1. Successful `main` CI triggers image builds and publishes digest references
|
||||
plus the release descriptor artifact.
|
||||
2. `deploy-handoff.yaml` verifies the source run and may read artifact metadata.
|
||||
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
|
||||
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
|
||||
receiver. A green result means only that this status check completed.
|
||||
3. The fixed receiver is physically started only by the separately enabled
|
||||
owner-controlled auto-apply task or by an interactive owner command. The
|
||||
`--once` preparation task stores a verified tuple and never sends deploy;
|
||||
the separate apply task requires the tuple to match a fresh status response.
|
||||
No repository workflow invokes either task. Both scheduler templates and
|
||||
both configuration gates remain disabled, and neither task has been
|
||||
installed or exercised.
|
||||
|
||||
`.gitea/workflows/ci.yaml` runs the Python unit suites and builds both
|
||||
production images on pull requests and pushes to `main`. It does not publish an
|
||||
image or deploy production. `.gitea/workflows/runner-smoke.yaml` exercises the
|
||||
`akyldash-ci` runner on pull requests, pushes to `main`, or manual dispatch.
|
||||
Both workflows passed on main head `0e276ae6` in runs 75 and 76 respectively.
|
||||
The repository runner-list endpoint is not available to the current API
|
||||
principal, so its registration details cannot be independently read through
|
||||
Gitea API.
|
||||
Production CD is still not active: task/credential setup has not been verified,
|
||||
and no VPS or live D: operation has been performed. This design avoids a
|
||||
Windows Actions runner entirely; the owner-controlled local task is not exposed
|
||||
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
|
||||
commands and verified host key. Do not use an unrestricted `authorized_keys`
|
||||
entry or a persistent host-mode runner with D: and SSH access.
|
||||
|
||||
## External backup and cutover
|
||||
|
||||
The fixed root release receiver accepts only a bounded protocol request over
|
||||
stdin. It does not accept a remote shell command, artifact path, or image
|
||||
reference from the caller. It rechecks the release and current `main`, takes a
|
||||
shared lock, captures running image IDs, and stops the stack. It streams the
|
||||
cold archive directly to the owner's Windows client at
|
||||
`D:\Dev\akyldash\.backups`; the VPS does not hold a full archive, including
|
||||
temporarily. The Windows client requires a real NTFS destination with private
|
||||
ACLs and a precise local Git exclude rule. It publishes only after hash,
|
||||
archive, and manifest verification, and acknowledges the exact live session
|
||||
nonce and backup identity.
|
||||
|
||||
Only after the acknowledgement does the host pull approved digest references,
|
||||
atomically replace both image refs, and start Compose with `--no-build`. It
|
||||
checks running image IDs, `/review`, and non-empty Russian and Kyrgyz search
|
||||
canaries. A stale main, source mismatch, invalid archive, image mismatch, or
|
||||
failed health/canary check stops cutover and attempts recovery using the
|
||||
pre-change references. A recovery journal blocks another operation if
|
||||
automatic recovery cannot be verified.
|
||||
|
||||
Rollback uses the root-pinned prior external archive and creates a fresh
|
||||
external backup of current state before switching refs. It restores images
|
||||
only, not data. Old local VPS backup paths and legacy release states without a
|
||||
pinned external backup identity are rejected. See [backup and restore](BACKUP.md)
|
||||
and [rollback](ROLLBACK.md).
|
||||
|
||||
The receiver records a durable pending recovery journal before stopping
|
||||
services. A retry of the exact release already recorded in pinned state is
|
||||
rejected before backup or downtime. Gitea source verification requires
|
||||
`status=completed` and `conclusion=success`, and resolves current `main` from
|
||||
the explicit production checkout. The Windows client checks the destination,
|
||||
ACL, ignore rule, and full protocol archive capacity before opening SSH.
|
||||
|
||||
## Owner bootstrap still required
|
||||
|
||||
After review and merge, the owner must decide whether to install the helper
|
||||
from a root-owned checkout and provision, without exposing values in logs:
|
||||
|
||||
- a read-only Gitea API token in `/etc/akyldash/gitea-api-token`, root-owned
|
||||
mode `0600`;
|
||||
- the exact artifact redirect origin allowlist in
|
||||
`/etc/akyldash/gitea-download-origins`, if the Gitea artifact endpoint
|
||||
redirects;
|
||||
- approved Russian and Kyrgyz search canaries in
|
||||
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`;
|
||||
- a root-only SSH path to `/usr/local/sbin/akyldash-release-deploy` and
|
||||
`/usr/local/sbin/akyldash-release-rollback` using the existing owner key and
|
||||
verified host key.
|
||||
- a dedicated SSH alias `akyldash_release` backed by a key restricted to
|
||||
`/usr/local/sbin/akyldash-release-dispatch`; its forced command must preserve
|
||||
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
|
||||
or arbitrary command access;
|
||||
- a protected installation of the Windows client under
|
||||
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
|
||||
XML templates reviewed. Keep the preparation task/config separate from the
|
||||
auto-apply task/config; the auto-apply pair remains disabled until the owner
|
||||
explicitly accepts unattended production deployment.
|
||||
|
||||
The installer intentionally does not create credentials, SSH entries, canary
|
||||
values, registry permissions, or a new privilege rule. Existing files with
|
||||
unexpected owner, mode, or content cause installation to stop for owner
|
||||
review. Do not run the installer or receiver from CI. The owner's first use
|
||||
must be a separate-target restore check and a reviewed dry run; this PR does
|
||||
not authorize a production cutover.
|
||||
|
||||
The Windows client uses the existing OpenSSH host-key checking. Its configured
|
||||
alias must reach a root-controlled endpoint capable of invoking the fixed
|
||||
receiver; no new key or sudo permission is requested. Windows ACL and NTFS
|
||||
checks, SSH host configuration, artifact redirect origin, registry pull
|
||||
access, and every production health gate remain unverified until the owner
|
||||
checks them.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Пакет проверок владельца перед деплоем
|
||||
|
||||
Здесь перечислены проверки, которые выполняет владелец. Этот документ не
|
||||
разрешает production-изменения. Не включайте значения токенов и паролей,
|
||||
содержимое env-файлов, приватные ключи, другие секреты или полный вывод
|
||||
переменных окружения.
|
||||
|
||||
## Что требуется до использования CD
|
||||
|
||||
- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но
|
||||
не установлен и не запускался. Bootstrap из этого PR также не установлен.
|
||||
После ревью и слияния обновите локальный `main` до проверенного полного SHA.
|
||||
Под обычным пользователем подготовьте bundle командой
|
||||
`prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit <SHA> --output <new ZIP path>`.
|
||||
Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает
|
||||
Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов
|
||||
commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь
|
||||
elevated installer:
|
||||
`install_windows_release_client.ps1 -ExpectedCommit <SHA> -BundlePath <path> -ExpectedBundleSha256 <digest>`.
|
||||
Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256,
|
||||
manifest и цепочку `commit → tree → файлы` в памяти и только после полной
|
||||
проверки копирует эти байты. Elevated installer не запускает Git и не читает
|
||||
`.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и
|
||||
установкой; сохраните SHA и полный commit для журнала владельца.
|
||||
- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые
|
||||
ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей
|
||||
версии и назначает владельцем новых файлов кода `Administrators`. Кодовые
|
||||
пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller;
|
||||
учётная запись задачи допускается владельцем только для отдельных private
|
||||
state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление,
|
||||
удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских
|
||||
каталогов выявляет права, позволяющие заменить защищённый путь; создание
|
||||
постороннего соседнего каталога само по себе не считается подменой.
|
||||
- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json`
|
||||
только если он остаётся отключённым, создаёт disabled auto-apply config лишь
|
||||
при отсутствии и формирует два disabled Task Scheduler XML для проверки.
|
||||
Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое
|
||||
и его enabled-состояние не читались.
|
||||
- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`,
|
||||
проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`,
|
||||
очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe`
|
||||
в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и
|
||||
никогда не отправляет deploy-запрос.
|
||||
- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного
|
||||
`enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён,
|
||||
только если свежий root status точно совпал с tuple, сохранённым предыдущим
|
||||
опросом. После явного согласования владельцем unattended deployment отдельное
|
||||
подтверждение каждого SHA не требуется. Включайте preparation config и задачу
|
||||
только после отдельного разрешения на опрос; auto-apply config и задачу — только
|
||||
после отдельного разрешения на unattended production deployment и выполнения
|
||||
всех backup/restore gate. Отключение любого auto-apply control останавливает
|
||||
будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть
|
||||
восстановлен по процедуре.
|
||||
- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command
|
||||
dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding),
|
||||
проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте
|
||||
unrestricted shell или команду в root `authorized_keys`. Существующий ключ и
|
||||
путь rollback остаются отдельным ручным действием владельца. PR не создаёт,
|
||||
не копирует и не устанавливает ключи.
|
||||
- Решите, допустим ли unattended production CD. Сейчас он не включён:
|
||||
`deploy-handoff.yaml` остаётся только status-проверкой и сообщает
|
||||
`NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост
|
||||
не открыт workflow и PR runner. Убедитесь, что root status command установлен
|
||||
из проверенного bootstrap и ключ forced command ограничен.
|
||||
- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает
|
||||
защищённые root Compose/Caddy файлы и установленную release policy с файлами
|
||||
точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs
|
||||
и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет
|
||||
его после backup. Несовпадение требует отдельного проверенного owner bootstrap
|
||||
update; код release или root helper автоматически не выполняется и не обновляется.
|
||||
Текущий `main` и CI/release provenance всё равно перепроверяются перед apply.
|
||||
Endpoint закрывается при неполной странице workflow-run API (более десяти
|
||||
кандидатов или несовпадение count). Перед включением проверьте поведение этого
|
||||
API на установленной версии Gitea.
|
||||
- Подтвердите имена registry packages и минимальные права записи/чтения для
|
||||
`admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не
|
||||
короче rollback window. Последняя read-only проверка Gitea: main CI run #706
|
||||
завершился успешно, image release run #708 завершился ошибкой из-за отсутствия
|
||||
repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only
|
||||
handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea;
|
||||
не помещайте значения в Git или логи.
|
||||
- Проверьте, что срок хранения Gitea artifacts покрывает rollback window.
|
||||
Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней.
|
||||
- Подготовьте датированный очищенный root audit: SHA установленного checkout,
|
||||
Compose services и image IDs, состояние health, пути и режимы volumes,
|
||||
владельца/режим защищённых файлов, сводку listeners и состояние recovery journal.
|
||||
- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд,
|
||||
используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный
|
||||
`codex` не должен получать общий sudo, членство в Docker group или Docker socket.
|
||||
PR не устанавливает SSH key или root alias.
|
||||
- Если потребуется, установите узко ограниченный read-only Gitea API token в
|
||||
`/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только
|
||||
наличие и scope токена, не его значение.
|
||||
- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только
|
||||
точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned,
|
||||
mode `0600`. Исправленный helper не должен отправлять этому origin token.
|
||||
- Добавьте одобренные владельцем RU и KY search canary queries в
|
||||
`/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что
|
||||
они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса.
|
||||
- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность
|
||||
публичных `/review` и search.
|
||||
|
||||
## Backup и восстановление
|
||||
|
||||
- Полный архив хранится только на Windows-носителе владельца
|
||||
`D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS.
|
||||
- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture
|
||||
прошли. До использования проверьте реальный путь D: и локальное правило
|
||||
`.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только
|
||||
после проверки hash и manifest.
|
||||
- Подтвердите шифрование D: при хранении. Архив содержит production secrets и
|
||||
private keys; протокол шифрует передачу, но не хранение файлов.
|
||||
- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные,
|
||||
SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production.
|
||||
- Подтвердите текущие counts данных и индекса, corpus generation и совместимость
|
||||
миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы.
|
||||
- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь
|
||||
rollback window.
|
||||
|
||||
Installer не создаёт credentials, SSH configuration, canary values, доступ к
|
||||
registry или новые sudo rules. PR не установлен и не запускался на production.
|
||||
Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную
|
||||
private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались;
|
||||
реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены.
|
||||
Не включайте deployment до фиксации владельцем результатов всех проверок выше.
|
||||
+28
-35
@@ -1,41 +1,34 @@
|
||||
# Rollback
|
||||
|
||||
## Current status
|
||||
The current rollback implementation is code only. PR #59 has not been merged,
|
||||
installed, or run against the VPS. No current production release state or
|
||||
external backup has been verified; production rollback is therefore not
|
||||
available yet.
|
||||
|
||||
There is no production release, backup, or installed rollback helper. The
|
||||
repository contains a root-owned rollback helper and a separate installer, but
|
||||
neither has been run on the VPS.
|
||||
After owner installation and separate-target restore validation, rollback is
|
||||
an owner-controlled Windows-to-VPS session. The Windows client reads the exact
|
||||
previous backup identity pinned in root-owned release state, validates the
|
||||
saved archive and sidecar, then sends it over SSH. The VPS accepts the bytes
|
||||
only when size and SHA-256 match that root-pinned identity. It verifies the
|
||||
current deployment against release state, stops Compose, and streams a fresh
|
||||
cold backup to Windows before changing either image reference.
|
||||
|
||||
After a verified cold backup is created before a release, an owner must check
|
||||
that the data format is compatible with the prior backend/OpenSearch images.
|
||||
Then run the limited helper with that archive:
|
||||
The rollback helper verifies deployment-file hashes, previous image refs and
|
||||
image IDs, and that the fresh backup matches the current image IDs. It does not
|
||||
restore application data, OpenSearch contents, or secrets. It uses the shared
|
||||
deployment lock, atomically switches both image references, starts Compose
|
||||
with `--no-build`, verifies running image IDs and the local `/review` health
|
||||
route, then records the new release state. Failures attempt to restore the
|
||||
pre-rollback references and services; unresolved recovery is recorded in a
|
||||
root-owned journal and blocks subsequent release/rollback operations.
|
||||
|
||||
```bash
|
||||
sudo -n /usr/local/sbin/akyldash-rollback \
|
||||
/var/backups/akyldash/<pre-release-backup>.tar.gz --data-compatible
|
||||
```
|
||||
The former `akyldash-rollback <VPS path>` interface now fails closed and performs
|
||||
no Docker operation. Local `/var/backups/akyldash` archives cannot authorize a
|
||||
rollback. Legacy release states without a verified external `previous_backup`
|
||||
identity also cannot authorize this rollback path.
|
||||
|
||||
The helper verifies archive integrity, Compose image references, deployment
|
||||
file hashes, and the availability of both prior local images. It makes and
|
||||
verifies a second cold backup, restores only the prior backend/OpenSearch image
|
||||
tags, waits for Compose healthchecks, and checks HTTPS locally. Digest-pinned
|
||||
references are validated against their recorded local image IDs, but rollback
|
||||
fails before stopping services or making a backup when either image reference
|
||||
uses `@sha256:`. The helper cannot safely switch immutable digest references
|
||||
with the current release-recording flow. Do not retag a digest manually; digest
|
||||
rollback needs a separately reviewed atomic release-reference mechanism.
|
||||
|
||||
It refuses to restore if the current Compose/Caddyfile differs from the saved
|
||||
release. This does not restore data or automatically undo a release with an
|
||||
unknown schema change. The application has no formal migration framework;
|
||||
compatibility must be checked for every change. If it is uncertain, stop and ask
|
||||
the owner.
|
||||
|
||||
The initial owner-run deployment must record its running backend image ID in
|
||||
`/opt/akyldash/deployed-image.txt` with mode `0600`; the rollback helper updates
|
||||
that record after a successful rollback, and `infra/bootstrap/verify.sh` checks
|
||||
it against the running container.
|
||||
|
||||
Any data restore requires the owner, a verified pre-change backup, and a
|
||||
maintenance window. Preserve the failed release and both backups until recovery
|
||||
is confirmed.
|
||||
The application has no formal migration framework. Check schema and index
|
||||
compatibility before rollback. If compatibility is uncertain, stop; do not
|
||||
attempt a data restore as part of an image rollback. A data restore requires a
|
||||
separate owner-approved recovery plan and maintenance window. See
|
||||
[backup and restore](BACKUP.md) and [deployment](DEPLOYMENT.md).
|
||||
@@ -1,128 +0,0 @@
|
||||
# Готовность к публичному frontend
|
||||
|
||||
Этот документ определяет обязательный результат до начала проектирования и
|
||||
разработки публичной пользовательской системы. Он дополняет
|
||||
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
|
||||
спринты frontend, этот — критерий перехода к ним.
|
||||
|
||||
## Правило перехода
|
||||
|
||||
Публичный frontend и его выпуск начинаются, когда выполнены все обязательные
|
||||
пункты этого плана и пройден финальный readiness gate. Внутренняя
|
||||
авторизованная поисковая страница и лаборатория могут развиваться отдельно по
|
||||
явной задаче и используют только реальные существующие API.
|
||||
|
||||
Персональные функции вне этого этапа: закладки, заметки, уведомления, RAG,
|
||||
судебная практика и внешние коммерческие сервисы. Внутренние учетные записи
|
||||
для лаборатории не включают публичную регистрацию.
|
||||
|
||||
## 1. Данные и поисковый индекс
|
||||
|
||||
### Результат
|
||||
|
||||
В локальном OpenSearch доступен воспроизводимо собранный корпус актуальных
|
||||
редакций ЦБД Минюста КР, пригодный для поиска на русском и кыргызском языках.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Подтвердить для каждого индексируемого документа наличие канонических
|
||||
реквизитов: код, редакция, язык, статус, тип, орган, дата, номер, название
|
||||
и ссылка на официальный источник.
|
||||
2. Зафиксировать правила для документов без текста и одноязычных редакций:
|
||||
они не скрываются и не получают выдуманный перевод.
|
||||
3. Проверить versioned-индекс, mapping, анализаторы RU/KY, полноту актуальных
|
||||
редакций и процедуру безопасной переиндексации с переключением alias.
|
||||
4. Описать и выполнить воспроизводимый сценарий обновления:
|
||||
выгрузка → нормализация → новый индекс → проверка → переключение alias.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
Команда может повторить обновление на чистом локальном окружении, проверить
|
||||
количество документов и фрагментов, а затем безопасно переключить поисковый
|
||||
alias без смешивания старого и нового корпуса.
|
||||
|
||||
## 2. Relevance set и качество поиска
|
||||
|
||||
### Результат
|
||||
|
||||
Есть замороженный набор `relevance set v1` из 50 практических запросов:
|
||||
минимум 25 на русском и 25 на кыргызском языках.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
|
||||
естественными формулировками пользователей и подтверждёнными
|
||||
`document_code` релевантных действующих актов.
|
||||
2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
|
||||
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
|
||||
запросы без установленного эталонного результата.
|
||||
3. Провести независимую вторую проверку языка, формулировки и полного списка
|
||||
кодов. Спорные результаты не включать до согласования.
|
||||
4. После независимой проверки сохранить неизменяемую копию набора, затем
|
||||
оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
|
||||
правила ранжирования проверять на том же наборе и сравнивать с baseline.
|
||||
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
|
||||
практическом действии. Правило принимается, только если улучшает
|
||||
подтверждённые запросы и не создаёт ложных срабатываний в негативных
|
||||
сценариях.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
Оценщик проходит на полном наборе, выводит метрики и выдачу для каждого
|
||||
запроса; baseline и результаты его повторного запуска воспроизводимы.
|
||||
|
||||
## 3. Справочники и контракт API
|
||||
|
||||
### Результат
|
||||
|
||||
Frontend получает все юридически значимые данные через версионированный
|
||||
OpenAPI-контракт, а не реконструирует их из текста фрагментов.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Утвердить справочники v1: типы документов, органы принятия, статусы,
|
||||
уровни действия и темы. Для каждого значения определить стабильный код и
|
||||
подписи RU/KY.
|
||||
2. Реализовать и описать OpenAPI для:
|
||||
- `GET /search` — запрос, язык, фильтры, сортировка, серверная пагинация,
|
||||
выдержка и подсветка;
|
||||
- `GET /search/filters` — допустимые значения фильтров;
|
||||
- `GET /documents/{code}` — карточка актуального документа;
|
||||
- `GET /documents/{code}/editions` и
|
||||
`GET /documents/{code}/editions/{edition}` — редакции и их содержимое.
|
||||
3. Зафиксировать единые ответы для пустой выдачи, неизвестного документа,
|
||||
недоступной редакции и ошибки upstream; для документов с одним языком
|
||||
вернуть доступные языки явно.
|
||||
4. Добавить контрактные и интеграционные проверки API на локальном OpenSearch:
|
||||
поиск, фильтры, пагинация, сортировка, документ, редакции и одноязычные
|
||||
акты.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
OpenAPI опубликован вместе с backend, тестовый клиент получает реальные данные
|
||||
по всем endpoint без mock-слоя, а результаты поиска открывают актуальный
|
||||
документ и выбранную редакцию.
|
||||
|
||||
## 4. Финальный readiness gate
|
||||
|
||||
Перед началом frontend выполнить и зафиксировать один сквозной сценарий:
|
||||
|
||||
1. Обновить корпус из официальной ЦБД.
|
||||
2. Нормализовать данные и собрать новый versioned-индекс.
|
||||
3. Прогнать проверки индекса и relevance baseline.
|
||||
4. Переключить alias на проверенный индекс.
|
||||
5. Выполнить API-сценарии поиска, фильтрации, просмотра документа и редакции
|
||||
на RU, KY и одноязычном документе.
|
||||
|
||||
Gate считается пройденным, если все проверки успешны, зафиксированы версии
|
||||
backend и индекса, опубликованы известные ограничения и назначен ответственный
|
||||
за юридическую проверку relevance set.
|
||||
|
||||
## Порядок issues
|
||||
|
||||
1. Собрать и независимо проверить relevance set v1.
|
||||
2. Завершить проверку полноты данных и воспроизводимую переиндексацию с alias.
|
||||
3. Утвердить справочники v1.
|
||||
4. Реализовать OpenAPI и интеграционные проверки.
|
||||
5. Провести финальный readiness gate и только затем открыть задачу на
|
||||
проектирование frontend.
|
||||
@@ -1,355 +0,0 @@
|
||||
# План точного воспроизведения поискового интерфейса
|
||||
|
||||
Дата: 23 октября 2026 года.
|
||||
|
||||
Статус: основная реализация и локальная приёмка выполнены в
|
||||
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
|
||||
навигация, плотность результатов, заголовок документа, fixture-контент,
|
||||
клавиатурный trap для диалогов и компактные позиционируемые подсказки
|
||||
лаборатории. Скриншоты, покропленные сравнения и расширенный
|
||||
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
|
||||
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
|
||||
сборка production images. Независимый review запускает владелец. Скринридер и
|
||||
реальный корпус всё ещё требуют проверки.
|
||||
|
||||
## 1. Цель, границы и источники истины
|
||||
|
||||
Привести существующую авторизованную поисковую страницу `/` к композиции
|
||||
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
|
||||
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
|
||||
|
||||
Источники:
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
|
||||
токены, типографика, доступность, постоянные правила компонентов.
|
||||
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
|
||||
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
|
||||
этому сохранённому изображению более высокого разрешения.
|
||||
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
|
||||
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
|
||||
готовности публичного продукта, которые этот план не отменяет.
|
||||
|
||||
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
|
||||
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
|
||||
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
|
||||
Временные задачи и результаты измерений остаются здесь; новые постоянные
|
||||
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
|
||||
|
||||
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
|
||||
индекса, production, регистрация, аналитика, сравнение редакций, переход
|
||||
на новый frontend framework. Содержимое демонстрационного документа
|
||||
не становится юридическими данными продукта.
|
||||
|
||||
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
|
||||
При необходимости совместного редактирования можно подготовить отдельный
|
||||
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
|
||||
это не заменяет проверку фактической вёрстки в браузере.
|
||||
|
||||
## 2. Исходное состояние до реализации
|
||||
|
||||
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
|
||||
изображениям без браузера; строки не описывают текущее состояние. Последующие
|
||||
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
|
||||
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
|
||||
состояние production или актуального OpenSearch.
|
||||
|
||||
Основные файлы:
|
||||
|
||||
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
|
||||
сценариев, footer и авторизация.
|
||||
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
|
||||
фильтров и редакций.
|
||||
- `backend/test_search_api.py` — существующие проверки API.
|
||||
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
|
||||
|
||||
| Проблема | Подтверждение в реализации | Требуемый результат |
|
||||
|---|---|---|
|
||||
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
|
||||
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
|
||||
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
|
||||
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
|
||||
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
|
||||
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
|
||||
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
|
||||
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
|
||||
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
|
||||
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
|
||||
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
|
||||
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
|
||||
|
||||
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
|
||||
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
|
||||
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
|
||||
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
|
||||
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
|
||||
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
|
||||
|
||||
## 3. Этап 0 — воспроизводимый эталон
|
||||
|
||||
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
|
||||
реализацию маршрутов и обязательные проверки; использовать установленный
|
||||
Ponytail согласно правилам репозитория.
|
||||
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
|
||||
`feature/` ветку и не переносить посторонние изменения в PR.
|
||||
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
|
||||
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
|
||||
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
|
||||
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
|
||||
три результата из изображения, выбран первый, открыта соответствующая
|
||||
демонстрационная редакция, фильтр статуса и исходная прокрутка.
|
||||
- [x] Изолировать fixture от реального режима и пометить демонстрационное
|
||||
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
|
||||
в production-путь.
|
||||
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
|
||||
пропорций и сохранить параметры подготовки эталона.
|
||||
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
|
||||
согласованные ограничения приведены в отчёте.
|
||||
|
||||
Критерий завершения: один и тот же тестовый сценарий воспроизводится
|
||||
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
|
||||
продолжает использовать API. Авторизация не ослабляется ради снимков.
|
||||
|
||||
## 4. Этап 1 — карта геометрии
|
||||
|
||||
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
|
||||
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
|
||||
около 1 CSS px.
|
||||
|
||||
| Область | Нормализованный эталон | Реализация |
|
||||
|---|---:|---:|
|
||||
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
|
||||
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
|
||||
| Footer | y ≈ 744–800 px | y = 744–800 px |
|
||||
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
|
||||
| Результаты | x ≈ 226–648 px | x = 226–648 px |
|
||||
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
|
||||
|
||||
Внутренние контрольные координаты и ограничения см. в
|
||||
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
|
||||
разница по каждой крупной области.
|
||||
|
||||
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
|
||||
документа по исходнику; фактические значения записаны в отчёте.
|
||||
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
|
||||
интервалы и разделители по всем областям.
|
||||
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
|
||||
реальные длинные данные ещё не проверены.
|
||||
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
|
||||
Не выравнивать принудительно высоту всех результатов или разделов.
|
||||
- [ ] Определить владельца прокрутки каждой области, поведение footer
|
||||
при коротком содержимом и доступность длинного документа.
|
||||
|
||||
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
|
||||
а не независимыми приблизительными настройками каждого блока.
|
||||
|
||||
## 5. Этап 2 — разделение сценариев и базовых стилей
|
||||
|
||||
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
|
||||
авторизацию, кабинет, управление пользователями, обучение и черновики.
|
||||
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
|
||||
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
|
||||
- [ ] Использовать общий слой семантических токенов и базовых элементов;
|
||||
стили поискового экрана изолировать BEM-классами.
|
||||
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
|
||||
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
|
||||
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
|
||||
static-файлов с MIME, защитой от обхода путей и сохранением защитных
|
||||
заголовков. Убедиться, что ресурсы входят в production-образ.
|
||||
|
||||
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
|
||||
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
|
||||
Не вводить Next.js, React или новый сборщик только ради этой задачи.
|
||||
|
||||
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
|
||||
их композицию случайным образом; существующие права доступа сохранены.
|
||||
|
||||
## 6. Этап 3 — шрифты, знак, орнамент и favicon
|
||||
|
||||
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
|
||||
с лицензиями; исключить случайное синтетическое начертание.
|
||||
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
|
||||
Визуальные снимки выполнять после `document.fonts.ready`.
|
||||
- [ ] Подготовить SVG-знак по визуальному направлению референса;
|
||||
сверить размер, толщину линий и положение относительно названия.
|
||||
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
|
||||
`aria-hidden`, без перекрытия управления и текста.
|
||||
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
|
||||
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
|
||||
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
|
||||
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
|
||||
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
|
||||
|
||||
Критерий завершения: шрифты реально загружены, знак и орнамент являются
|
||||
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
|
||||
|
||||
## 7. Этап 4 — сборка композиции
|
||||
|
||||
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
|
||||
|
||||
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
|
||||
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
|
||||
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
|
||||
демонстрационная подпись только для fixture. «Об источниках» открывает
|
||||
реальные сведения о происхождении данных, а не пустую заглушку.
|
||||
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
|
||||
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
|
||||
Сообщения для скринридера сохраняются.
|
||||
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
|
||||
орнамент в свободной нижней части без перекрытия длинного содержимого.
|
||||
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
|
||||
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
|
||||
безопасная подсветка выдержки, пагинация.
|
||||
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
|
||||
компактные метаданные, язык, действия, поиск внутри, поле чтения.
|
||||
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
|
||||
не копировать устаревшую backend-версию и слово «макет» из изображения.
|
||||
|
||||
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
|
||||
соответствуют эталону; нет лишних лабораторных блоков.
|
||||
|
||||
## 8. Этап 5 — рабочее поведение и интеграция
|
||||
|
||||
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
|
||||
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
|
||||
остальных; сохранение второго фильтра проверяется Playwright.
|
||||
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
|
||||
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
|
||||
значения. Не имитировать множественный выбор checkbox без поддержки API.
|
||||
Если нужен multiselect, отдельно описать и проверить расширение контракта.
|
||||
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
|
||||
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
|
||||
- [ ] Не выдавать число на странице за общее: использовать явную подпись
|
||||
либо отдельно реализовать достоверный total с проверкой его семантики.
|
||||
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
|
||||
при отсутствии структуры показать понятное состояние.
|
||||
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
|
||||
подсветка, активное совпадение, счётчик, предыдущий/следующий,
|
||||
прокрутка и состояние нулевого результата. Не повреждать HTML,
|
||||
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
|
||||
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
|
||||
прокрутки и корректного фокуса.
|
||||
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
|
||||
без поисковых и служебных панелей; служебные элементы проверены в print media.
|
||||
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
|
||||
через отмену запроса или проверку актуальности результата.
|
||||
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
|
||||
обработку ошибок и ограничения авторизации.
|
||||
|
||||
Критерий завершения: каждый видимый элемент эталона выполняет действие;
|
||||
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
|
||||
|
||||
## 9. Этап 6 — адаптивность, типографика и состояния
|
||||
|
||||
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
|
||||
Сверить переносы названия документа, результатов и плотность абзацев.
|
||||
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
|
||||
Высоты и параметры окружения записаны в отчёте.
|
||||
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
|
||||
breakpoints на реальных данных корпуса.
|
||||
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
|
||||
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
|
||||
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
|
||||
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
|
||||
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
|
||||
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
|
||||
широкое изображение синтетическими данными.
|
||||
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
|
||||
длинный контент из реального корпуса.
|
||||
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
|
||||
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
|
||||
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
|
||||
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
|
||||
диалогов, а также `prefers-reduced-motion`.
|
||||
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
|
||||
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
|
||||
|
||||
Критерий завершения: точность desktop не приводит к переполнению,
|
||||
недоступности действий или потере контекста на узком экране.
|
||||
|
||||
## 10. Этап 7 — визуальная приёмка
|
||||
|
||||
Выполнить один пакет первичных снимков всех контрольных размеров,
|
||||
собрать дефекты по областям, исправить одним пакетом и выполнить один
|
||||
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
|
||||
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
|
||||
|
||||
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
|
||||
прокрутку; снимки делаются после `document.fonts.ready`.
|
||||
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
|
||||
login, ошибку или другой маршрут.
|
||||
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
|
||||
сравнения шапки, фильтров, результатов, документа и footer.
|
||||
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
|
||||
исправлениями и известными ограничениями.
|
||||
- [ ] Передать независимому reviewer запрос пользователя, эталон,
|
||||
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
|
||||
|
||||
Критерии для детерминированного fixture при 1280×800:
|
||||
|
||||
| Проверка | Условие приёмки |
|
||||
|---|---|
|
||||
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
|
||||
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
|
||||
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
|
||||
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
|
||||
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
|
||||
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
|
||||
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
|
||||
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
|
||||
|
||||
Нулевая попиксельная разница не является критерием: сглаживание текста,
|
||||
растровая природа концепта и нормативные токены дают объяснимые различия.
|
||||
Один процент сходства всего экрана не заменяет проверки каждой области.
|
||||
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
|
||||
|
||||
## 11. Этап 8 — проверки, версия и PR
|
||||
|
||||
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
|
||||
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
|
||||
API v1 не менялся.
|
||||
|
||||
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
|
||||
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
|
||||
config/runner guards, shell syntax и Playwright.
|
||||
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
|
||||
проверка Caddyfile и сборка backend/OpenSearch production images.
|
||||
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
|
||||
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
|
||||
визуальное сравнение использует стабильный fixture.
|
||||
- [x] Повысить версии для совместимого функционального изменения;
|
||||
frontend/backend версии согласованы в footer.
|
||||
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
|
||||
изменения остаются в одном PR #53.
|
||||
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
|
||||
прежние исправления и оставляет краткий PR-комментарий с явным выводом
|
||||
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
|
||||
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
|
||||
commit/push и независимый review.
|
||||
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
|
||||
существующий changelog, записав значимые изменения для пользователя.
|
||||
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
|
||||
и независимый review окончательного состояния.
|
||||
|
||||
Готовность к merge: проверки проходят, финальный review не содержит
|
||||
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
|
||||
доступны reviewer. Не сливать автоматически и не развёртывать production.
|
||||
|
||||
## 12. Порядок выполнения и итоговые материалы
|
||||
|
||||
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
|
||||
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
|
||||
Не начинать микроподгонку кнопок до исправления общей геометрии.
|
||||
|
||||
Ожидаемые материалы реализации:
|
||||
|
||||
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
|
||||
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
|
||||
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
|
||||
- карта измерений, снимки, overlay/diff и отчёт приёмки;
|
||||
- результаты обязательных проверок и независимого review;
|
||||
- версии, changelog и один PR без автоматического merge.
|
||||
|
||||
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
|
||||
которые реально выполнены, зафиксированы в отчёте visual-review.
|
||||
Этот документ не подтверждает готовность API, публичного продукта или
|
||||
production.
|
||||
@@ -1,265 +0,0 @@
|
||||
# План реализации frontend поисковой СПС
|
||||
|
||||
Основание: `docs/Функциональные_возможности_поисковой_СПС.docx`.
|
||||
|
||||
Документ с требованиями описывает не только frontend, но и поиск, юридическую
|
||||
обработку, персональные данные, уведомления и внешние источники. Поэтому
|
||||
frontend следует начинать после появления нормализованной базы, поискового API
|
||||
и API документов.
|
||||
|
||||
Требования необходимо адаптировать под Кыргызскую Республику: в исходном
|
||||
документе используются примеры ТК РФ и деление
|
||||
«федеральный/региональный/муниципальный», которое нельзя переносить без
|
||||
изменений.
|
||||
|
||||
## Задачи до начала frontend-разработки
|
||||
|
||||
### Обязательные для MVP
|
||||
|
||||
1. Нормализовать архив Минюста:
|
||||
- очистить HTML;
|
||||
- выделить структуру документа и редакций;
|
||||
- унифицировать статусы, органы, виды документов и даты;
|
||||
- сохранить ссылки на официальный источник и дату получения;
|
||||
- определить правила отображения документов без текста.
|
||||
2. Подготовить backend API:
|
||||
- `GET /search`;
|
||||
- `GET /search/filters`;
|
||||
- `GET /documents/{code}`;
|
||||
- `GET /documents/{code}/editions`;
|
||||
- `GET /documents/{code}/editions/{edition}`;
|
||||
- описание API в OpenAPI;
|
||||
- серверную пагинацию, фильтрацию и сортировку.
|
||||
3. Развернуть поисковый сервис. Рекомендуемый вариант — self-hosted OpenSearch:
|
||||
- отдельные поля и анализаторы для русского и кыргызского текстов;
|
||||
- русский морфологический анализ;
|
||||
- ICU-нормализация кыргызского текста;
|
||||
- словари синонимов и сокращений;
|
||||
- подсветка совпадений;
|
||||
- индексирование всех редакций.
|
||||
4. Подготовить тестовый набор из 50–100 реальных запросов на русском и
|
||||
кыргызском языках и вручную отметить ожидаемые результаты.
|
||||
5. Утвердить справочники:
|
||||
- виды документов;
|
||||
- органы принятия;
|
||||
- статусы;
|
||||
- уровни действия;
|
||||
- тематический классификатор первой версии.
|
||||
|
||||
OpenSearch имеет встроенный
|
||||
[русский морфологический анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/russian/),
|
||||
поддерживает [синонимы и нечёткий поиск](https://docs.opensearch.org/latest/query-dsl/full-text/match/)
|
||||
и [подсветку результатов](https://docs.opensearch.org/latest/search-plugins/searching-data/highlight).
|
||||
Встроенного кыргызского морфологического анализатора в перечне нет, поэтому
|
||||
нужно отдельно проверить ICU и словари на реальных запросах.
|
||||
[ICU-анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/icu/)
|
||||
обеспечивает Unicode-нормализацию, но сам по себе не гарантирует кыргызскую
|
||||
морфологию.
|
||||
|
||||
### Сторонние сервисы, не нужные для MVP
|
||||
|
||||
Их не следует подключать заранее:
|
||||
|
||||
- Keycloak или другой OIDC-провайдер — перед закладками, папками и ролями;
|
||||
- SMTP, Telegram или Web Push — перед «документами на контроле»;
|
||||
- LibreOffice или Gotenberg — перед экспортом в Word, PDF и RTF;
|
||||
- поставщики судебной практики и экспертных комментариев — после проверки
|
||||
лицензий;
|
||||
- источники курсов, календарей и справочных данных — перед соответствующим
|
||||
разделом;
|
||||
- Sentry или аналог — опционально перед публичным запуском.
|
||||
|
||||
## Граница MVP
|
||||
|
||||
MVP — публичная справочно-поисковая система без регистрации и персональных
|
||||
функций.
|
||||
|
||||
В MVP входят:
|
||||
|
||||
- интерфейс на русском и кыргызском языках;
|
||||
- строка полнотекстового поиска;
|
||||
- исправление распространённых опечаток;
|
||||
- базовые синонимы и сокращения;
|
||||
- список результатов с подсвеченными фрагментами;
|
||||
- фильтры по языку, виду документа, органу, статусу и дате;
|
||||
- сортировка по релевантности и дате;
|
||||
- пагинация;
|
||||
- карточка документа;
|
||||
- актуальная редакция, статус и дата актуальности;
|
||||
- переключение между доступными языками;
|
||||
- поиск внутри открытого документа;
|
||||
- список редакций и открытие выбранной редакции;
|
||||
- ссылка на официальный источник и сведения о происхождении данных;
|
||||
- адаптивность, доступность, состояния загрузки и ошибок.
|
||||
|
||||
Сравнение редакций, аккаунты, заметки, уведомления, RAG и судебная практика в
|
||||
MVP не входят.
|
||||
|
||||
Интерфейс не должен предполагать наличие обоих языков. На момент полного
|
||||
скачивания архива распределение следующее:
|
||||
|
||||
- только русский язык — 29 433 документа;
|
||||
- только кыргызский язык — 98 905 документов;
|
||||
- оба языка — 80 930 документов;
|
||||
- нет HTML-текста — 690 документов.
|
||||
|
||||
## Рекомендуемая основа frontend
|
||||
|
||||
Для внутреннего frontend лаборатории и авторизованного поиска используется
|
||||
одна HTML-страница, которую отдаёт существующий Search API, и браузерный
|
||||
JavaScript без сборщика и runtime-зависимостей. Это сохраняет текущую схему
|
||||
развёртывания. Публичный frontend после readiness gate можно оценить отдельно.
|
||||
|
||||
- Next.js App Router и TypeScript;
|
||||
- CSS Modules с BEM-именованием;
|
||||
- дизайн-токены для цветов, отступов, типографики и состояний;
|
||||
- серверный `fetch` и URL-параметры вместо отдельного глобального хранилища;
|
||||
- Playwright для основных пользовательских сценариев;
|
||||
- адаптивный web-интерфейс без отдельного мобильного приложения.
|
||||
|
||||
Next.js App Router поддерживает серверные компоненты, маршрутизацию и
|
||||
TypeScript в стандартной конфигурации. См.
|
||||
[официальную документацию](https://nextjs.org/docs/app).
|
||||
|
||||
## Дизайн-процесс и внешние ориентиры
|
||||
|
||||
При проектировании и проверке интерфейса используются следующие источники:
|
||||
|
||||
- [jakubkrehel/skills](https://github.com/jakubkrehel/skills) — обязательная
|
||||
комплексная проверка интерфейса через `better-interface`, включая UI,
|
||||
типографику, цвета, доступность, layout и тексты;
|
||||
- [UI Skills](https://www.ui-skills.com/) — каталог практик и узких skills,
|
||||
которые подключаются только под конкретную задачу после проверки их
|
||||
содержания и лицензии;
|
||||
- [Refero Styles](https://styles.refero.design/) — библиотека визуальных
|
||||
направлений и примеров `DESIGN.md` для поиска референсов.
|
||||
|
||||
Правила применения:
|
||||
|
||||
1. Визуальное направление «Түндүк» утверждено пользователем. Применять
|
||||
корневой [DESIGN.md](../../DESIGN.md); повторный выбор направления не нужен.
|
||||
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
|
||||
компоненты должны учитывать длинные юридические тексты, два языка и
|
||||
доступность.
|
||||
3. Перенести значения из корневого `DESIGN.md` в дизайн-токены проекта.
|
||||
Не создавать отдельный `frontend/DESIGN.md` или конкурирующие спецификации.
|
||||
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
|
||||
рекомендации не могут отменять BEM, доступность, требования безопасности и
|
||||
продуктовые ограничения проекта.
|
||||
5. Каждый завершённый пользовательский сценарий проходит `better-interface`
|
||||
review. Перед выпуском MVP выполняется полный review поиска, фильтров и
|
||||
просмотра документа.
|
||||
6. UI Skills используется для точечного поиска решения, а не для одновременного
|
||||
смешивания нескольких визуальных стилей.
|
||||
|
||||
Эти ресурсы используются на этапе проектирования и review и не являются
|
||||
runtime-зависимостями frontend. Регистрация в стороннем SaaS для MVP не нужна.
|
||||
|
||||
## План спринтов MVP
|
||||
|
||||
### Спринт 0 — фундамент, 1 неделя
|
||||
|
||||
- создать `frontend/`;
|
||||
- настроить Next.js, TypeScript, lint и сборку;
|
||||
- применить утверждённую дизайн-систему «Түндүк» из корневого `DESIGN.md`;
|
||||
- установить полный набор `jakubkrehel/skills` для проектных design review;
|
||||
- определить маршруты и типы API;
|
||||
- создать дизайн-токены;
|
||||
- реализовать базовые компоненты: кнопка, поле, селект, статус, карточка,
|
||||
пагинация;
|
||||
- создать общий layout и двуязычную навигацию;
|
||||
- добавить footer с версиями frontend и backend;
|
||||
- подготовить макеты поиска, результатов и документа;
|
||||
- провести первый `better-interface` review макетов;
|
||||
- настроить CI.
|
||||
|
||||
Результат: интерфейсный каркас работает на mock-ответах API.
|
||||
|
||||
### Спринт 1 — быстрый поиск, 2 недели
|
||||
|
||||
- главная страница с поиском;
|
||||
- интеграция с `/search`;
|
||||
- список результатов;
|
||||
- подсветка совпадений;
|
||||
- URL, которым можно поделиться;
|
||||
- переключение RU/KY;
|
||||
- состояния загрузки, отсутствия результатов и ошибки API;
|
||||
- базовая мобильная версия.
|
||||
|
||||
Результат: пользователь может найти документ и открыть результат.
|
||||
|
||||
### Спринт 2 — точный отбор, 2 недели
|
||||
|
||||
- фильтры по реквизитам;
|
||||
- сортировка;
|
||||
- пагинация;
|
||||
- отображение числа результатов;
|
||||
- сброс отдельных и всех фильтров;
|
||||
- сохранение состояния в URL;
|
||||
- доступное управление с клавиатуры;
|
||||
- адаптивная панель фильтров.
|
||||
|
||||
Результат: поддерживается быстрый и реквизитный поиск.
|
||||
|
||||
### Спринт 3 — просмотр документа, 2 недели
|
||||
|
||||
- заголовок, реквизиты, статус и дата актуальности;
|
||||
- безопасное отображение очищенного HTML;
|
||||
- переключение языка;
|
||||
- поиск внутри документа;
|
||||
- навигация по найденным фрагментам;
|
||||
- список редакций;
|
||||
- открытие предыдущей редакции;
|
||||
- ссылка на ЦБД Минюста;
|
||||
- печать средствами браузера.
|
||||
|
||||
Результат: пользователь может проверить текст и его происхождение.
|
||||
|
||||
### Спринт 4 — стабилизация и выпуск, 2 недели
|
||||
|
||||
- сквозные тесты поиска и просмотра;
|
||||
- проверка русских, кыргызских и одноязычных документов;
|
||||
- соответствие WCAG 2.2 AA;
|
||||
- защита от внедрения небезопасного HTML;
|
||||
- проверка производительности;
|
||||
- корректные метаданные страниц;
|
||||
- обработка недоступности API;
|
||||
- production-сборка и развёртывание;
|
||||
- пользовательское тестирование на 10–15 реальных юридических задачах.
|
||||
|
||||
Результат: публичный MVP.
|
||||
|
||||
Оценка frontend-части после готовности API: **9 недель**.
|
||||
|
||||
## Спринты после MVP
|
||||
|
||||
### Спринт 5 — персональные функции
|
||||
|
||||
Базовый вход и смена пароля для внутреннего frontend уже реализованы. Для
|
||||
публичного продукта остаются закладки, заметки, подборки и сохранённые фильтры.
|
||||
|
||||
### Спринт 6 — контроль изменений
|
||||
|
||||
Документы на контроле, подписки на редакции и уведомления.
|
||||
|
||||
### Спринт 7 — юридические связи
|
||||
|
||||
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
|
||||
|
||||
### Спринт 8 — практические материалы
|
||||
|
||||
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
|
||||
|
||||
### Спринт 9 — расширенный анализ
|
||||
|
||||
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
|
||||
ссылками на источники.
|
||||
|
||||
### Спринт 10 — корпоративные функции
|
||||
|
||||
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
|
||||
персонализация.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -0,0 +1,452 @@
|
||||
# План реализации и приёмки Акылдаш
|
||||
|
||||
Основа аудита: `main` на `7ece39f212b67e497a8dfb360d5d271ab088ef57`
|
||||
от 2 ноября 2026 года. Документ хранит результаты UI-аудита, критерии
|
||||
интерфейсной приёмки и порядок подготовки production. Целевые возможности
|
||||
и условия публичного MVP ведутся в [функциональности платформы](platform-functionality.md).
|
||||
|
||||
## 1. Статус и границы достоверности
|
||||
|
||||
Различаем три вида сведений:
|
||||
|
||||
- **В коде** — наличие исходного кода на указанном commit; это не доказывает,
|
||||
что функция развёрнута или доступна пользователям.
|
||||
- **Историческое наблюдение** — результат конкретного аудита, дата и пределы
|
||||
которого указаны рядом. Оно не является повторной проверкой текущего сайта.
|
||||
- **Цель / gate** — требование, которое нужно выполнить и подтвердить до
|
||||
следующего этапа.
|
||||
|
||||
На сверенном commit источники кода показывают Backend `0.13.3`
|
||||
(`backend/search/minjust_opensearch.py`), Frontend `0.3.3`
|
||||
(`backend/search/review.html`) и Telegram-бот `0.2.2`
|
||||
(`tools/telegram-bot/bot.py`). Это версии исходного кода, а не подтверждение
|
||||
установленных production-версий. В этом PR согласованы версии в корневом и
|
||||
общем документационном README; `backend/README.md` отдельно называет версию
|
||||
загрузчика `minjust_cbd.py` (`0.9.3`). При новых изменениях сверять версию
|
||||
компонента с соответствующим исполняемым кодом.
|
||||
|
||||
В Search API и frontend реализованы поиск RU/KY, фильтры, карточка документа,
|
||||
редакции, поиск в тексте, внутренняя лаборатория, вход и административные
|
||||
действия. В коде также есть локальные Noto Sans/Serif с лицензиями, favicon и
|
||||
встроенный SVG-знак бренда. Наличие файлов и ссылок в HTML не подтверждает,
|
||||
что они загрузились в live-браузере. Результаты браузерной проверки приведены
|
||||
только в [отчёте визуальной приёмки](visual-review/README.md).
|
||||
|
||||
Production readiness не следует из наличия Compose, release workflow или
|
||||
deploy-кода. Подтверждённое состояние и ограничения находятся в
|
||||
[CURRENT_STATE.md](../operations/CURRENT_STATE.md); статус релиза — в
|
||||
[DEPLOYMENT.md](../operations/DEPLOYMENT.md), ограничения backup/restore — в
|
||||
[BACKUP.md](../operations/BACKUP.md), rollback — в
|
||||
[ROLLBACK.md](../operations/ROLLBACK.md), аварийное восстановление — в
|
||||
[RECOVERY.md](../operations/RECOVERY.md). Этот план не повторяет команды из
|
||||
этих runbooks.
|
||||
|
||||
## 2. Очерёдность работ
|
||||
|
||||
### 2.1 Интерфейс: датированный реестр UI-01—UI-33
|
||||
|
||||
Реестр ниже сохраняет исходные наблюдения аудита **23 октября 2026 года** и
|
||||
критерии приёмки; это не список текущих открытых дефектов. Актуальный план на
|
||||
`main` указывает, что все UI-01—UI-33 реализованы в PR #54. Он также описывает
|
||||
follow-up по поиску, кабинету и лаборатории, локальную браузерную проверку и
|
||||
синтетические regression fixtures. Их результаты привязаны к указанным в
|
||||
исходном документе commits и окружению, а не являются повторным live-аудитом в
|
||||
этой документационной задаче. Сверяться с [отчётом visual-review](visual-review/README.md);
|
||||
не открывать снова выполненные пункты без нового воспроизводимого свидетельства.
|
||||
|
||||
Источники по состоянию на базовый commit этого PR сохранены постоянными
|
||||
ссылками: [UI-план на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/interface-correction-plan.md)
|
||||
и [план pixel-perfect на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/frontend-pixel-perfect-plan.md).
|
||||
UI-01—UI-33 вошли в [PR #54](https://git.vakanaut.ru/admin/akyldash/pulls/54)
|
||||
(merge commit [`01380d3c`](https://git.vakanaut.ru/admin/akyldash/commit/01380d3c09d71e32c965d974a39a4d82de0a15c5));
|
||||
последующий follow-up поиска, кабинета и лаборатории записан в
|
||||
[commit `e5a8326a`](https://git.vakanaut.ru/admin/akyldash/commit/e5a8326a5e3def1bf5d5dc94794da82a2af801ab).
|
||||
|
||||
В исходном реестре **P1** означает обязательное исправление до визуальной
|
||||
приёмки/выпуска, **P2** — существенное улучшение понятности и
|
||||
последовательности. Отсутствие функции само по себе не считается багом;
|
||||
обозначенные целевые корректировки требуют подтверждения по коду и дизайну.
|
||||
|
||||
| ID | Приоритет | Историческое наблюдение 23.10.2026 | Требование / критерий |
|
||||
|---|---|---|---|
|
||||
| UI-01 | P1 | При пустом запросе одновременно показывались подсказка и empty-состояние | Разделить initial/loading/results/empty/error; empty показывать после завершённого поиска |
|
||||
| UI-02 | P1 | До выбора документа были доступны действия и пустые select | До загрузки документа показывать понятное приглашение; действия доступны после выбора |
|
||||
| UI-03 | P1 | Фильтры имели горизонтальное переполнение (`clientWidth=194`, `scrollWidth=231` при 1064 px) | Исключить переполнение; длинные списки раскрывать по запросу |
|
||||
| UI-04 | P1 | Действие «Сбросить» обрезалось заголовком | Заголовок и действие видны целиком на поддерживаемых размерах |
|
||||
| UI-05 | P1 | Счётчики отрывались от подписей, переносы растягивали панель | Разделить колонки выбора/подписи/числа; переносить только длинные названия |
|
||||
| UI-06 | P1 | Все 28 видов и 8 статусов были раскрыты, орган и даты находились ниже первого экрана | Показывать основные значения и сворачиваемые группы, сохраняя доступ ко всему каталогу |
|
||||
| UI-07 | P1 | Ширины около 78 px не хватало для RU/KY названий языка | Подбирать размер по самой длинной локализованной подписи без обрезки |
|
||||
| UI-08 | P1 | На 390 px навигация прокручивалась горизонтально, кабинет и фильтры уходили за край | Адаптивно показывать поиск, фильтры и кабинет без горизонтальной прокрутки основных действий |
|
||||
| UI-09 | P2 | Кабинет был малозаметной иконкой во второй строке | Подписанная desktop-кнопка; на mobile — доступное имя и достаточная touch-область |
|
||||
| UI-10 | P1 | Три колонки оставались тесными на 1024/1064 px | Перейти к двухпанельному режиму и скрытию фильтров, не сжимать колонки бесконечно |
|
||||
| UI-11 | P2 | Название документа повторялось в breadcrumb, заголовке и тексте | Breadcrumb обозначает контекст; официальный текст не переписывать |
|
||||
| UI-12 | P1 | Ссылка на источник и toolbar переполнялись на промежуточной ширине | Переносить действия группами без обрезки; сохранять подписи редакции и языка |
|
||||
| UI-13 | P2 | Пустые абзацы и синие ссылки конфликтовали с оболочкой | Нормализовать отступы и ссылки без потери структуры официального текста |
|
||||
| UI-14 | P1 | Таблицы в документе шире панели (`clientWidth=412`, `scrollWidth=834`) | Дать таблице отдельную обозначенную прокрутку; не прокручивать всю страницу по горизонтали |
|
||||
| UI-15 | P1 | Встречались Temp-пути и HTTP-ссылки исходного редактора | Классифицировать ссылки безопасно, не удалять юридический текст; не считать локальные/IP-ссылки проверенными |
|
||||
| UI-16 | P2 | Развёрнутый документ терял поиск, совпадение и toolbar | Сохранять редакцию, язык, позицию и навигацию; обеспечить очевидный возврат |
|
||||
| UI-17 | P2 | Часть KY-интерфейса оставалась на русском | Инвентаризировать строки интерфейса; не заменять переводом язык документа |
|
||||
| UI-18 | P1 | На `/review` отображались два footer, общий footer был выше сохранения | Один footer после основного содержимого и панели сохранения |
|
||||
| UI-19 | P1 | Шапка, формы, панели и footer лаборатории имели разные направляющие | Единая оболочка и совпадающие границы контента |
|
||||
| UI-20 | P1 | Высокая шапка лаборатории отодвигала работу на mobile | Компактный заголовок и помощь; поиск и рабочая область доступны сразу |
|
||||
| UI-21 | P1 | Заголовок кабинета был пустым | Видимый заголовок и доступное имя диалога |
|
||||
| UI-22 | P1 | Не было постоянного крестика; закрытие находилось внизу формы | Крестик 44×44, доступное имя, Escape и возврат фокуса; отмена редактирования остаётся |
|
||||
| UI-23 | P1 | Навигация, выход, профиль и смена пароля были смешаны | Разделить «Профиль» и «Безопасность», отделить выход от формы |
|
||||
| UI-24 | P1 | Окно пользователей открывалось поверх кабинета | Не оставлять конкурирующие модальные окна; сохранять контекст и корректный фокус |
|
||||
| UI-25 | P1 | Список пользователей был длинным модальным окном с закрытием ниже экрана | Отдельный экран списка и короткие диалоги для создания/сброса |
|
||||
| UI-26 | P2 | Создание, удаление, сброс и входные данные смешивались | Действия на строке, отдельное подтверждение удаления, не раскрывать лишние формы |
|
||||
| UI-27 | P2 | Бренд, навигация, шрифты и ссылки различались между экранами | Общие header, logo, button, link, dialog и footer |
|
||||
| UI-28 | P2 | Диалоги закрывались разными способами | Единая модель окна и доступного закрытия |
|
||||
| UI-29 | P2 | Обучение появлялось с задержкой и перехватывало ввод | Запуск по запросу или ненавязчиво до начала ввода; не отбирать фокус |
|
||||
| UI-30 | P2 | Шкала 0–3 переносилась, комментарии всегда занимали место | Полные подписи; допустима сетка 2×2; раскрываемый комментарий без потери текста |
|
||||
| UI-31 | P2 | Пустой экран лаборатории выглядел готовым к отправке оценки | Явное состояние снимка, прогресс и статус черновика; не менять смысл пустой оценки |
|
||||
| UI-32 | P2 | Footer live-сайта показывал «Концепция Түндүк» | Рабочий интерфейс показывает продукт и реальные версии; концепция подписывается только в макете/демо |
|
||||
| UI-33 | P1 | Документ растягивал лабораторию до y≈636935 px, сохранение уходило ниже | Ограничить высоту области чтения и прокручивать её отдельно; сохранение доступно независимо от длины текста |
|
||||
|
||||
Дополнительные целевые критерии исходного аудита:
|
||||
|
||||
- **Сетка и ресайз колонок:** desktop — три колонки с доступными разделителями;
|
||||
ориентиры 1440 px: `280 / 408 / 736` плюс два захвата по 8 px, 1280 px:
|
||||
`256 / 384 / 624` плюс захваты. Рекомендуемые пределы фильтров 256–360 px,
|
||||
выдачи от 360 px, документа от 560 px. При недостатке ширины сначала скрыть
|
||||
фильтры, затем показывать документ отдельно.
|
||||
- Разделители должны иметь `role=separator`, доступное имя, min/max/current
|
||||
значения, pointer drag и управление стрелками клавиатуры. Отдельные кнопки
|
||||
скрытия/показа фильтров и сброса ширины обязательны; двойной клик не заменяет
|
||||
доступный сброс. Сохранённая ширина ограничивается текущим viewport.
|
||||
- Остальные правила сетки: фильтры не становятся постоянно прокручиваемой
|
||||
узкой колонкой; полный каталог значений всегда достижим; API single-select не
|
||||
изображается множественными checkbox. Длинное название органа читается в
|
||||
панели выбора полностью. Поля дат имеют постоянные «От» / «До» и переходят
|
||||
в столбец, когда не помещаются. На desktop выдача и документ могут иметь
|
||||
отдельную вертикальную прокрутку с доступными заголовком/toolbar; на mobile
|
||||
основной поток вертикальный с отдельными панелями. Footer использует
|
||||
растущий main (`min-height: 100dvh`), не перекрывает контент и следует за ним;
|
||||
панель сохранения имеет собственное место.
|
||||
- **Панели и формы:** понятный заголовок и крестик 44×44; именованный modal
|
||||
dialog, фокус внутри, Tab/Shift+Tab без выхода в фон, Escape там, где закрытие
|
||||
допустимо, возврат фокуса инициатору; фон не интерактивен. Не наслаивать
|
||||
кабинет и пользователей. Профиль и безопасность — отдельные разделы; права
|
||||
по-прежнему проверяет сервер. В форме пароля постоянные подписи, требования,
|
||||
доступное показать/скрыть и подтверждение нового пароля. Пароли не
|
||||
предзаполнять и не сохранять в материалах аудита; ввод выполняет человек.
|
||||
- Основное действие одно; отмена вторична, выход отдельно; Enter отправляет
|
||||
только активную форму. Ошибка у поля и в доступном сообщении, допустимый ввод
|
||||
сохраняется, loading исключает повторную отправку. Несохранённое редактирование
|
||||
защищено от случайной потери, обычный просмотр закрывается без лишнего
|
||||
подтверждения. После успеха сообщается результат, при ошибке есть путь
|
||||
восстановления; состояние не кодируется одним цветом.
|
||||
- Правила применяются к кабинету, входу, обязательной смене пароля, источникам,
|
||||
созданию/сбросу/удалению пользователя, обучению и просмотру документа.
|
||||
- **Приёмка:** единая оболочка/header/footer; нет общей горизонтальной
|
||||
прокрутки; initial не сообщает об empty до поиска; действия документа
|
||||
активируются по состоянию; keyboard и touch работают; RU/KY подписи и язык
|
||||
документа независимы; таблицы прокручиваются отдельно; тестируются contrast
|
||||
WCAG AA, видимый focus, reduced motion, zoom/reflow и доступные имена.
|
||||
- Viewport: **1440×900, 1280×800, 1024×768, 390×844**, дополнительно 320 CSS px
|
||||
для reflow. Визуальное сравнение — детерминированное fixture; нулевой
|
||||
pixel-diff не является критерием. Авторизация и destructive-сценарии
|
||||
проверяются только в безопасном тестовом контуре.
|
||||
|
||||
Исторически выполненные браузерные сценарии и ограничения аудита 23.10.2026:
|
||||
|
||||
| Область | Выполнено в том аудите | Не проверено тогда |
|
||||
|---|---|---|
|
||||
| Начальная страница и поиск RU | Пустой запрос, фильтры, footer; поиск «налог на прибыль», открытие кодекса | Экспертная оценка юридической релевантности |
|
||||
| Фильтры, сортировка, страницы | Статус, chip, снятие фильтра, дата/релевантность, страница 2 и сброс на страницу 1 | Все комбинации справочников |
|
||||
| Пустая выдача и документ | Контрольный заведомо пустой запрос, реальные текст и редакции, язык, разворот | Искусственное создание серверного сбоя, реальная печать/PDF |
|
||||
| Поиск в документе и локализация | Совпадения, переход к следующему, переключение RU/KY и чтение подписей | Поиск после смены редакции; полная проверка всех переводов |
|
||||
| Источники и мобильные фильтры | Открытие/закрытие панели и раздела источников | Внешний сайт Минюста, полный touch на физическом устройстве |
|
||||
| Кабинет и пользователи | Профиль, поля смены пароля, Escape/возврат фокуса; просмотр списка | Ввод пароля, создание/удаление пользователя, сброс пароля |
|
||||
| `/review` | Пустое состояние, поиск, шкала, документ, обучение | Отправка оценки и подтверждение серверного сохранения |
|
||||
| Авторизация | Наличие локальной реализации | Live login, истечение сессии и обязательная смена пароля |
|
||||
|
||||
Этот перечень описывает только прежний браузерный сеанс. Не использовать его
|
||||
как подтверждение текущих production-сценариев.
|
||||
|
||||
#### Реализация, зафиксированная в текущем плане `main`
|
||||
|
||||
UI-план, находившийся на базовом commit этого PR, отмечает UI-01—UI-33
|
||||
реализованными в PR #54 и связывает группы изменений с
|
||||
регрессионными сценариями:
|
||||
|
||||
| Пункты | Изменение, зафиксированное в плане `main` | Regression evidence, указанное там |
|
||||
|---|---|---|
|
||||
| UI-01—02 | Разделены initial/loading/results/empty/error; действия документа зависят от выбранной редакции | Empty, API error и редакция без текста |
|
||||
| UI-03—08 | Компактные/сворачиваемые фильтры, счётчики, локализованные языковые select и адаптивная навигация | Reflow 320/390/640/1024/1280/1440; на 640 px проверены основные действия `/`, `/review`, `/users` |
|
||||
| UI-09—10 | Подписанный кабинет и адаптивный layout вместо бесконечного сжатия трёх колонок | Геометрия и overflow на 640/1024/1280/1440 |
|
||||
| UI-11—16 | Breadcrumb, toolbar, отдельный scroll таблиц, очистка небезопасных URL-атрибутов и разворот документа в панели | Синтетические private-IP/relative/HTTP ссылки; ошибки и гонки редакций A/B |
|
||||
| UI-17 | Перевод интерфейсных подписей RU/KY с независимым языком документа | Переключение языка; полный ручной лингвистический review остаётся открытым |
|
||||
| UI-18—20 | Один footer, общие направляющие и ограниченная по высоте лаборатория | Один footer и длинный синтетический документ с собственным scroll |
|
||||
| UI-21—24 | Имя и разделы кабинета, безопасный переход к пользователям без наложенных диалогов | ARIA name, Escape, focus trap/return и отдельные маршруты |
|
||||
| UI-25—28 | Отдельный экран пользователей, целевые окна для действий, общий способ закрытия | Только фиктивный `fixture-created`; реальные аккаунты/API не использовались |
|
||||
| UI-29 | Обучение запускается пользователем | Локальный regression scenario |
|
||||
| UI-30—31 | Оценка 2×2, раскрываемый комментарий, disable до результата и draft state | Фиктивное сохранение и сохранность оценок при ошибке/устаревшем ответе |
|
||||
| UI-32—33 | Версии в footer и отдельная прокрутка длинного текста лаборатории | Footer versions и длинный fixture, сохранение остаётся доступно |
|
||||
|
||||
Тот же план `main` фиксирует локальный browser run на **320×800, 390×844,
|
||||
640×450, 1024×900, 1280×800 и 1440×900 CSS px**; на 1280 px измерена
|
||||
сетка 256 / 384 / 640 px. Экран лаборатории с длинным текстом имел собственную
|
||||
панель прокрутки, а сохранение/footer оставались доступны. Встроенная
|
||||
проверка fixture не использовала production API или реальные учётные записи.
|
||||
Это точная передача прежнего отчёта; её не следует трактовать как результат
|
||||
нового прогона в этом PR. Полный screen-reader review, 200–400% zoom,
|
||||
физический touch и полный ручной лингвистический review в исходном плане
|
||||
оставлены непроверенными.
|
||||
|
||||
Отдельный follow-up в плане `main` описывает реализованные после PR #54
|
||||
корректировки и regression coverage:
|
||||
|
||||
- **Поиск:** «Открыт» сбрасывается при смене результата/редакции и ставится
|
||||
только после успешной загрузки. Оглавление распознаёт самостоятельные
|
||||
заголовки RU/KY из структурированных абзацев (`p > b > span`), задаёт
|
||||
уникальные якоря и вложенность, не принимает обычные упоминания за заголовки.
|
||||
Выбор языка поиска находится в фильтрах и не обрезается; декоративный SVG и
|
||||
пустое резервное место удалены; активная навигация использует палитру.
|
||||
Проверены ошибки и гонки загрузки, структура заголовков, splitter и
|
||||
доступность диапазона его ширины.
|
||||
- **Кабинет:** ссылки действий сохраняют `<a>` семантику, крестик остаётся
|
||||
подписанной SVG-кнопкой 44×44, значения профиля стоят в парах «поле—значение»,
|
||||
длинные значения переносятся, заголовок компактнее; администратору явно
|
||||
доступно «Управление пользователями». Проверены RU/KY, отсутствие ссылки у
|
||||
не-администратора, фокус/закрытие и ширина.
|
||||
- **Лаборатория:** имя показывается без дублирующей роли; ссылки навигации имеют
|
||||
вид кнопок; оболочка, форма, рабочая область, сохранение и footer выровнены.
|
||||
Пустой live status не резервирует высоту, результаты и документ образуют
|
||||
общую поверхность с внутренней прокруткой на desktop, заголовок использует
|
||||
доступную ширину, а mobile возвращается к естественному потоку. Проверены
|
||||
desktop/mobile, пустой status, сохранение после длинного документа и page
|
||||
overflow.
|
||||
|
||||
Исходный план сообщает о локальной проверке этих изменений 24 октября 2026
|
||||
года и повышении frontend до `0.3.0` из-за регулируемой ширины панелей. Текущий
|
||||
source footer и версия кода — `0.3.3`. Его отметка «ожидаются CI и review»
|
||||
относилась к конкретному pushed SHA и не задаёт статус этого или любого нового
|
||||
PR.
|
||||
|
||||
#### Проверка сценариев публичного MVP
|
||||
|
||||
Каждый завершённый пользовательский сценарий проходит проверку
|
||||
через `better-interface` с оценкой интерфейса, типографики, цвета,
|
||||
доступности, компоновки и текстов. Перед выпуском публичного MVP провести
|
||||
итоговую комплексную проверку поиска, фильтров и просмотра документа.
|
||||
|
||||
#### Общие критерии окон и форм
|
||||
|
||||
- Desktop-окно обычно 480–640 px и не шире viewport; на mobile — отступ 16 px
|
||||
или полноэкранный режим для длинной формы. Заголовок и закрытие остаются
|
||||
доступны при прокрутке.
|
||||
- Диалог именуется через заголовок, держит фокус внутри и не делает фон
|
||||
интерактивным. Escape закрывает обычные окна; исключения (например,
|
||||
обязательная смена пароля) должны объясняться, а не маскироваться.
|
||||
- Профиль и безопасность разделены. Удаление требует отдельного подтверждения;
|
||||
создание/сброс не раскрывают дополнительные формы без запроса.
|
||||
- Поля имеют постоянные подписи; пароль не предзаполняется и не записывается
|
||||
в отчёт. Ошибки объясняют восстановление, допустимый ввод сохраняется,
|
||||
повторная отправка во время loading исключена.
|
||||
- «Отмена» вторична, выход не смешивается с сохранением; несохранённое
|
||||
редактирование защищено от случайной потери. Обычный просмотр закрывается
|
||||
без лишнего подтверждения.
|
||||
|
||||
#### Последовательность при возобновлении UI-работ
|
||||
|
||||
1. Зафиксировать актуальные SHA и версии; читать маршрут, API, `DESIGN.md`,
|
||||
UI tests и CI. Не переносить состояние live-сайта без новой проверки.
|
||||
2. Исправить общую оболочку и разделение поиска, лаборатории и служебных
|
||||
экранов; затем initial/loading/empty/error и жизненный цикл документа.
|
||||
3. Исправить фильтры, адаптивные минимумы и RU/KY; после этого — регулируемые
|
||||
колонки и скрытие фильтров.
|
||||
4. Привести формы/диалоги к общей модели с сохранением серверной авторизации,
|
||||
прав, валидации, снимков оценок и черновиков.
|
||||
5. Проверить текст, таблицы, ссылки, редакции, печать, адаптивность,
|
||||
локализацию, доступность и состояния на реальном корпусе, где доступен.
|
||||
6. Сделать детерминированный fixture-проход и независимый review; обновить
|
||||
версию только при изменении кода, а changelog — после зелёных проверок и
|
||||
разрешения blocking findings.
|
||||
|
||||
Не переносить существующий Python frontend на React/Next.js ради визуальной
|
||||
корректировки. UI-работа не меняет ранжирование, корпус или права доступа.
|
||||
|
||||
| Приёмка | Условие |
|
||||
|---|---|
|
||||
| Оболочка | Один header/footer на маршруте, единые границы и порядок контента |
|
||||
| Фильтры | Нет общей горизонтальной прокрутки; подписи/действия/счётчики видны полностью |
|
||||
| Начальное состояние | До первого завершённого запроса empty-сообщение не показано |
|
||||
| Документ | До выбора неактивные действия не вводят в заблуждение |
|
||||
| Колонки | Pointer и клавиатура; min/max; скрытие/возврат; reset; адаптация сохранённой ширины к viewport |
|
||||
| Телефон | Основные действия без горизонтального скролла; понятный возврат из фильтров/документа |
|
||||
| Окна | Заголовок, крестик, Escape, trap/return focus; окна не конкурируют слоями |
|
||||
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение; текст не теряется, панель доступна при длинном документе |
|
||||
| Локализация | RU/KY labels и символы; язык UI не подменяет язык документа |
|
||||
| Геометрия | При 1280×800 границы основных зон и отступы/размеры controls отличаются от измеренного fixture не более чем на 2 CSS px; согласованные исключения описаны |
|
||||
| Типографика и таблицы | Noto действительно загружается; читаемый длинный текст; у широкой таблицы собственный скролл |
|
||||
| Доступность | Контраст AA, focus, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion и touch targets |
|
||||
| Авторизация | Неверный пароль, expired session и обязательная смена проверены только в безопасном тестовом контуре |
|
||||
| Снимки | 1440×900, 1280×800, 1024×768, 390×844 и 320 CSS px; стабильные fixture и объяснённые различия |
|
||||
|
||||
Допуск **до 2 CSS px** для границ крупных зон и отступов/размеров controls при
|
||||
1280×800 перенесён из исторического pixel-perfect плана по стабильной ссылке
|
||||
выше. Он применяется к одинаковым детерминированным fixture; расхождения
|
||||
растра, вызванные сглаживанием текста или содержимым, сами по себе не дают
|
||||
основания обрезать или уплотнять юридический текст.
|
||||
|
||||
#### Нормативный орнамент и фактическое состояние
|
||||
|
||||
`DESIGN.md` нормативно требует небольшой золотистый SVG-орнамент в свободной
|
||||
нижней части desktop-панели фильтров, не перекрывающий длинные списки и не
|
||||
резервирующий место за счёт управления. Follow-up из commit `e5a8326a` удалил
|
||||
предыдущий декоративный SVG и его пустой резерв; в `backend/search/review.html`
|
||||
на базовом `main` целевого орнамента нет. Это не выполненное требование, а
|
||||
подтверждённое расхождение между `DESIGN.md` и реализацией. Дизайн остаётся
|
||||
нормативным до отдельного решения владельца: UI-задача должна либо вернуть
|
||||
орнамент в свободную область без влияния на управление, либо получить явное
|
||||
одобрение на изменение `DESIGN.md` и подтвердить его визуальной приёмкой.
|
||||
|
||||
### 2.2 Инфраструктура и переход к production
|
||||
|
||||
Инфраструктурные источники содержали повторяющиеся этапы deployment и
|
||||
устаревшие снимки VPS. В этом плане оставлены только gates; актуальные
|
||||
подтверждённые данные и процедуры находятся в операционных документах.
|
||||
|
||||
1. **Аудит:** сверить commit `main`, CI, Compose, зависимости, порты, volumes,
|
||||
healthchecks, данные и перечень env-переменных без вывода секретов. Для VPS
|
||||
пользоваться только read-only инвентаризацией, разрешённой
|
||||
[правилами доступа](../operations/ACCESS.md). Не переносить IP, версии,
|
||||
DNS-статус или ресурсы из старых планов как текущие факты.
|
||||
2. **Данные и конфигурация:** установить источник и допустимость корпуса,
|
||||
формат, состав, checksum/manifest, размер и воспроизводимую цепочку импорта.
|
||||
Обязательный gate любой передачи/публикации корпуса — процедура
|
||||
[CORPUS_HANDOFF.md](../operations/CORPUS_HANDOFF.md): source → staging →
|
||||
inventory/manifest и checksum comparison → атомарная публикация
|
||||
проверенного staging-корпуса в `DATA_ROOT/minjust-normalized` → отдельная
|
||||
проверка и импорт в индекс → OpenSearch alias switch. Runbook требует
|
||||
непустые source и staging, явное решение по исключению code 6 и fail-closed
|
||||
при неполных/несовпадающих данных. Атомарная файловая публикация и
|
||||
возобновление/отказ после прерывания — обязательный, но ещё не реализованный
|
||||
gate; до описания и проверки all-or-nothing механизма не заменять им
|
||||
действующий корпус. Не смешивать его с атомарным переключением OpenSearch
|
||||
alias. Следовать runbook без копирования его команд сюда; до прохождения
|
||||
gates корпус в production не передавать и не индексировать. Не подменять
|
||||
корпус пустым каталогом. Production env/секреты создаются вне Git; в
|
||||
репозитории остаются только имена переменных и шаблоны без значений.
|
||||
3. **Build/CI:** PR-проверки не получают production secrets и Docker socket.
|
||||
Публикация образов должна фиксировать digest и provenance; release workflow
|
||||
не равен deployment. Production CD не включать до прохождения owner gates,
|
||||
безопасной сборки, backup и отдельного restore.
|
||||
4. **Контролируемое изменение существующего production:** последняя датированная
|
||||
проверка `https://akyldash.kg/review` вернула HTTP 200 25 октября 2026 года
|
||||
([CURRENT_STATE](../operations/CURRENT_STATE.md)); установленный на хосте
|
||||
SHA остался неизвестен. План относится к безопасному
|
||||
обновлению существующего сервиса, а не к первому развёртыванию. До изменения
|
||||
проверять реальные данные и конфигурацию, доступное место, опубликованные
|
||||
порты, права, совместимость схем и backup readiness. Выполнять через
|
||||
существующую ограниченную процедуру; не добавлять общие sudo/Docker-
|
||||
привилегии. Базы данных и внутренние сервисы не публиковать в интернет.
|
||||
DNS, reverse proxy и TLS
|
||||
проверять до выпуска; для Cloudflare применять Full (strict). SSH/UFW и
|
||||
сетевые ограничения меняются только после owner approval.
|
||||
5. **Проверка и recovery:** проверять health/API, RU/KY canaries, доступность
|
||||
HTTPS, сохранность данных и rollback в пределах его документированных
|
||||
ограничений. Data restore — отдельная процедура в staging, не автоматический
|
||||
побочный эффект отката образа.
|
||||
6. **Автоматизация:** только после одобренного отдельного bootstrap и
|
||||
end-to-end проверки; serial deployment, digest verification, backup ACK,
|
||||
health gates и отказ без destructive cleanup. Не включать триггер только
|
||||
потому, что workflow завершился зелёным.
|
||||
|
||||
**Backup/maintenance gate:** настройки CI, проверка конфигурации и подготовка
|
||||
release provenance должны быть отделены от production cutover. Production CD
|
||||
в `CURRENT_STATE.md` остаётся неактивным. Перед каждым фактическим production
|
||||
изменением, включая миграции, создать и проверить внешний pre-change backup.
|
||||
Полный cold backup требует остановки Compose и может выполняться только в
|
||||
заранее согласованное с владельцем окно.
|
||||
Ни полный архив, ни его временная копия никогда не записываются на VPS. По
|
||||
текущей утверждённой схеме архив передаётся напрямую на фиксированный
|
||||
`D:\Dev\akyldash\.backups`; фактическая готовность D:, SSH и отдельное
|
||||
восстановление всё ещё не подтверждены. На VPS остаётся только secret-free
|
||||
идентификатор/manifest согласно `BACKUP.md`. Политика — одна retained,
|
||||
проверенная внешняя копия. При замене прежняя копия сохраняется до тех пор,
|
||||
пока новый candidate не опубликован и не проверен; на время этой проверки две
|
||||
копии могут сосуществовать. После подтверждения новой копии retention может
|
||||
изменить только владелец явным решением; автоматическая очистка запрещена.
|
||||
При неудачной публикации или проверке прежняя подтверждённая копия остаётся.
|
||||
Локальные VPS cold-backup команды из старых документов superseded и
|
||||
использоваться не должны.
|
||||
|
||||
#### Локальные generated reports
|
||||
|
||||
`/reports/` и `/tmp/` игнорируются по умолчанию, чтобы generated bulk не
|
||||
попадал в обычный `git status`. Уже отслеживаемые historical evidence остаются
|
||||
отслеживаемыми. Если конкретный отчёт нужен в Git, сначала проверить его
|
||||
содержимое, удалить секреты, персональные данные, production corpus и
|
||||
временные файлы, затем добавить только предназначенный к публикации файл
|
||||
явной командой `git add -f reports/<path>`. Не добавлять каталоги целиком.
|
||||
|
||||
Нынешний статус не переопределяется этим roadmap: на сверенной документации
|
||||
production CD остаётся неактивным, а backup/restore и внешний bootstrap имеют
|
||||
непроверенные owner prerequisites. Все точные блокировки сверять с
|
||||
`CURRENT_STATE.md` и `DEPLOYMENT.md` перед планированием действий.
|
||||
|
||||
## 3. Визуальные материалы и исходная база
|
||||
|
||||
### Макеты Figma
|
||||
|
||||
Целевые редактируемые экраны — гипотезы компоновки, а не работающий интерфейс,
|
||||
источник юридических данных или доказательство доступности.
|
||||
|
||||
| Экран | Макет |
|
||||
|---|---|
|
||||
| 01. Поиск с документом, 1440×900 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
|
||||
| 02. Фильтры скрыты | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
|
||||
| 03. Начальный поиск | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
|
||||
| 04. Лаборатория и сохранение | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
|
||||
| 05. Кабинет: профиль и закрытие | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
|
||||
| 06. Кабинет: безопасность | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
|
||||
| 07. Источники | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
|
||||
| 08. Пользователи: отдельный экран | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
|
||||
| 09–11. Телефон: выдача, чтение и фильтры (общий Figma-узел) | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
|
||||
| 12. Эталон 1280×800 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
|
||||
| 13. Планшет: две панели | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
|
||||
| 14. Кабинет на телефоне | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
|
||||
| 15. Вход, создание и подтверждение удаления | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
|
||||
|
||||
Локальные target exports: [поиск](../design/tunduk-search-target.png),
|
||||
[лаборатория](../design/tunduk-review-target.png),
|
||||
[кабинет](../design/tunduk-account-target.png). Это изображения цели; они не
|
||||
заменяют браузерную проверку.
|
||||
|
||||
В Figma экраны собраны из редактируемого текста, Auto Layout, SVG, локальных
|
||||
переменных цвета/отступов и общих экземпляров кнопок; это не один растр.
|
||||
Состояния 09–11 ведут на один общий мобильный узел, как в исходном плане.
|
||||
Прокрутка длинной выдачи/текста показывает продолжение содержимого.
|
||||
Splitter, закрытие и формы описывают целевое поведение, но не являются
|
||||
работающим web-кодом.
|
||||
|
||||
### Исторические свидетельства
|
||||
|
||||
Аудит UI выполнен **23 октября 2026 года** в авторизованной сессии на
|
||||
`https://akyldash.kg/`, где footer показывал Frontend `0.2.3`, Backend
|
||||
`0.13.0`. Сверка SHA сервиса с checkout не выполнялась. Проверены
|
||||
1440×900, 1280×800, 1024×768, 390×844 CSS px и дополнительный browser viewport
|
||||
1064×1244. Пароли не вводились; создание/удаление пользователей и отправка
|
||||
оценки не выполнялись; полный физический touch, screen-reader и принтерный
|
||||
прогоны не выполнялись. Существующие локальные browser evidence и screenshots
|
||||
собраны в [отчёте visual-review](visual-review/README.md). Ветка `main` не
|
||||
содержит отдельный архив live screenshots этого аудита. Любое использование
|
||||
результатов требует повторной проверки актуальной версии и окружения.
|
||||
|
||||
## 4. Постоянные правила дизайна и полномочия
|
||||
|
||||
[DESIGN.md](../../DESIGN.md) остаётся нормативной дизайн-системой: цветовые
|
||||
токены, типографика, бренд, композиционные принципы и доступность не
|
||||
дублируются здесь. Этот roadmap задаёт работы, исторические критерии и gates;
|
||||
он не утверждает новый дизайн и не разрешает production deployment.
|
||||
|
||||
Все пункты плана отмечать выполненными только при наличии актуального
|
||||
свидетельства. Изменения приложения проходят обычные тесты, версионирование,
|
||||
CI и независимое review. Инфраструктурные изменения отражаются в
|
||||
`CURRENT_STATE.md` только по фактическим проверкам. Никакие действия с
|
||||
production, секретами, пользователями или правовым корпусом не следуют из
|
||||
наличия этого документа. Root-only bootstrap и предоставление секретов
|
||||
выполняет/утверждает владелец в защищённой среде; DEV готовит проверяемый код
|
||||
и точный план, reviewer независимо проверяет безопасность и failure paths.
|
||||
@@ -1,314 +0,0 @@
|
||||
# Единый план корректировки интерфейса «Акылдаш»
|
||||
|
||||
Дата аудита: **23 октября 2026 года**.
|
||||
Статус на 23 октября 2026 года: реализация внесена в ветку
|
||||
`feature/uiux-tunduk-corrections`; локальная браузерная проверка прошла.
|
||||
Ожидаются CI и ручное PR review.
|
||||
|
||||
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
|
||||
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
|
||||
Продуктовый roadmap и требования к юридической готовности остаются отдельными
|
||||
документами: они не являются конкурирующими планами исправления интерфейса.
|
||||
|
||||
## 1. Источники истины и границы
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
|
||||
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
|
||||
ориентир, а не образец юридических данных или текущих функций.
|
||||
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
|
||||
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
|
||||
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
|
||||
|
||||
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
|
||||
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
|
||||
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
|
||||
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
|
||||
документы не заменяют результаты этой браузерной проверки.
|
||||
|
||||
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
|
||||
Backend-проверки ролей и операций управления пользователями не меняются.
|
||||
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
|
||||
корпус не меняются. Реальные учётные записи не используются в регрессионном
|
||||
fixture.
|
||||
|
||||
## 2. Что проверено
|
||||
|
||||
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
|
||||
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
|
||||
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
|
||||
с более широким desktop учитываются как дополнительные свидетельства,
|
||||
но не выдаются за собственный браузерный прогон.
|
||||
|
||||
| Поверхность / сценарий | Выполнено | Ограничение |
|
||||
|---|---|---|
|
||||
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
|
||||
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
|
||||
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
|
||||
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
|
||||
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
|
||||
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
|
||||
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
|
||||
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
|
||||
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
|
||||
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
|
||||
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
|
||||
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
|
||||
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
|
||||
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
|
||||
|
||||
Снимки: [каталог аудита](../design/audit-2026-10-23/).
|
||||
Участки с реальными списками пользователей не переносятся в макеты Figma;
|
||||
там используются явно условные примеры. Часть снимков лаборатории сделана
|
||||
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
|
||||
|
||||
## 3. Реестр недостатков
|
||||
|
||||
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
|
||||
улучшение понятности и последовательности. Предложения обозначены отдельно
|
||||
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
|
||||
|
||||
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|
||||
|---|---|---|---|
|
||||
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
|
||||
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
|
||||
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
|
||||
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
|
||||
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
|
||||
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
|
||||
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
|
||||
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
|
||||
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
|
||||
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
|
||||
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
|
||||
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
|
||||
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
|
||||
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
|
||||
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
|
||||
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
|
||||
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
|
||||
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
|
||||
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
|
||||
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
|
||||
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
|
||||
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
|
||||
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
|
||||
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
|
||||
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
|
||||
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
|
||||
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
|
||||
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
|
||||
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
|
||||
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
|
||||
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
|
||||
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
|
||||
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
|
||||
|
||||
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
|
||||
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
|
||||
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
|
||||
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
|
||||
|
||||
## 4. Целевая сетка, изменение ширины и фильтры
|
||||
|
||||
Это согласованное направление корректировки, а не уже существующие функции.
|
||||
|
||||
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
|
||||
и лабораторный контент не вставляются друг в друга и не дублируют footer.
|
||||
2. На широком desktop три колонки с двумя доступными разделителями.
|
||||
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
|
||||
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
|
||||
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
|
||||
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
|
||||
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
|
||||
в отдельную панель, затем документ становится отдельным представлением.
|
||||
Пользовательское растягивание не может сломать соседнюю колонку.
|
||||
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
|
||||
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
|
||||
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
|
||||
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
|
||||
видимый и клавиатурный способ сброса. Положение хранится локально;
|
||||
при восстановлении ограничивается фактическим viewport.
|
||||
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
|
||||
колонки: краткие группы и раскрытие длинных справочников. Полный
|
||||
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
|
||||
Одиночный выбор API не выдаётся за множественный checkbox.
|
||||
6. Длинные названия органов открываются в панели выбора с полными строками;
|
||||
основное поле не становится бессмысленной обрезанной подписью.
|
||||
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
|
||||
ставятся друг под другом. Перенос текста внутри input недопустим.
|
||||
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
|
||||
документа допустима. Их заголовки/toolbar доступны при чтении.
|
||||
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
|
||||
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
|
||||
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
|
||||
и растущим main, а не `position: fixed` поверх содержимого. При длинном
|
||||
контенте footer следует после него. Панель сохранения имеет собственное
|
||||
место; sticky допускается только без перекрытия последней строки.
|
||||
|
||||
## 5. Единые правила всех окон и форм
|
||||
|
||||
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
|
||||
Закрытие не спрятано в конце длинного содержимого.
|
||||
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
|
||||
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
|
||||
Заголовок/закрытие остаются доступны при прокрутке содержимого.
|
||||
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
|
||||
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
|
||||
возвращается на инициатор. Фон не интерактивен. Исключения для
|
||||
обязательной смены пароля объясняются пользователю, не маскируются.
|
||||
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
|
||||
поверхности с понятным возвратом. Подтверждение удаления — короткое
|
||||
целевое окно, а не второй длинный редактор за первым.
|
||||
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
|
||||
к администрированию определяется сервером, редизайн не меняет права.
|
||||
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
|
||||
пароль с доступным именем; подтверждение нового пароля как защита от
|
||||
опечатки. Никакие реальные пароли не предзаполняются и не записываются
|
||||
в материалы аудита. Изменение пароля выполняет человек.
|
||||
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
|
||||
Enter отправляет только активную форму. Ошибки рядом с полем и в
|
||||
доступном сообщении, введённые допустимые данные сохраняются.
|
||||
- При несохранённом редактировании — понятная защита от случайной потери;
|
||||
обычное закрытие окна просмотра не требует лишнего подтверждения.
|
||||
- Loading исключает повторную отправку; при успехе сообщается результат;
|
||||
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
|
||||
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
|
||||
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
|
||||
|
||||
Основания для доступного поведения:
|
||||
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
||||
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||
(паттерн APG находится в разработке; использовать как ориентир),
|
||||
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
|
||||
Крестик и структура кабинета — также прямое требование пользователя.
|
||||
|
||||
## 6. Реализация и проверяемое покрытие
|
||||
|
||||
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
|
||||
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
|
||||
сценарию. Playwright fixture использует только условные записи `admin`,
|
||||
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
|
||||
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
|
||||
run завершился успешно.
|
||||
|
||||
| Пункты | Изменение | Регрессионное покрытие |
|
||||
|---|---|---|
|
||||
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
|
||||
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
|
||||
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
|
||||
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
|
||||
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
|
||||
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
|
||||
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
|
||||
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
|
||||
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
|
||||
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
|
||||
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
|
||||
|
||||
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
|
||||
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
|
||||
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
|
||||
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
|
||||
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
|
||||
реальный zoom 200–400% остаётся непроверенным.
|
||||
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
|
||||
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
|
||||
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
|
||||
Снимки, overlay и pixel-difference с целевым изображением сохранены в
|
||||
[каталоге visual review](visual-review/). Растровый diff является обзорной
|
||||
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
|
||||
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
|
||||
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
|
||||
|
||||
Не мигрировать существующий Python frontend на React/Next.js ради этих
|
||||
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
|
||||
|
||||
## 7. Приёмка: что значит «вылизанный интерфейс»
|
||||
|
||||
| Проверка | Условие |
|
||||
|---|---|
|
||||
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
|
||||
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
|
||||
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
|
||||
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
|
||||
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
|
||||
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
|
||||
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
|
||||
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
|
||||
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
|
||||
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
|
||||
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
|
||||
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
|
||||
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
|
||||
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
|
||||
|
||||
Для визуального сравнения использовать изолированный детерминированный fixture
|
||||
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
|
||||
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
|
||||
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
|
||||
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
|
||||
неработающий control или нечитаемый реальный контент.
|
||||
|
||||
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
|
||||
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
|
||||
процессу репозитория; автоматический reviewer не запускается. Production не
|
||||
меняется автоматически.
|
||||
|
||||
## 8. Материалы и оставшаяся проверка
|
||||
|
||||
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
|
||||
и не доказательство работоспособности функций. Учётные записи в ней
|
||||
обезличены, текст документа помечен как пример оформления.
|
||||
|
||||
| Figma-экран | Ссылка |
|
||||
|---|---|
|
||||
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
|
||||
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
|
||||
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
|
||||
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
|
||||
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
|
||||
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
|
||||
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
|
||||
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
|
||||
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
|
||||
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
|
||||
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
|
||||
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
|
||||
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
|
||||
|
||||
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
|
||||
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
|
||||
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
|
||||
обозначает намеренное продолжение содержимого. Drag-разделители,
|
||||
закрытие и формы — спецификация будущего поведения, не работающий web-код.
|
||||
|
||||
Локальные экспорты целевых экранов для просмотра без Figma:
|
||||
[поиск](../design/tunduk-search-target.png),
|
||||
[лаборатория](../design/tunduk-review-target.png),
|
||||
[кабинет](../design/tunduk-account-target.png).
|
||||
|
||||
Контрольные свидетельства live-сайта:
|
||||
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
|
||||
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
|
||||
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
|
||||
[кабинет](../design/audit-2026-10-23/account-desktop.png),
|
||||
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
|
||||
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
|
||||
|
||||
Browser evidence текущей ветки:
|
||||
[desktop 1280×800](visual-review/desktop-1280x800.png),
|
||||
[mobile 390 px](visual-review/mobile-390px.png),
|
||||
[Figma overlay](visual-review/overlay-50.png) и
|
||||
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
|
||||
различия компоновки между live-target frame и deterministic fixture; pixel
|
||||
diff не используется как pass/fail.
|
||||
|
||||
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
|
||||
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
|
||||
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
|
||||
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
|
||||
юридической корректности по законодательству не подтверждены этими тестами.
|
||||
|
||||
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
|
||||
смена пароля, удаление/создание пользователей и отправка оценки в production.
|
||||
Наличие макета не считается доказательством этих функций.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Функциональность платформы Акылдаш
|
||||
|
||||
Этот документ определяет целевой функциональный состав Акылдаша — юридической
|
||||
информационно-аналитической платформы для Кыргызской Республики. Пользователь
|
||||
сможет найти применимую норму, проверить источник и редакцию, сохранить
|
||||
материалы исследования и узнать об изменениях. Существенные юридические выводы
|
||||
проверяет специалист.
|
||||
|
||||
## Текущая реализация
|
||||
|
||||
Сверено с `main` на `871bde1`. В коде доступны полнотекстовый поиск по русским
|
||||
и кыргызским текстам, фильтры по языку, виду акта, статусу, группе органов,
|
||||
юридической силе и дате, сортировка по релевантности и дате, пагинация.
|
||||
Есть карточка документа, выбор редакции и доступного языка, оглавление,
|
||||
поиск в открытом тексте, печать и ссылка на официальный источник.
|
||||
Внутренние учётные записи поддерживают вход, смену пароля, серверную проверку
|
||||
прав, административное создание и удаление аккаунтов и сброс пароля.
|
||||
Лаборатория собирает оценки релевантности и комментарии проверяющих.
|
||||
Публичный запуск и готовность полного корпуса требуют подтверждения по
|
||||
[состоянию инфраструктуры](../operations/CURRENT_STATE.md).
|
||||
Telegram-бот команды описан в [решении о рабочем пространстве](../decisions/001-telegram-workspace-mvp.md).
|
||||
|
||||
## Правовые данные и условия публичного выпуска
|
||||
|
||||
- Получать открытые или лицензированные НПА и иные правовые материалы из
|
||||
официальных источников. Для каждого источника проверить законное основание,
|
||||
условия использования и распространения. Хранить происхождение, ссылку,
|
||||
лицензию, идентификаторы, контрольные суммы, даты получения и последней
|
||||
успешной проверки. Секреты и персональные данные исключать из Git, логов
|
||||
и тестовых наборов. Закрытые юридические документы хранить с поимённым
|
||||
доступом в защищённом хранилище.
|
||||
- Поддерживать регулярное обновление ЦБД Минюста КР с проверкой подлинности,
|
||||
полноты, языков, порядка редакций, вложений и объяснённых пропусков.
|
||||
Нормализовать HTML и структуру до статей и пунктов, сохранять реквизиты
|
||||
и двуязычные коды справочников. У каждого индексируемого документа
|
||||
проверять код, редакцию, язык, статус, тип, орган, дату, номер, название
|
||||
и ссылку на первоисточник. Показывать только доступные официальные
|
||||
языковые версии, явно отмечать отсутствие текста и неизвестный статус.
|
||||
Различать принятие, публикацию, вступление в силу, период применимости,
|
||||
редакцию и дату проверки. Проверять, какая редакция применима на нужную дату.
|
||||
- Вести журнал новых, изменённых и отменённых актов. Повторная обработка
|
||||
изменения идемпотентна. Цель обновления в течение суток после появления
|
||||
у источника измеряется на пилоте до принятия SLA.
|
||||
- Сборку индекса проверять в чистом окружении. Пройти цепочку от обновления
|
||||
корпуса и нормализации до нового версионированного индекса с раздельным
|
||||
анализом RU/KY, проверки mapping, числа документов и фрагментов, затем
|
||||
атомарного переключения alias. При неудаче сохранять рабочий индекс. Приёмка корпуса
|
||||
и выкладка описаны в [процедуре передачи корпуса](../operations/CORPUS_HANDOFF.md)
|
||||
и [плане реализации и приёмки](implementation-roadmap.md).
|
||||
- Собрать `relevance set v1` из 50 практических запросов, минимум по 25 на
|
||||
каждом языке. Юрист сверяет эталонные документы с первоисточником и кодами
|
||||
в индексе. Второй проверяющий подтверждает формулировки и коды. Спорные
|
||||
случаи включаются после согласования. Заморозить набор, воспроизвести
|
||||
Recall@10 и MRR@10 отдельно по языкам, сохранять выдачу для каждого запроса.
|
||||
Сравнивать изменения на том же наборе, проверять опечатки, сокращения,
|
||||
практические запросы и ложные срабатывания. См.
|
||||
[разметку](../../backend/search/RELEVANCE_ANNOTATION.md)
|
||||
и [диагностику поиска](search-relevance-findings-2026-11-03.md).
|
||||
- Утвердить [справочники v1](search-catalog-v1.md) типов, органов, статусов,
|
||||
уровней действия и тем со стабильными кодами и подписями RU/KY. Публичный
|
||||
тематический отбор добавляется после MVP. Проверить опубликованный
|
||||
OpenAPI и реальные интеграции на локальном OpenSearch.
|
||||
Зафиксировать совместимый контракт для поиска, фильтров, пагинации,
|
||||
сортировки, карточки, списка и содержимого редакций. Покрыть пустую
|
||||
выдачу, неизвестный документ, недоступную редакцию и сбой источника.
|
||||
API явно возвращает список доступных языков, в том числе для одноязычных актов.
|
||||
Маршруты и запуск описаны в [Backend README](../../backend/README.md#http-api-v1).
|
||||
- До проектирования публичного frontend зафиксировать сквозное прохождение
|
||||
перечисленных проверок с RU, KY и одноязычным документом, версии backend и
|
||||
индекса, известные ограничения и ответственного за юридическую проверку.
|
||||
До публичного выпуска проверить мониторинг ошибок и восстановление из
|
||||
резервной копии на изолированном окружении. Внутреннее рабочее место и
|
||||
лаборатория используют существующие API и развиваются по своим задачам.
|
||||
|
||||
## Публичный поисковый MVP
|
||||
|
||||
Публичный адаптивный веб-интерфейс на русском и кыргызском языках. Доступ к
|
||||
базовому поиску и чтению открыт без регистрации.
|
||||
|
||||
- **Поиск.** Свободный полнотекстовый запрос, раздельный анализ двух языков,
|
||||
морфология русского текста, проверенные синонимы, сокращения и опечатки.
|
||||
Для кыргызского текста отдельно проверять качество нормализации и словарей.
|
||||
Выдача показывает число результатов, подсвеченные фрагменты и пагинацию.
|
||||
- **Отбор.** Фильтры по языку, виду документа, статусу, органу, юридической
|
||||
силе и дате. Сортировка по релевантности и дате. Сброс отдельных и всех
|
||||
фильтров, сохранение состояния поиска в URL для повторного открытия.
|
||||
- **Документ.** Заголовок и реквизиты, актуальная редакция, юридический
|
||||
статус и дата актуальности, происхождение и официальный источник.
|
||||
Доступны оглавление, поиск по тексту с подсветкой и переходом между
|
||||
совпадениями, выбор доступного языка, список редакций, открытие предыдущей
|
||||
редакции и печать.
|
||||
Одноязычные документы и карточки без текста имеют явное состояние.
|
||||
- **Качество интерфейса.** Состояния загрузки, пустой выдачи и ошибок,
|
||||
клавиатурная навигация, адаптивные фильтры и длинные правовые тексты,
|
||||
WCAG 2.2 AA, безопасное отображение очищенного HTML, проверка
|
||||
производительности, метаданные страниц и версии frontend/backend в
|
||||
подвале. Дизайн опирается на [Түндүк](../../DESIGN.md). Перед выпуском
|
||||
пройти сквозные проверки поиска и чтения на 10–15 практических задачах.
|
||||
|
||||
## Поисковое рабочее место
|
||||
|
||||
- Реквизитный поиск с отдельными полями названия и текста, точным номером,
|
||||
открытием по коду и поиском без текстового запроса. Поддержать множественный
|
||||
выбор и определить правила AND/OR. Тематический отбор строить на
|
||||
иерархическом рубрикаторе КР с проверенными стабильными кодами.
|
||||
- Расширить паспорт документа. По мере готовности источников отображать
|
||||
точный орган принятия, территорию действия по классификации КР, данные
|
||||
регистрации и источник публикации. По результатам пилота добавить отбор
|
||||
по этим реквизитам и оценить сортировку по органу, виду, дате изменения
|
||||
и юридической силе с выбором направления.
|
||||
- Экспорт выбранной редакции, результатов и подборок в DOCX с сохранением
|
||||
структуры и выделений. Включать язык, редакцию, источник и время выгрузки.
|
||||
При копировании фрагмента указывать первоисточник. PDF сверх браузерной
|
||||
печати и RTF зависят от подтверждённой потребности.
|
||||
- Открывать несколько документов одновременно, сохранять контекст во вкладках
|
||||
и показывать материалы бок о бок. Приёмка рабочего места включает 20
|
||||
практических задач от запроса до нужного фрагмента, проверку экспорта
|
||||
нужной редакции и отсутствие ухудшения на замороженном наборе поиска.
|
||||
|
||||
## Личный кабинет и контроль изменений
|
||||
|
||||
- Выбрать модель пользовательского доступа. Для закрытого пилота подходят
|
||||
внутренние аккаунты, выданные администратором. Для персональных функций
|
||||
внешних пользователей определить регистрацию, подтверждение аккаунта и
|
||||
проверяемое восстановление доступа по email.
|
||||
- Хранить на сервере избранное, историю запросов и документов, именованные
|
||||
поиски и шаблоны фильтров, параметры выдачи, закладки и заметки к статьям и
|
||||
пунктам. Поддержать папки и подборки по проектам, доступ по приглашению
|
||||
или ссылке с проверкой прав и экспорт подборок. Персонализировать быстрые
|
||||
ссылки, избранные разделы и панель инструментов.
|
||||
- Публиковать ленту поступлений и обзор новых, изменённых и отменённых
|
||||
актов. Пользователь ставит документ или отдельную норму на контроль и
|
||||
получает сигнал об изменении редакции либо о новой связанной судебной
|
||||
практике после появления соответствующей коллекции.
|
||||
- Выбрать каналы уведомлений из email, Telegram и Web Push по результатам
|
||||
пилота. Предусмотреть согласие, настройки частоты и отключение.
|
||||
Проверять синхронизацию на втором устройстве, изоляцию аккаунтов,
|
||||
отзыв доступа, отключение и удаление контроля, отсутствие дубликатов
|
||||
событий. Документировать сроки хранения истории и удаление аккаунта.
|
||||
|
||||
## Юридическое сравнение и связи
|
||||
|
||||
- Сравнивать редакции бок о бок с подсветкой добавлений, исключений,
|
||||
перемещений и перенумерации. Различать технические и содержательные правки,
|
||||
отмечать утратившие силу фрагменты и показывать неопределённость.
|
||||
Юрист проверяет минимум 30 разнообразных пар. Юридическую применимость
|
||||
оценивать по реквизитам и источникам.
|
||||
- Показывать русский и кыргызский тексты одной редакции параллельно,
|
||||
сопоставляя только подтверждённые фрагменты.
|
||||
- Хранить прямые и обратные связи с другими нормами, актами, разъяснениями
|
||||
и судебной практикой. Показывать контекстный предпросмотр, цепочку норм
|
||||
и дерево или граф. Фиксировать источник, целевую редакцию и уверенность
|
||||
сопоставления. Отдельно обозначать непроверенные связи.
|
||||
- Выдавать проверенные вложения, сканы и графические копии при наличии
|
||||
в источнике и прав на распространение.
|
||||
|
||||
## Практические материалы и проверяемые ответы
|
||||
|
||||
- По результатам пилота оценить включение правоприменительной практики КР,
|
||||
писем и позиций госорганов, редакционных пояснений и экспертных
|
||||
комментариев. Проверять полноту, обновляемость, обезличивание и права
|
||||
на использование. Сначала выяснить покрытие международных актов
|
||||
существующим корпусом.
|
||||
- Подготовить типовые договоры, заявления, претензии и акты с заполняемыми
|
||||
полями и подсказками. Добавить инструкции, готовые решения, чек-листы,
|
||||
сроки и ответственные органы. Справочный раздел включает производственный
|
||||
календарь, сроки отчётности, ставки, курсы, индексы и реквизиты госорганов.
|
||||
Для справочных данных определить источник и частоту обновления.
|
||||
- Построить базу знаний для RAG поверх проверенного корпуса и структуры
|
||||
редакций. Сначала выдавать доказательства и точные цитаты с переходом
|
||||
к фрагменту, затем формировать краткий ответ. Отделять найденные факты
|
||||
от вывода, сообщать о недостатке данных и проверять актуальность нормы.
|
||||
Приёмка на замороженном экспертном наборе проверяет каждую ссылку,
|
||||
цитату и редакцию и требует отсутствия критических неподтверждённых
|
||||
юридических утверждений.
|
||||
|
||||
## Организации и интеграции
|
||||
|
||||
- Профили организаций, приглашение и удаление участников, роли и права
|
||||
юриста, бухгалтера, кадровика и администратора. Сервер проверяет доступ
|
||||
к материалам и срок действия прав. Журналировать административные и
|
||||
юридически значимые действия с соблюдением правил хранения данных.
|
||||
- Тарифы и права по продуктам, срок подписки, счета, платежи и сверка.
|
||||
Начать с одной модели подписки и канала оплаты после подтверждения
|
||||
спроса. Повторное событие обрабатывается один раз. Данные сохраняются
|
||||
при истечении подписки. Промокоды зависят от выбранной модели продаж.
|
||||
- Корпоративный API, структурированные выгрузки для дальнейшей обработки
|
||||
и интеграция с системами электронного документооборота с проверкой доступа.
|
||||
- Социальный вход, самостоятельные мобильный и десктопный клиенты,
|
||||
зарубежные коллекции, форум и пользовательские сообщения требуют
|
||||
отдельного решения о спросе, лицензиях и стоимости сопровождения.
|
||||
|
||||
## Порядок развития и проверка спроса
|
||||
|
||||
Данные и готовность выпуска предшествуют публичному MVP. Далее развивать
|
||||
поисковое рабочее место, личные функции и контроль изменений. Сравнение,
|
||||
связи и ответы зависят от проверенных редакций и структуры норм. Командные
|
||||
тарифы требуют доказанного повторного использования и готовности платить.
|
||||
Инфраструктурные и визуальные критерии ведутся в
|
||||
[плане реализации и приёмки](implementation-roadmap.md).
|
||||
|
||||
Исходная гипотеза из анализа «Токтом Онлайн» от 24 октября 2026 года —
|
||||
пилот для небольших юридических и бухгалтерских команд по ОсОО/ЖЧК,
|
||||
налогообложению и труду. Предложено провести 12–15 интервью, затем
|
||||
четырёхнедельный пилот с 10–15 специалистами на 20 обезличенных RU/KY-задачах.
|
||||
Юрист проверяет правильность по первоисточникам. Для каждого языка предложены
|
||||
пороги Recall@10 ≥ 0,90 и MRR@10 ≥ 0,75. Предложенный выигрыш медианного времени
|
||||
правильного решения составляет ≥ 25%, возвращаемость на четвёртой неделе
|
||||
составляет ≥ 60%. На пилоте также нужно подтвердить готовность минимум трёх
|
||||
организаций платить названную цену и отсутствие критических ошибок редакции
|
||||
или источника в приёмочных задачах. Пороги, сроки реализации и бюджет требуют
|
||||
подтверждения. Сужать сегмент, если нужные материалы недоступны законно или
|
||||
расходы на редакцию, поддержку и эксплуатацию делают тариф нежизнеспособным.
|
||||
|
||||
История требований сохранена в Git на `871bde1`. В основу этого документа
|
||||
легли Word-ТЗ поисковой СПС, обзор проекта, планы поискового frontend и
|
||||
готовности к нему, а также датированный анализ «Токтом Онлайн» (матрица
|
||||
G01–G25 и этапы A–F). Оценки конкурента относятся к датированному исследованию.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
|
||||
@@ -1,47 +0,0 @@
|
||||
# Обзор проекта
|
||||
|
||||
## Назначение
|
||||
|
||||
Акылдаш — юридическая информационно-аналитическая платформа. Она должна помочь
|
||||
пользователю находить применимые нормы и материалы, понимать их актуальность и
|
||||
получать ответ со ссылками на проверяемые первоисточники. Платформа не заменяет
|
||||
профессиональное юридическое заключение.
|
||||
|
||||
## Основные области
|
||||
|
||||
1. Получение открытых или лицензированных законов, актов и иных правовых
|
||||
источников.
|
||||
2. Очистка, нормализация, связывание редакций и сохранение происхождения данных.
|
||||
3. Хранение структурированных документов и поисковых индексов.
|
||||
4. Формирование базы знаний и RAG с обязательными ссылками на источники.
|
||||
5. Backend для доступа к данным и функциям платформы.
|
||||
6. Frontend для поиска, анализа и работы с результатами.
|
||||
7. Инструменты рабочего окружения команды, включая Telegram-бота.
|
||||
|
||||
Физическая структура каждой области появится вместе с её первой задачей. До
|
||||
этого список служит картой продукта, а не обещанием заранее выбранной
|
||||
архитектуры.
|
||||
|
||||
## Обязательные принципы
|
||||
|
||||
- Источник, дата получения, редакция и лицензия документа должны быть
|
||||
прослеживаемыми.
|
||||
- Ответ системы должен отделять найденный факт от вывода модели и ссылаться на
|
||||
конкретный первоисточник.
|
||||
- Актуальность правовых данных должна проверяться до использования в ответе.
|
||||
- Существенные юридические выводы проверяет человек.
|
||||
- Секреты и персональные данные не попадают в Git, логи и тестовые наборы.
|
||||
- Данные загружаются только при наличии законного основания и с соблюдением
|
||||
условий источника.
|
||||
|
||||
## Правовая защита проекта
|
||||
|
||||
Договоры, заявки, материалы об интеллектуальной собственности и другие
|
||||
закрытые юридические документы следует хранить отдельно от исходного кода: в
|
||||
закрытом репозитории или защищённом документном хранилище. Доступ выдаётся
|
||||
поимённо и только тем, кому он нужен. В основном репозитории можно хранить
|
||||
несекретный реестр документов, правила доступа и ссылки на место хранения.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -0,0 +1,318 @@
|
||||
# Диагностика релевантности поиска — 3 ноября 2026 года
|
||||
|
||||
## Решения и границы
|
||||
|
||||
Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
|
||||
терминологии, условный multi-index scoring и контракт для закрытия пробела
|
||||
корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
|
||||
прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
|
||||
экспертную оценку.
|
||||
|
||||
Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
|
||||
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
|
||||
относится к предшествующему составу ranking semantics; новые подписанные
|
||||
снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
|
||||
Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит
|
||||
этого отчёта и changelog меняет только документацию.
|
||||
|
||||
## Исходный benchmark
|
||||
|
||||
Зафиксированный набор содержит 7 500 русских формулировок: 15 тематических
|
||||
групп, по 500 запросов в каждой. Это 750 тем с десятью вариантами формулировки,
|
||||
а не 7 500 независимых правовых ситуаций. Исходный индекс
|
||||
`akyldash-fragments-v2` содержал около 42,6 млн фрагментов и занимал 11,2 ГБ.
|
||||
|
||||
| Показатель Акылдаш, исходный прогон | Результат |
|
||||
|---|---:|
|
||||
| Собранные выдачи | 7 500 / 7 500 |
|
||||
| Пустые выдачи | 6 |
|
||||
| Медиана / P95 ответа | 21,67 / 77,36 мс |
|
||||
| Совпадение основ темы в видимых названии/фрагменте, top 1 | 40,57% |
|
||||
| То же, top 3 / top 10 | 47,05% / 51,40% |
|
||||
|
||||
Сравнение с КонсультантПлюс в исходном отчёте — только диагностическое:
|
||||
юрисдикции и корпуса различаются. Совпадение основ темы не измеряет Recall,
|
||||
MRR, полноту ответа или юридическую правильность; российская выдача не является
|
||||
эталоном права Кыргызстана. Для вывода о качестве нужны экспертные релевантные
|
||||
коды, контрольный набор и отдельные метрики.
|
||||
|
||||
## Подтверждённые классы ошибок
|
||||
|
||||
14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
|
||||
предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
|
||||
нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
|
||||
триажа, а не заключение юриста или независимая экспертная проверка. Она не
|
||||
подтверждает полноту правового ответа. Неоценённые результаты не считались
|
||||
ошибками.
|
||||
|
||||
| Тема | Наблюдавшийся неверный приоритет | Вывод |
|
||||
|---|---:|---|
|
||||
| Расторжение договора аренды | 135066 | Решение по одной сделке с землёй не даёт общих оснований расторжения |
|
||||
| Международный коммерческий арбитраж | 26411 | Продажа земельных участков не регулирует арбитраж и отказ в признании решения |
|
||||
| Испытательный срок работника | 94603 | Земельный участок под завод/испытательный полигон совпадает словами, но не предметом |
|
||||
| Место жительства ребёнка | 50760 | Финансирование приюта не устанавливает порядок спора родителей |
|
||||
| Перевод студента в другой вуз | 14165 | Разовое финансирование студентов не устанавливает процедуру перевода |
|
||||
| Таможенные пошлины | 41854 | Индивидуальная отсрочка одному предприятию не задаёт общие правила |
|
||||
| Общий порядок предоставления земли | 54735, 54571 | Решения по отдельным участкам не являются общей процедурой |
|
||||
| Защита критической инфраструктуры | 59082 | Предоставление земли под завод не устанавливает защитные требования |
|
||||
| Купля-продажа / расторжение договора | 60053 | Расторжение одной сделки с госакциями не является общей нормой |
|
||||
| Подача искового заявления | 18516, 7115 | Отдельный спор и узкая льгота по пошлине не дают общего порядка подачи иска |
|
||||
| Принятие нормативных актов | 77190 | Первое чтение одного законопроекта не устанавливает общий процесс |
|
||||
| Подключение к водоснабжению | 135061 | Целевая помощь отдельным домохозяйствам не задаёт технические условия |
|
||||
|
||||
Проверенные варианты глобального повышения веса кодексов отвергнуты: вес 2 не
|
||||
исправил аренду и подачу иска, а вес 4 поднял Налоговый кодекс выше профильных
|
||||
источников по воинской обязанности и подаче иска. Общие веса или жёсткие
|
||||
приоритеты кодексов не добавлялись.
|
||||
|
||||
## Узкая терминологическая поправка
|
||||
|
||||
В исследованной редакции ГК КР II (код 5, редакция 460) глава 27 и статья 542
|
||||
связывают договор аренды с имущественным наймом; статьи 558–559 используют
|
||||
термин «имущественный наем». В полном тексте этой редакции буквальная фраза
|
||||
«расторжение договора аренды» не встречалась. Исходный запрос возвращал код
|
||||
135066 на первом месте, а формулировка «расторжение договора имущественного
|
||||
найма» — код 5.
|
||||
|
||||
Backend теперь сохраняет исходную строку и добавляет только для точного
|
||||
сочетания «договор [арендная форма]» вариант «договор [та же форма]
|
||||
имущественного найма». В частности, `договора аренды` превращается в
|
||||
`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
|
||||
или земельного участка, поднайма, найма работника или лизинга. Точный запрос
|
||||
остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
|
||||
не исключается из поиска.
|
||||
|
||||
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
|
||||
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
|
||||
оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
|
||||
поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
|
||||
выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
|
||||
проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
|
||||
фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
|
||||
подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
|
||||
выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
|
||||
сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый
|
||||
conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде
|
||||
жилья/земли, поднайме, трудовых отношениях, международном праве и KY
|
||||
формулировках выполнялся отдельно; rank-метрики не являются юридической
|
||||
оценкой.
|
||||
|
||||
## Условный scoring для явного multi-index scope
|
||||
|
||||
При поиске по явно настроенному comma-separated scope (`base,supplement`) API
|
||||
передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
|
||||
одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
|
||||
одиночные wildcard и alias не классифицируются по результату их expansion.
|
||||
DFS включается только при запятой в настроенной строке scope.
|
||||
Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
|
||||
Параметр применяется только к `/search`; фильтры и остальные API вызовы
|
||||
используют прежний режим.
|
||||
|
||||
OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
|
||||
frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
|
||||
точнее, но может быть медленнее. `search_type` поддерживается именно как
|
||||
параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
|
||||
Поэтому ограниченный режим использует глобальные statistics для явной
|
||||
multi-index конфигурации и не меняет behavior одиночного поиска.
|
||||
|
||||
На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
|
||||
одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
|
||||
Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
|
||||
изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
|
||||
индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о
|
||||
педагогических/научных работниках и одном запросе о болезнях животных top-1 не
|
||||
изменился. Эти шесть случаев требуют предметной оценки и не объявляются
|
||||
автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает
|
||||
аттестацию педагогических/научных работников с медицинской деятельностью, а
|
||||
случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не
|
||||
выставлялись; специальные веса или document IDs не добавлялись. Сравнение
|
||||
ограничено выборкой и не является разметкой юридической правильности.
|
||||
Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
|
||||
скорости: default всегда исполнялся первым, возможен cache/order bias.
|
||||
|
||||
Frozen experiment metadata is stored outside the repository in
|
||||
`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
|
||||
response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
|
||||
`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
|
||||
Выборка содержала base index (42 624 501 fragments) и read-only supplement
|
||||
(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
|
||||
отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
|
||||
персональные данные или выдачи документов.
|
||||
|
||||
Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
|
||||
прежнее тело запроса и неизменность single alias/index и filters. Read-only
|
||||
integration gate для root-owned runtime:
|
||||
|
||||
```bash
|
||||
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
|
||||
AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
|
||||
PYTHONPATH=backend python3 -m unittest \
|
||||
backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
|
||||
```
|
||||
|
||||
Root выполнил этот read-only gate на 8081 для `1c7ddf2`: фактический multi-index
|
||||
URI использовал DFS и подписанный token сохранял полный scope. Gate выполняет
|
||||
только поиск и не создаёт/удаляет индексы. Полный benchmark на том же code SHA
|
||||
и frozen baseline приведён ниже; 300-query experiment и более ранние прогоны
|
||||
остаются отдельными сравнительными срезами.
|
||||
|
||||
Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
|
||||
поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
|
||||
не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
|
||||
остаются помечены `search-8`. Для supplement действует отдельный rollback
|
||||
контракт ниже: отключение его подключения скрывает только добавленные редакции,
|
||||
не удаляя подготовленные файлы и базовый индекс.
|
||||
|
||||
## Итоговый pinned прогон search-8
|
||||
|
||||
Полный isolated прогон для executable SHA `1c7ddf2fcbd7c55920470e7ac52339fdab0366db`
|
||||
собрал 7 500 уникальных запросов с версией `search-8`. SHA-256 выдачи:
|
||||
`fc02e22e5b3d612813ffc0bdaf9892bb16ee4c29af3e1b77f79e58a0a45c8ec1`. Итог
|
||||
сопоставлен с frozen baseline и двумя явно указанными промежуточными режимами:
|
||||
|
||||
| Сравнение | Изменён порядок | Изменён top-1 |
|
||||
|---|---:|---:|
|
||||
| Исходный baseline `search-6` | 537 | 103 |
|
||||
| Code-only `726b1ee`, `search-7` | 521 | 93 |
|
||||
| Code + три акта, `e653f3b`, без DFS | 425 | 63 |
|
||||
|
||||
Это число изменений порядка, а не улучшений. Все шесть пустых выдач сохранились
|
||||
во всех четырёх прогонах. Среди ранее проверенных Codex (ИИ) пар с нулевой
|
||||
оценкой top-1 стало 26 вместо 28 в baseline; остальные 26 не объявлены
|
||||
исправленными, и новые выдачи автоматически не оценивались.
|
||||
|
||||
| Добавленный акт | Наблюдался в top-1 / top-3 / top-10 |
|
||||
|---|---:|
|
||||
| Трудовой кодекс КР `230021083` | 24 / 58 / 105 |
|
||||
| Земельный кодекс КР `230029465` | 4 / 11 / 26 |
|
||||
| Закон об охране здоровья `230000918` | 34 / 51 / 92 |
|
||||
|
||||
Все шесть точных названий новых актов на RU/KY дали ожидаемый текущий акт первым.
|
||||
Все 284 запроса, общие с целевым DFS экспериментом, совпали с ним по полному
|
||||
порядку кодов и редакций. Проверки dataset guard подтвердили неизменность
|
||||
исходного индекса, manifest, mappings и UUID/count, а также 115 подключённых
|
||||
файлов. 7 503 записи локальной лаборатории совпали с backup; обе SQLite
|
||||
integrity checks прошли. Записи оценок, комментарии и пользователи не менялись.
|
||||
|
||||
Шесть межтематических контекстов нельзя автоматически считать нерелевантными:
|
||||
медицинский закон остался первым в пяти запросах о педагогических/научных
|
||||
работниках и в одном запросе о болезнях животных. Для педагогических случаев
|
||||
проверенная статья 150(8) связывает аттестацию этих работников с медицинской
|
||||
деятельностью; новые оценки пар не выставлялись. Вопрос о животных также
|
||||
остался без экспертной оценки. ИИ-аннотации не доказывают полноту правового
|
||||
ответа или юридическую правильность ранжирования.
|
||||
|
||||
Режим и scope дополнительно проверены на runtime `8081`: для multi-index `/search`
|
||||
уходит DFS, для одиночного индекса и filters — прежний default; тело DSL
|
||||
одинаково. Alias проверен transport mock без создания alias. Runtime mode/API
|
||||
smoke включал HMAC/TTL, signed scope, сохранение пустого результата и
|
||||
отрицательные rank/code сценарии. Browser UI fixture прошёл на `726b1ee`; UI
|
||||
побайтно не менялся в следующих SHA, но отдельный browser прогон на `1c7ddf2`
|
||||
не выполнялся. Медиана backend `23.685 мс` описательна и не является SLO или
|
||||
доказательством ускорения.
|
||||
|
||||
Эксперимент подключал только три акта в отдельном supplement и не доказывает
|
||||
полноту всего корпуса, актуальность всех редакций, наличие вложений или
|
||||
production-готовность. Отключение overlay/supplement скроет эти новые редакции
|
||||
из локального поиска/API, но сохранит подготовленные файлы, review-записи и
|
||||
исходный индекс. Production не менялся. Подробный технический отчёт и raw
|
||||
benchmark остаются во внешнем рабочем наборе и в PR не включены.
|
||||
|
||||
## Пустые выдачи в лаборатории
|
||||
|
||||
Исходный контракт блокировал сохранение шести подписанных пустых снимков:
|
||||
API отвергал `results: []`, а интерфейс читал поля из пустого placeholder.
|
||||
Теперь signed snapshot может сохранить пустую оценку с обязательным общим
|
||||
комментарием. В записи `top_result_code` остаётся `null`, список результатов
|
||||
пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно
|
||||
один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая
|
||||
аутентификация не меняются.
|
||||
|
||||
Подписанный снимок теперь сохраняет scope запроса при явно настроенном
|
||||
multi-index выражении (`A,B`): provenance не сужается до индекса, который
|
||||
случайно вернул единственный результат, и сохраняется при пустой выдаче. Для
|
||||
одного настроенного индекса или alias остаётся прежнее поведение: единственный
|
||||
concrete `_index` записывается как provenance; если OpenSearch не сообщил его,
|
||||
сохраняется настроенный scope. Новые отзывы получают эту метку при сохранении.
|
||||
SQLite-схема, исторические записи и формат подписанных токенов не меняются.
|
||||
Откат к старому коду не требует миграции и не переписывает существующие
|
||||
отзывы; пока работает откатанная версия, новые multi-index отзывы снова могут
|
||||
получить суженную метку, поэтому исправление provenance следует вернуть вместе
|
||||
с основным выпуском.
|
||||
|
||||
Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
|
||||
сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
|
||||
оценок результатов остались пустыми намеренно. Integrity check прошёл,
|
||||
порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
|
||||
переписывались. В последующем изолированном trial шесть реальных подписанных
|
||||
пустых отзывов с общими комментариями были сохранены как записи 7498–7503;
|
||||
итоговые 7 503 строки лаборатории совпали с backup, integrity check прошёл.
|
||||
Пустые query-level отзывы не являются оценками отдельных документов.
|
||||
|
||||
## Пробел актуального корпуса и безопасный контракт закрытия
|
||||
|
||||
Отдельно от ранжирования, в исходном base index не было трёх
|
||||
подтверждённых действующих актов (все три canonical ID имели `_count = 0`):
|
||||
|
||||
| Акт | Canonical ID | Последняя проверенная редакция |
|
||||
|---|---:|---|
|
||||
| Трудовой кодекс КР от 23.01.2025 № 23 | 230021083 | 57548 от 28.07.2026 |
|
||||
| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
|
||||
| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
|
||||
|
||||
Это разрыв между нормализованными документами и индексом, а не полное
|
||||
отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
|
||||
canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
|
||||
обработанных документов. Однако трудовой акт там отстаёт от официальной
|
||||
редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
|
||||
земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
|
||||
последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical
|
||||
ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
|
||||
или поднять как замену актуальным нормам.
|
||||
|
||||
Для локальной изолированной проверки подготовлены точные официальные RU/KY
|
||||
редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно
|
||||
подключены отдельным supplement-index и доступны API через read-only overlay
|
||||
catalogues. Это не изменение base index или production и не доказательство
|
||||
полноты корпуса. Вложения и полная история новых canonical ID отдельно не
|
||||
подтверждены.
|
||||
|
||||
Закрытие пробела требует отдельного контролируемого обновления данных:
|
||||
|
||||
1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить
|
||||
исходный ответ, хеши и provenance в отдельном staging-каталоге.
|
||||
2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
|
||||
вложения; не импортировать метаданные с ненужными персональными полями.
|
||||
3. Нормализовать документы отдельно и проверить exact-count/hash. Для
|
||||
ограниченного дополнения собрать отдельный версионный индекс только из этих
|
||||
актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
|
||||
4. Изолированный read-only trial уже проверил отдельный supplement-index,
|
||||
подключённые каталоги `data_root`, RU/KY выдачу, current-edition routes,
|
||||
collapse и контрольные запросы. Перед новым подключением нужно повторно
|
||||
сверить official source и dataset guards; trial не записывал новые документы
|
||||
в base index и не менял alias.
|
||||
5. Публикация требует отдельного владельческого разрешения и проверенного
|
||||
rollback. При отключении локального supplement-index поиск и просмотр новых
|
||||
редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы
|
||||
сохраняются, базовый индекс не удаляется. PR
|
||||
фиксирует код и результаты локального испытания; production, base index и
|
||||
исходные данные не менялись.
|
||||
|
||||
## Что остаётся проверить
|
||||
|
||||
- Финальный code/data эксперимент и full 7 500 run относятся к executable SHA
|
||||
`1c7ddf2`; текущий follow-up commit меняет только этот report и changelog.
|
||||
CI и полное независимое ревью должны повторно пройти на новом PR head.
|
||||
- Browser fixture пустой выдачи прошёл на `726b1ee`; UI побайтно неизменён в
|
||||
следующих commit. Для `1c7ddf2` выполнены реальные API/runtime smokes, но новый
|
||||
полный browser run не заявляется.
|
||||
- Три supplement-акта не подтверждают полноту всего корпуса, полную историю,
|
||||
вложения, лицензирование или production readiness. Для новой публикации нужен
|
||||
свежий source/edition check и отдельное разрешение владельца.
|
||||
- Измерения top-k, AI-разметка и технические smoke checks не заменяют независимую
|
||||
экспертную оценку юридической релевантности.
|
||||
|
||||
Исходные измерения и хеши хранятся вне репозитория в рабочем наборе
|
||||
`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы
|
||||
MinJust, диагностические логи, внешние пользовательские данные или выгрузка
|
||||
лабораторных комментариев.
|
||||
Binary file not shown.
+97
-32
@@ -17,6 +17,7 @@ import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import BinaryIO
|
||||
|
||||
COMPONENTS = {
|
||||
"data",
|
||||
@@ -68,7 +69,7 @@ def read_manifest(archive: tarfile.TarFile) -> dict:
|
||||
stream = archive.extractfile("manifest.json")
|
||||
if stream is None:
|
||||
raise ValueError("backup manifest is unreadable")
|
||||
manifest = json.load(stream)
|
||||
manifest = json.loads(stream.read().decode("utf-8"), object_pairs_hook=unique_json_object)
|
||||
components = manifest.get("components") if isinstance(manifest, dict) else None
|
||||
if (
|
||||
not isinstance(manifest, dict)
|
||||
@@ -90,6 +91,15 @@ def read_manifest(archive: tarfile.TarFile) -> dict:
|
||||
return manifest
|
||||
|
||||
|
||||
def unique_json_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate backup manifest field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def verify_archive(path: Path) -> dict:
|
||||
checksum_path = Path(f"{path}.sha256")
|
||||
expected = checksum_path.read_text(encoding="ascii").split()[0]
|
||||
@@ -99,6 +109,31 @@ def verify_archive(path: Path) -> dict:
|
||||
return read_manifest(archive)
|
||||
|
||||
|
||||
def verify_external_archive(path: Path, *, expected_sha256: str, expected_manifest_sha256: str) -> dict:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("external backup must be a regular file")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256) or digest(path) != expected_sha256:
|
||||
raise ValueError("external backup SHA-256 does not match the live session")
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
manifest_member = next(member for member in checked_members(archive) if member.name == "manifest.json")
|
||||
stream = archive.extractfile(manifest_member)
|
||||
if stream is None:
|
||||
raise ValueError("external backup manifest is unreadable")
|
||||
manifest_bytes = stream.read(1024 * 1024 + 1)
|
||||
manifest = read_manifest(archive)
|
||||
canonical_manifest = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(manifest_bytes) > 1024 * 1024 or hashlib.sha256(canonical_manifest).hexdigest() != expected_manifest_sha256:
|
||||
raise ValueError("external backup manifest does not match the live session")
|
||||
for member in archive.getmembers():
|
||||
if member.isfile():
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ValueError(f"external backup member is unreadable: {member.name}")
|
||||
while stream.read(1024 * 1024):
|
||||
pass
|
||||
return manifest
|
||||
|
||||
|
||||
def parse_sources(values: list[str], required: set[str] = COMPONENTS) -> dict[str, Path]:
|
||||
sources: dict[str, Path] = {}
|
||||
for value in values:
|
||||
@@ -277,43 +312,14 @@ def create_backup(
|
||||
for name in ("production-env", "review-secret"):
|
||||
if sources[name] is None or not sources[name].is_file():
|
||||
raise ValueError(f"sensitive component must be a regular file: {name}")
|
||||
if sources["data"] is not None and sources["data"].is_dir():
|
||||
check_sqlite(sources["data"])
|
||||
|
||||
manifest = {
|
||||
"format": 1,
|
||||
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"git_revision": revision,
|
||||
"images": images or {},
|
||||
"deployment_files": deployment_files or {},
|
||||
"components": sorted(sources),
|
||||
}
|
||||
manifest = backup_manifest(sources, revision, images, deployment_files)
|
||||
with tempfile.NamedTemporaryFile(dir=parent, prefix=".akyldash-backup-", delete=False) as temp:
|
||||
temporary = Path(temp.name)
|
||||
os.chmod(temporary, 0o600)
|
||||
checksum_temp = Path(f"{temporary}.sha256")
|
||||
try:
|
||||
with tarfile.open(temporary, "w:gz") as archive:
|
||||
encoded = json.dumps(manifest, sort_keys=True).encode("utf-8")
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(encoded)
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(encoded))
|
||||
for name, source in sorted(sources.items()):
|
||||
if source is None:
|
||||
info = tarfile.TarInfo(f"{name}/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o700
|
||||
archive.addfile(info)
|
||||
continue
|
||||
def protect_secrets(member: tarfile.TarInfo) -> tarfile.TarInfo:
|
||||
if name in {"production-env", "review-secret"}:
|
||||
member.mode = 0o600
|
||||
if member.name.endswith((".sqlite3", ".sqlite3-wal", ".sqlite3-shm")):
|
||||
member.mode = 0o600
|
||||
return member
|
||||
|
||||
archive.add(source, arcname=name, recursive=True, filter=protect_secrets)
|
||||
write_backup(archive, sources, manifest)
|
||||
with tarfile.open(temporary, "r:gz") as archive:
|
||||
read_manifest(archive)
|
||||
checksum_fd = os.open(checksum_temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
@@ -330,6 +336,65 @@ def create_backup(
|
||||
checksum_temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def backup_manifest(
|
||||
sources: dict[str, Path | None], revision: str, images: dict | None, deployment_files: dict[str, str] | None
|
||||
) -> dict:
|
||||
for name, source in sources.items():
|
||||
if source is not None and (source.is_symlink() or not (source.is_dir() or source.is_file())):
|
||||
raise ValueError(f"source must be a regular file or directory: {name}")
|
||||
for name in ("production-env", "review-secret"):
|
||||
if sources[name] is None or not sources[name].is_file():
|
||||
raise ValueError(f"sensitive component must be a regular file: {name}")
|
||||
if sources["data"] is not None and sources["data"].is_dir():
|
||||
check_sqlite(sources["data"])
|
||||
return {
|
||||
"format": 1,
|
||||
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"git_revision": revision,
|
||||
"images": images or {},
|
||||
"deployment_files": deployment_files or {},
|
||||
"components": sorted(sources),
|
||||
}
|
||||
|
||||
|
||||
def write_backup(archive: tarfile.TarFile, sources: dict[str, Path | None], manifest: dict) -> None:
|
||||
encoded = json.dumps(manifest, sort_keys=True).encode("utf-8")
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(encoded)
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(encoded))
|
||||
for name, source in sorted(sources.items()):
|
||||
if source is None:
|
||||
info = tarfile.TarInfo(f"{name}/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o700
|
||||
archive.addfile(info)
|
||||
continue
|
||||
|
||||
def protect_secrets(member: tarfile.TarInfo) -> tarfile.TarInfo:
|
||||
if name in {"production-env", "review-secret"}:
|
||||
member.mode = 0o600
|
||||
if member.name.endswith((".sqlite3", ".sqlite3-wal", ".sqlite3-shm")):
|
||||
member.mode = 0o600
|
||||
return member
|
||||
|
||||
archive.add(source, arcname=name, recursive=True, filter=protect_secrets)
|
||||
|
||||
|
||||
def create_backup_stream(
|
||||
output: BinaryIO, sources: dict[str, Path | None], revision: str,
|
||||
images: dict | None = None, deployment_files: dict[str, str] | None = None,
|
||||
manifest: dict | None = None,
|
||||
) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("external production backup must run as root")
|
||||
manifest = manifest or backup_manifest(sources, revision, images, deployment_files)
|
||||
with tarfile.open(fileobj=output, mode="w|gz") as archive:
|
||||
write_backup(archive, sources, manifest)
|
||||
output.flush()
|
||||
return manifest
|
||||
|
||||
|
||||
def restore_to_staging(backup: Path, target: Path) -> None:
|
||||
if not target.name.startswith("akyldash-restore-"):
|
||||
raise ValueError("restore target must be a new akyldash-restore-* staging directory")
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Bounded binary protocol for one live owner-controlled external backup session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import datetime as dt
|
||||
from typing import BinaryIO
|
||||
|
||||
MAGIC = b"AKYL"
|
||||
ACK_MAGIC = b"AKYA"
|
||||
CHALLENGE_MAGIC = b"AKYB"
|
||||
RESULT_MAGIC = b"AKYR"
|
||||
VERSION = 1
|
||||
CHUNK_BYTES = 1024 * 1024
|
||||
MAX_CONTROL_BYTES = 64 * 1024
|
||||
MAX_ARCHIVE_BYTES = 100 * 1024**3
|
||||
BACKUP_ID_RE = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\Z")
|
||||
SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
NONCE_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
|
||||
|
||||
def nonce() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def _json_frame(writer: BinaryIO, magic: bytes, value: dict) -> None:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(payload) > MAX_CONTROL_BYTES:
|
||||
raise ValueError("external backup control message exceeds its size limit")
|
||||
writer.write(magic + struct.pack("!I", len(payload)) + payload)
|
||||
writer.flush()
|
||||
|
||||
|
||||
def _read_exact(reader: BinaryIO, length: int) -> bytes:
|
||||
parts = bytearray()
|
||||
while len(parts) < length:
|
||||
block = reader.read(length - len(parts))
|
||||
if not block:
|
||||
raise ValueError("external backup session ended before a complete protocol frame")
|
||||
parts.extend(block)
|
||||
return bytes(parts)
|
||||
|
||||
|
||||
def _read_json_frame(reader: BinaryIO, magic: bytes) -> dict:
|
||||
if _read_exact(reader, len(magic)) != magic:
|
||||
raise ValueError("external backup protocol version or frame type is invalid")
|
||||
return _read_json_after_magic(reader)
|
||||
|
||||
|
||||
def _read_json_after_magic(reader: BinaryIO) -> dict:
|
||||
size = struct.unpack("!I", _read_exact(reader, 4))[0]
|
||||
if size > MAX_CONTROL_BYTES:
|
||||
raise ValueError("external backup control message exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(_read_exact(reader, size), object_pairs_hook=_unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("external backup control message is invalid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("external backup control message must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _unique_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate external backup field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_request(writer: BinaryIO, request: dict) -> None:
|
||||
validate_request(request)
|
||||
_json_frame(writer, MAGIC, request)
|
||||
|
||||
|
||||
def read_request(reader: BinaryIO) -> dict:
|
||||
request = _read_json_frame(reader, MAGIC)
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def validate_request(request: object) -> dict:
|
||||
if (not isinstance(request, dict) or type(request.get("version")) is not int
|
||||
or request["version"] != VERSION):
|
||||
raise ValueError("external backup request has an unsupported version")
|
||||
if request.get("action") == "release" and set(request) == {
|
||||
"version", "action", "run_id", "commit", "descriptor_sha256"
|
||||
}:
|
||||
if (not isinstance(request["run_id"], str) or not request["run_id"].isdigit()
|
||||
or not isinstance(request["commit"], str) or not re.fullmatch(r"[0-9a-f]{40}", request["commit"])
|
||||
or not isinstance(request["descriptor_sha256"], str)
|
||||
or not SHA256_RE.fullmatch(request["descriptor_sha256"])):
|
||||
raise ValueError("external deployment request identity is invalid")
|
||||
return request
|
||||
if request.get("action") == "rollback" and set(request) == {
|
||||
"version", "action", "data_compatible"
|
||||
} and request["data_compatible"] is True:
|
||||
return request
|
||||
raise ValueError("external deployment request has unexpected fields or action")
|
||||
|
||||
|
||||
def read_header(reader: BinaryIO) -> dict:
|
||||
header = _read_json_frame(reader, MAGIC)
|
||||
return validate_header(header)
|
||||
|
||||
|
||||
def validate_header(header: object) -> dict:
|
||||
if not isinstance(header, dict):
|
||||
raise ValueError("external backup header is invalid")
|
||||
if set(header) != {"version", "nonce", "backup_id", "manifest", "manifest_sha256", "context"}:
|
||||
raise ValueError("external backup header has unexpected fields")
|
||||
if (type(header["version"]) is not int or header["version"] != VERSION
|
||||
or not isinstance(header["nonce"], str)
|
||||
or not NONCE_RE.fullmatch(header["nonce"]) or not isinstance(header["backup_id"], str)
|
||||
or not BACKUP_ID_RE.fullmatch(header["backup_id"]) or not isinstance(header["manifest"], dict)
|
||||
or not isinstance(header["manifest_sha256"], str)
|
||||
or not SHA256_RE.fullmatch(header["manifest_sha256"])):
|
||||
raise ValueError("external backup header is invalid")
|
||||
canonical = json.dumps(header["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != header["manifest_sha256"]:
|
||||
raise ValueError("external backup manifest identity does not match its header")
|
||||
if not isinstance(header["context"], dict):
|
||||
raise ValueError("external backup context is invalid")
|
||||
return header
|
||||
|
||||
|
||||
def read_response(reader: BinaryIO) -> tuple[str, dict]:
|
||||
magic = _read_exact(reader, 4)
|
||||
value = _read_json_after_magic(reader) if magic in {MAGIC, RESULT_MAGIC} else None
|
||||
if magic == MAGIC:
|
||||
return "header", validate_header(value)
|
||||
if magic == RESULT_MAGIC:
|
||||
result = value
|
||||
if (set(result) != {"version", "status", "message"} or type(result["version"]) is not int
|
||||
or result["version"] != VERSION
|
||||
or result["status"] not in {"success", "error"} or not isinstance(result["message"], str)):
|
||||
raise ValueError("external backup final result is invalid")
|
||||
return "result", result
|
||||
raise ValueError("external deployment response frame is invalid")
|
||||
|
||||
|
||||
def write_header(writer: BinaryIO, *, session_nonce: str, backup_id: str, manifest: dict,
|
||||
context: dict | None = None) -> str:
|
||||
if not NONCE_RE.fullmatch(session_nonce) or not BACKUP_ID_RE.fullmatch(backup_id):
|
||||
raise ValueError("external backup session identity is invalid")
|
||||
manifest_bytes = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest()
|
||||
_json_frame(writer, MAGIC, {
|
||||
"version": VERSION,
|
||||
"nonce": session_nonce,
|
||||
"backup_id": backup_id,
|
||||
"manifest": manifest,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"context": context or {},
|
||||
})
|
||||
return manifest_sha256
|
||||
|
||||
|
||||
class ArchiveFrameWriter:
|
||||
def __init__(self, writer: BinaryIO, *, maximum: int = MAX_ARCHIVE_BYTES):
|
||||
self.writer = writer
|
||||
self.maximum = maximum
|
||||
self.size = 0
|
||||
self.digest = hashlib.sha256()
|
||||
self.buffer = bytearray()
|
||||
self.closed = False
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
if self.closed:
|
||||
raise ValueError("external backup stream is already closed")
|
||||
self.size += len(data)
|
||||
if self.size > self.maximum:
|
||||
raise ValueError("external backup archive exceeds its size limit")
|
||||
self.digest.update(data)
|
||||
remaining = memoryview(data)
|
||||
while remaining:
|
||||
take = min(CHUNK_BYTES - len(self.buffer), len(remaining))
|
||||
self.buffer.extend(remaining[:take])
|
||||
remaining = remaining[take:]
|
||||
if len(self.buffer) == CHUNK_BYTES:
|
||||
self._flush_chunk(CHUNK_BYTES)
|
||||
return len(data)
|
||||
|
||||
def _flush_chunk(self, size: int) -> None:
|
||||
block = bytes(self.buffer[:size])
|
||||
del self.buffer[:size]
|
||||
self.writer.write(struct.pack("!I", len(block)))
|
||||
self.writer.write(block)
|
||||
|
||||
def finish(self) -> tuple[int, str]:
|
||||
if self.closed:
|
||||
raise ValueError("external backup stream is already closed")
|
||||
if self.buffer:
|
||||
self._flush_chunk(len(self.buffer))
|
||||
digest = self.digest.hexdigest()
|
||||
self.writer.write(struct.pack("!I", 0) + struct.pack("!Q", self.size) + bytes.fromhex(digest))
|
||||
self.writer.flush()
|
||||
self.closed = True
|
||||
return self.size, digest
|
||||
|
||||
|
||||
def receive_archive(reader: BinaryIO, output: BinaryIO, *, maximum: int = MAX_ARCHIVE_BYTES) -> tuple[int, str]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
while True:
|
||||
length = struct.unpack("!I", _read_exact(reader, 4))[0]
|
||||
if length == 0:
|
||||
expected_size = struct.unpack("!Q", _read_exact(reader, 8))[0]
|
||||
expected_digest = _read_exact(reader, 32).hex()
|
||||
break
|
||||
if length > CHUNK_BYTES or size + length > maximum:
|
||||
raise ValueError("external backup archive frame exceeds its size limit")
|
||||
chunk = _read_exact(reader, length)
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
size += length
|
||||
output.flush()
|
||||
actual_digest = digest.hexdigest()
|
||||
if size != expected_size or actual_digest != expected_digest:
|
||||
raise ValueError("external backup byte count or SHA-256 does not match its stream trailer")
|
||||
return size, actual_digest
|
||||
|
||||
|
||||
def write_ack(writer: BinaryIO, *, session_nonce: str, backup_id: str, size: int,
|
||||
archive_sha256: str, manifest_sha256: str, verified: bool) -> None:
|
||||
if (not NONCE_RE.fullmatch(session_nonce) or not BACKUP_ID_RE.fullmatch(backup_id)
|
||||
or type(size) is not int or not 0 < size <= MAX_ARCHIVE_BYTES
|
||||
or not SHA256_RE.fullmatch(archive_sha256) or not SHA256_RE.fullmatch(manifest_sha256)
|
||||
or verified is not True):
|
||||
raise ValueError("external backup acknowledgement is invalid")
|
||||
_json_frame(writer, ACK_MAGIC, {
|
||||
"version": VERSION,
|
||||
"nonce": session_nonce,
|
||||
"backup_id": backup_id,
|
||||
"size": size,
|
||||
"archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"verified": verified,
|
||||
})
|
||||
|
||||
|
||||
def write_challenge(writer: BinaryIO, identity: dict, *, session_nonce: str) -> None:
|
||||
if (not NONCE_RE.fullmatch(session_nonce) or not isinstance(identity, dict)
|
||||
or set(identity) != {"backup_id", "size", "archive_sha256", "manifest_sha256", "manifest"}):
|
||||
raise ValueError("external rollback challenge is invalid")
|
||||
if (not isinstance(identity["backup_id"], str) or not BACKUP_ID_RE.fullmatch(identity["backup_id"])
|
||||
or type(identity["size"]) is not int or not 0 < identity["size"] <= MAX_ARCHIVE_BYTES
|
||||
or not isinstance(identity["archive_sha256"], str) or not SHA256_RE.fullmatch(identity["archive_sha256"])
|
||||
or not isinstance(identity["manifest_sha256"], str) or not SHA256_RE.fullmatch(identity["manifest_sha256"])
|
||||
or not isinstance(identity["manifest"], dict)):
|
||||
raise ValueError("external rollback backup identity is invalid")
|
||||
canonical = json.dumps(identity["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != identity["manifest_sha256"]:
|
||||
raise ValueError("external rollback manifest identity is inconsistent")
|
||||
_json_frame(writer, CHALLENGE_MAGIC, {"version": VERSION, "nonce": session_nonce, **identity})
|
||||
|
||||
|
||||
def read_challenge(reader: BinaryIO) -> dict:
|
||||
value = _read_json_frame(reader, CHALLENGE_MAGIC)
|
||||
if (set(value) != {"version", "nonce", "backup_id", "size", "archive_sha256", "manifest_sha256", "manifest"}
|
||||
or type(value["version"]) is not int or value["version"] != VERSION
|
||||
or not isinstance(value["nonce"], str)
|
||||
or not NONCE_RE.fullmatch(value["nonce"]) or not isinstance(value["backup_id"], str)
|
||||
or not BACKUP_ID_RE.fullmatch(value["backup_id"]) or type(value["size"]) is not int
|
||||
or not 0 < value["size"] <= MAX_ARCHIVE_BYTES
|
||||
or not isinstance(value["archive_sha256"], str) or not SHA256_RE.fullmatch(value["archive_sha256"])
|
||||
or not isinstance(value["manifest_sha256"], str) or not SHA256_RE.fullmatch(value["manifest_sha256"])
|
||||
or not isinstance(value["manifest"], dict)):
|
||||
raise ValueError("external rollback challenge is invalid")
|
||||
canonical = json.dumps(value["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != value["manifest_sha256"]:
|
||||
raise ValueError("external rollback challenge manifest identity is inconsistent")
|
||||
return value
|
||||
|
||||
|
||||
def read_ack(reader: BinaryIO, *, session_nonce: str, backup_id: str, size: int,
|
||||
archive_sha256: str, manifest_sha256: str) -> dict:
|
||||
ack = _read_json_frame(reader, ACK_MAGIC)
|
||||
if (set(ack) != {"version", "nonce", "backup_id", "size", "archive_sha256", "manifest_sha256", "verified"}
|
||||
or type(ack.get("version")) is not int or type(ack.get("size")) is not int
|
||||
or ack.get("verified") is not True):
|
||||
raise ValueError("external backup acknowledgement is invalid")
|
||||
expected = {
|
||||
"version": VERSION, "nonce": session_nonce, "backup_id": backup_id,
|
||||
"size": size, "archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256, "verified": True,
|
||||
}
|
||||
if ack != expected:
|
||||
raise ValueError("external backup acknowledgement does not match this live session")
|
||||
return ack
|
||||
|
||||
|
||||
def write_result(writer: BinaryIO, *, status: str, message: str = "") -> None:
|
||||
if status not in {"success", "error"} or len(message.encode("utf-8")) > 4096:
|
||||
raise ValueError("external backup result is invalid")
|
||||
_json_frame(writer, RESULT_MAGIC, {"version": VERSION, "status": status, "message": message})
|
||||
|
||||
|
||||
def read_result(reader: BinaryIO) -> dict:
|
||||
result = _read_json_frame(reader, RESULT_MAGIC)
|
||||
if (set(result) != {"version", "status", "message"} or type(result["version"]) is not int
|
||||
or result["version"] != VERSION or result["status"] not in {"success", "error"}
|
||||
or not isinstance(result["message"], str)):
|
||||
raise ValueError("external backup final result is invalid")
|
||||
return result
|
||||
|
||||
|
||||
def send_backup_session(writer: BinaryIO, reader: BinaryIO, *, sources: dict, revision: str,
|
||||
images: dict, deployment_files: dict[str, str], context: dict) -> dict:
|
||||
from archive import backup_manifest, create_backup_stream
|
||||
|
||||
manifest = backup_manifest(sources, revision, images, deployment_files)
|
||||
session_nonce = nonce()
|
||||
backup_id = "akyldash-" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + session_nonce[:8]
|
||||
manifest_sha256 = write_header(
|
||||
writer, session_nonce=session_nonce, backup_id=backup_id, manifest=manifest, context=context
|
||||
)
|
||||
framed = ArchiveFrameWriter(writer)
|
||||
create_backup_stream(
|
||||
framed, sources, revision, images, deployment_files, manifest=manifest
|
||||
)
|
||||
size, archive_sha256 = framed.finish()
|
||||
read_ack(
|
||||
reader, session_nonce=session_nonce, backup_id=backup_id, size=size,
|
||||
archive_sha256=archive_sha256, manifest_sha256=manifest_sha256,
|
||||
)
|
||||
return {
|
||||
"backup_id": backup_id,
|
||||
"archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"size": size,
|
||||
"manifest": manifest,
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Receive and verify one cold backup from the fixed owner SSH session to D:."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from archive import verify_external_archive
|
||||
from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result,
|
||||
MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request)
|
||||
|
||||
DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash")
|
||||
DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release"
|
||||
TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json")
|
||||
AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json")
|
||||
PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json")
|
||||
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
|
||||
MAX_STATUS_BYTES = 1024
|
||||
STATUS_TIMEOUT_SECONDS = 180
|
||||
NOT_READY_REASONS = {
|
||||
"recovery_journal", "runtime_config_changed", "release_policy_changed",
|
||||
"no_current_main_release", "main_advanced", "already_applied",
|
||||
}
|
||||
MAX_ERROR_BYTES = 8192
|
||||
_WINDOWS = os.name == "nt"
|
||||
DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh"
|
||||
|
||||
|
||||
def _reparse(path: Path) -> bool:
|
||||
if path.is_symlink():
|
||||
return True
|
||||
try:
|
||||
return bool(path.stat().st_file_attributes & 0x400)
|
||||
except AttributeError:
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _directory_identity(path: Path) -> tuple[int, int]:
|
||||
info = path.lstat()
|
||||
if _reparse(path) or not stat.S_ISDIR(info.st_mode) or not info.st_ino:
|
||||
raise ValueError("backup publication directory is not a stable real directory")
|
||||
return info.st_dev, info.st_ino
|
||||
|
||||
|
||||
def _remove_owned_directory(path: Path, identity: tuple[int, int]) -> None:
|
||||
try:
|
||||
current = _directory_identity(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if current != identity:
|
||||
raise ValueError(f"refusing to remove a replaced backup directory: {path}")
|
||||
for child in path.iterdir():
|
||||
child_info = child.lstat()
|
||||
if (child.name not in {"backup.tar.gz", "backup.tar.gz.sha256"}
|
||||
or _reparse(child) or not stat.S_ISREG(child_info.st_mode)):
|
||||
raise ValueError(f"refusing to remove unexpected backup content: {child}")
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def check_private_acl(path: Path) -> None:
|
||||
command = r"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$acl = Get-Acl -LiteralPath $env:AKYLDASH_ACL_PATH
|
||||
$driveRoot = [System.IO.Path]::GetPathRoot($env:AKYLDASH_ACL_PATH)
|
||||
$volume = [System.IO.DriveInfo]::new($driveRoot)
|
||||
if (-not $volume.IsReady -or $volume.DriveFormat -ne 'NTFS') { exit 5 }
|
||||
$owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544')
|
||||
$aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
if ($aclOwner -notin $allowed) { exit 6 }
|
||||
$ownerAllowed = $false
|
||||
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 }
|
||||
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 }
|
||||
if ($sid -eq $owner) { $ownerAllowed = $true }
|
||||
}
|
||||
if (-not $ownerAllowed) { exit 4 }
|
||||
"""
|
||||
environment = {
|
||||
"SystemRoot": os.environ.get("SystemRoot", r"C:\Windows"),
|
||||
"AKYLDASH_ACL_PATH": str(path),
|
||||
}
|
||||
result = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
env=environment, text=True, capture_output=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError(f"backup path ACL is not private (PowerShell status {result.returncode})")
|
||||
|
||||
|
||||
def check_capacity(free_bytes: int) -> None:
|
||||
if free_bytes < MAX_ARCHIVE_BYTES + 1024 * 1024:
|
||||
raise ValueError("D: must have room for the maximum 100 GiB archive plus staging metadata")
|
||||
|
||||
|
||||
def check_repository_target(repository: Path, backup_id: str, *, allow_existing: bool = False) -> tuple[Path, Path]:
|
||||
if not repository.is_absolute() or not repository.is_dir() or _reparse(repository):
|
||||
raise ValueError("repository must be an existing real directory on the selected Windows volume")
|
||||
if _WINDOWS and repository.drive.upper() != "D:":
|
||||
raise ValueError("external backup destination is restricted to D:\\Dev\\akyldash")
|
||||
if _WINDOWS and repository.resolve().as_posix().lower() != "d:/dev/akyldash":
|
||||
raise ValueError("external backup destination is restricted to D:\\Dev\\akyldash")
|
||||
if _WINDOWS:
|
||||
check_capacity(shutil.disk_usage(repository).free)
|
||||
for parent in repository.parents:
|
||||
if _reparse(parent):
|
||||
raise ValueError("backup destination has a reparse-point parent")
|
||||
git_dir = subprocess.run(
|
||||
["git", "-C", str(repository), "rev-parse", "--absolute-git-dir"],
|
||||
check=True, text=True, capture_output=True,
|
||||
).stdout.strip()
|
||||
exclude = Path(git_dir) / "info" / "exclude"
|
||||
if _reparse(exclude) or not exclude.is_file():
|
||||
raise ValueError("the repository's local .git/info/exclude file is unavailable or unsafe")
|
||||
root = repository / ".backups"
|
||||
final = root / backup_id
|
||||
target_file = final / "backup.tar.gz"
|
||||
relative = target_file.relative_to(repository).as_posix()
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), "check-ignore", "--no-index", "-v", "--", relative],
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError("the exact D: backup target is not ignored by local Git metadata")
|
||||
try:
|
||||
source, _line, pattern = result.stdout.split("\t", 1)[0].rsplit(":", 2)
|
||||
except ValueError as error:
|
||||
raise ValueError("git check-ignore returned an unrecognized source record") from error
|
||||
source_path = Path(source)
|
||||
if not source_path.is_absolute():
|
||||
source_path = repository / source_path
|
||||
if source_path.resolve() != exclude.resolve() or pattern != "/.backups/":
|
||||
raise ValueError("the exact D: backup target must be ignored by .git/info/exclude")
|
||||
if root.exists():
|
||||
if _reparse(root) or not root.is_dir():
|
||||
raise ValueError("D: backup root must be a real directory")
|
||||
else:
|
||||
root.mkdir()
|
||||
check_private_acl(root)
|
||||
if final.exists() or _reparse(final):
|
||||
if not allow_existing or _reparse(final) or not final.is_dir():
|
||||
raise ValueError("backup ID already exists or resolves through a reparse point")
|
||||
elif allow_existing:
|
||||
raise ValueError("the pinned external backup is missing from D:")
|
||||
return root, final
|
||||
|
||||
|
||||
def open_previous_backup(repository: Path, challenge: dict) -> Path:
|
||||
_root, directory = check_repository_target(repository, challenge["backup_id"], allow_existing=True)
|
||||
check_private_acl(directory)
|
||||
archive_path = directory / "backup.tar.gz"
|
||||
sidecar = directory / "backup.tar.gz.sha256"
|
||||
for path in (archive_path, sidecar):
|
||||
if _reparse(path) or not path.is_file():
|
||||
raise ValueError("pinned external backup files must be regular files")
|
||||
check_private_acl(path)
|
||||
if archive_path.stat().st_size != challenge["size"]:
|
||||
raise ValueError("pinned external backup size does not match root-owned release state")
|
||||
expected_sidecar = f"{challenge['archive_sha256']} backup.tar.gz\n"
|
||||
if sidecar.read_text(encoding="ascii") != expected_sidecar:
|
||||
raise ValueError("pinned external backup checksum sidecar does not match root-owned release state")
|
||||
manifest = verify_external_archive(
|
||||
archive_path,
|
||||
expected_sha256=challenge["archive_sha256"],
|
||||
expected_manifest_sha256=challenge["manifest_sha256"],
|
||||
)
|
||||
if manifest != challenge["manifest"]:
|
||||
raise ValueError("pinned external backup contents differ from root-owned release state")
|
||||
return archive_path
|
||||
|
||||
|
||||
def _drain_stderr(stream, output: bytearray) -> None:
|
||||
while True:
|
||||
chunk = stream.read(1024)
|
||||
if not chunk:
|
||||
return
|
||||
if len(output) < MAX_ERROR_BYTES:
|
||||
output.extend(chunk[: MAX_ERROR_BYTES - len(output)])
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("release status contains duplicate JSON fields")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def validate_status_response(payload: bytes) -> dict | None:
|
||||
if not payload or len(payload) > MAX_STATUS_BYTES:
|
||||
raise ValueError("release status response is empty or exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(payload.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("release status response is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "state", "reason", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1):
|
||||
raise ValueError("release status response has an unsupported schema")
|
||||
if value["state"] == "not_ready":
|
||||
if (not isinstance(value["reason"], str) or value["reason"] not in NOT_READY_REASONS
|
||||
or value["release"] is not None):
|
||||
raise ValueError("release status not-ready response is invalid")
|
||||
return None
|
||||
if value["state"] != "prepared" or value["reason"] is not None:
|
||||
raise ValueError("release status state is invalid")
|
||||
release = value["release"]
|
||||
if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("release status candidate tuple is invalid")
|
||||
if (not isinstance(release["run_id"], str) or not re.fullmatch(r"[0-9]{1,20}", release["run_id"])
|
||||
or not isinstance(release["commit"], str) or not re.fullmatch(r"[0-9a-f]{40}", release["commit"])
|
||||
or not isinstance(release["descriptor_sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])):
|
||||
raise ValueError("release status candidate identity is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None:
|
||||
validate_request(request)
|
||||
if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path):
|
||||
raise ValueError("prepared release state path is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
if path.exists():
|
||||
check_private_acl(path)
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
check_private_acl(temporary)
|
||||
os.replace(temporary, path)
|
||||
check_private_acl(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict:
|
||||
if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file():
|
||||
raise ValueError("prepared release state is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
raw = path.read_bytes()
|
||||
if not raw or len(raw) > MAX_STATUS_BYTES:
|
||||
raise ValueError("prepared release state is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("prepared release state is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)):
|
||||
raise ValueError("prepared release state has an unsupported schema")
|
||||
release = value["release"]
|
||||
if set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("prepared release tuple is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release trigger is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file()
|
||||
or not path.parent.is_dir()):
|
||||
raise ValueError("release trigger configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
raw = stream.read(4097)
|
||||
except OSError as error:
|
||||
raise ValueError("release trigger configuration is unreadable") from error
|
||||
if not raw or len(raw) > 4096:
|
||||
raise ValueError("release trigger configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("release trigger configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict)
|
||||
or set(value) != {"schema_version", "enabled", "repository", "ssh_alias"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool
|
||||
or value["repository"] != str(DEFAULT_REPOSITORY)
|
||||
or value["ssh_alias"] != DEFAULT_DEPLOY_SSH_ALIAS):
|
||||
raise ValueError("release trigger configuration does not match the fixed deployment target")
|
||||
return value
|
||||
|
||||
|
||||
def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release apply is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()):
|
||||
raise ValueError("automatic release apply configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError("automatic release apply configuration is unreadable") from error
|
||||
if not raw or len(raw) > 1024:
|
||||
raise ValueError("automatic release apply configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("automatic release apply configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool):
|
||||
raise ValueError("automatic release apply configuration is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS:
|
||||
raise ValueError("automatic release status uses only the fixed deployment SSH alias")
|
||||
command = [
|
||||
ssh, "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
ssh_alias, RELEASE_STATUS_COMMAND,
|
||||
]
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
output = bytearray()
|
||||
overflow = threading.Event()
|
||||
|
||||
def read_status() -> None:
|
||||
while True:
|
||||
chunk = process.stdout.read(min(4096, MAX_STATUS_BYTES + 1 - len(output)))
|
||||
if not chunk:
|
||||
return
|
||||
if len(output) + len(chunk) > MAX_STATUS_BYTES:
|
||||
overflow.set()
|
||||
process.kill()
|
||||
return
|
||||
output.extend(chunk)
|
||||
|
||||
stdout_thread = threading.Thread(target=read_status, daemon=True)
|
||||
stderr_thread = threading.Thread(target=_drain_stderr, args=(process.stderr, bytearray()), daemon=True)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
try:
|
||||
return_code = process.wait(timeout=STATUS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise ValueError("release status request timed out") from error
|
||||
finally:
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
if overflow.is_set() or return_code:
|
||||
raise ValueError("release status command failed or exceeded its response limit")
|
||||
return validate_status_response(bytes(output))
|
||||
|
||||
|
||||
def receive_backup(reader, writer, *, repository: Path, request: dict, expected_context: dict | None = None) -> dict:
|
||||
frame, value = read_response(reader)
|
||||
if frame == "result":
|
||||
raise ValueError(value["message"] or "remote deploy preflight failed")
|
||||
header = value
|
||||
context = header["context"]
|
||||
if request["action"] == "release":
|
||||
expected_context = {key: request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")}
|
||||
if context != expected_context:
|
||||
raise ValueError("source backup session does not match the requested release tuple")
|
||||
elif expected_context is None or context != expected_context:
|
||||
raise ValueError("rollback backup session does not match the pinned previous backup")
|
||||
|
||||
root, final = check_repository_target(repository, header["backup_id"])
|
||||
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=root))
|
||||
staging_identity = None
|
||||
published_identity = None
|
||||
ack_started = False
|
||||
try:
|
||||
staging_identity = _directory_identity(staging)
|
||||
check_private_acl(staging)
|
||||
archive_path = staging / "backup.tar.gz"
|
||||
with archive_path.open("xb") as archive_file:
|
||||
size, archive_sha256 = receive_archive(reader, archive_file)
|
||||
archive_file.flush()
|
||||
os.fsync(archive_file.fileno())
|
||||
os.chmod(archive_path, 0o600)
|
||||
check_private_acl(archive_path)
|
||||
manifest = verify_external_archive(
|
||||
archive_path,
|
||||
expected_sha256=archive_sha256,
|
||||
expected_manifest_sha256=header["manifest_sha256"],
|
||||
)
|
||||
if manifest != header["manifest"]:
|
||||
raise ValueError("saved backup contents differ from the source-generated session manifest")
|
||||
sidecar = staging / "backup.tar.gz.sha256"
|
||||
with sidecar.open("x", encoding="ascii", newline="\n") as stream:
|
||||
stream.write(f"{archive_sha256} backup.tar.gz\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(sidecar, 0o600)
|
||||
check_private_acl(sidecar)
|
||||
if final.exists() or _reparse(final):
|
||||
raise ValueError("backup ID already exists or resolves through a reparse point")
|
||||
os.rename(staging, final)
|
||||
published_identity = staging_identity
|
||||
if _directory_identity(final) != published_identity:
|
||||
raise ValueError("published backup directory identity changed during rename")
|
||||
for path in (final, final / "backup.tar.gz", final / "backup.tar.gz.sha256"):
|
||||
check_private_acl(path)
|
||||
ack_started = True
|
||||
write_ack(
|
||||
writer, session_nonce=header["nonce"], backup_id=header["backup_id"], size=size,
|
||||
archive_sha256=archive_sha256, manifest_sha256=header["manifest_sha256"], verified=True,
|
||||
)
|
||||
return {"backup_id": header["backup_id"], "archive_sha256": archive_sha256, "size": size}
|
||||
except Exception as error:
|
||||
cleanup_path = final if published_identity is not None and not ack_started else staging
|
||||
cleanup_identity = published_identity if cleanup_path == final else staging_identity
|
||||
if cleanup_identity is not None:
|
||||
try:
|
||||
_remove_owned_directory(cleanup_path, cleanup_identity)
|
||||
except Exception as cleanup_error:
|
||||
raise ValueError(
|
||||
f"backup failed and its output could not be safely removed; inspect {cleanup_path}"
|
||||
) from cleanup_error
|
||||
raise
|
||||
|
||||
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if not ssh_alias or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" for character in ssh_alias):
|
||||
raise ValueError("SSH alias must name an existing owner-controlled host entry")
|
||||
check_repository_target(repository, "preflight-" + secrets.token_hex(16))
|
||||
process = subprocess.Popen(
|
||||
[ssh, "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
ssh_alias, "/usr/local/sbin/akyldash-release-deploy"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
errors = bytearray()
|
||||
thread = threading.Thread(target=_drain_stderr, args=(process.stderr, errors), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
write_request(process.stdin, request)
|
||||
process.stdin.flush()
|
||||
saved = receive_backup(process.stdout, process.stdin, repository=repository, request=request)
|
||||
final = read_result(process.stdout)
|
||||
if process.stdout.read(1):
|
||||
raise ValueError("unexpected trailing bytes after external deployment result")
|
||||
process.stdin.close()
|
||||
return_code = process.wait()
|
||||
thread.join()
|
||||
if return_code or final["status"] != "success":
|
||||
message = final.get("message", "remote deployment failed")
|
||||
raise ValueError(f"remote deployment failed (ssh status {return_code}): {message}")
|
||||
print(f"PASS release completed; verified external backup {saved['backup_id']} ({saved['size']} bytes)")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
thread.join()
|
||||
raise
|
||||
|
||||
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
if not config["enabled"]:
|
||||
print("Release trigger is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.")
|
||||
|
||||
|
||||
def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG,
|
||||
auto_apply_file: Path = AUTO_APPLY_CONFIG,
|
||||
prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
auto_config = load_auto_apply_config(auto_apply_file)
|
||||
if not config["enabled"] or not auto_config["enabled"]:
|
||||
print("Automatic apply is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
if _reparse(prepared_file):
|
||||
raise ValueError("prepared release state is unsafe")
|
||||
if not prepared_file.exists():
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
previous = read_prepared_request(prepared_file)
|
||||
if previous != request:
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH, confirm=input) -> None:
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("applying a prepared release requires an interactive owner confirmation")
|
||||
request = read_prepared_request(prepared_file)
|
||||
answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ")
|
||||
if answer != f"APPLY {request['commit']}":
|
||||
print("Prepared release was not applied.")
|
||||
return
|
||||
config = load_trigger_config(config_file)
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if not ssh_alias or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" for character in ssh_alias):
|
||||
raise ValueError("SSH alias must name an existing owner-controlled host entry")
|
||||
check_repository_target(repository, "preflight-" + secrets.token_hex(16))
|
||||
process = subprocess.Popen(
|
||||
[ssh, "-T", ssh_alias, "/usr/local/sbin/akyldash-release-rollback"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
errors = bytearray()
|
||||
thread = threading.Thread(target=_drain_stderr, args=(process.stderr, errors), daemon=True)
|
||||
thread.start()
|
||||
request = {"version": 1, "action": "rollback", "data_compatible": True}
|
||||
try:
|
||||
write_request(process.stdin, request)
|
||||
process.stdin.flush()
|
||||
challenge = read_challenge(process.stdout)
|
||||
archive_path = open_previous_backup(repository, challenge)
|
||||
framed = ArchiveFrameWriter(process.stdin, maximum=challenge["size"])
|
||||
with archive_path.open("rb") as archive_file:
|
||||
for chunk in iter(lambda: archive_file.read(1024 * 1024), b""):
|
||||
framed.write(chunk)
|
||||
size, digest = framed.finish()
|
||||
if size != challenge["size"] or digest != challenge["archive_sha256"]:
|
||||
raise ValueError("D: previous backup changed during the live rollback session")
|
||||
write_ack(process.stdin, session_nonce=challenge["nonce"], backup_id=challenge["backup_id"],
|
||||
size=size, archive_sha256=digest, manifest_sha256=challenge["manifest_sha256"], verified=True)
|
||||
receive_backup(process.stdout, process.stdin, repository=repository, request=request,
|
||||
expected_context={key: challenge[key] for key in
|
||||
("backup_id", "archive_sha256", "manifest_sha256")} | {"action": "rollback"})
|
||||
final = read_result(process.stdout)
|
||||
if process.stdout.read(1):
|
||||
raise ValueError("unexpected trailing bytes after external rollback result")
|
||||
process.stdin.close()
|
||||
return_code = process.wait()
|
||||
thread.join()
|
||||
if return_code or final["status"] != "success":
|
||||
raise ValueError(f"remote rollback failed (ssh status {return_code}): {final.get('message', 'error')}")
|
||||
print(f"PASS rollback completed; fresh external backup saved for {challenge['backup_id']}")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
thread.join()
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy")
|
||||
parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation")
|
||||
parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled")
|
||||
parser.add_argument("--rollback", action="store_true")
|
||||
parser.add_argument("--data-compatible", action="store_true")
|
||||
parser.add_argument("--run-id")
|
||||
parser.add_argument("--commit")
|
||||
parser.add_argument("--descriptor-sha256")
|
||||
parser.add_argument("--ssh-alias", default="akyldash_vps")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.once:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any(
|
||||
(args.run_id, args.commit, args.descriptor_sha256))
|
||||
):
|
||||
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
|
||||
run_release_once()
|
||||
elif args.apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--apply-prepared accepts no release tuple or rollback flags")
|
||||
apply_prepared_release()
|
||||
elif args.auto_apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags")
|
||||
run_auto_apply_once()
|
||||
elif args.rollback:
|
||||
if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)):
|
||||
raise ValueError("rollback requires --data-compatible and accepts no release tuple")
|
||||
run_rollback(ssh_alias=args.ssh_alias)
|
||||
else:
|
||||
if not args.run_id or not args.commit or not args.descriptor_sha256 or args.data_compatible:
|
||||
raise ValueError("release requires --run-id, --commit, and --descriptor-sha256")
|
||||
request = {
|
||||
"version": 1, "action": "release", "run_id": args.run_id,
|
||||
"commit": args.commit, "descriptor_sha256": args.descriptor_sha256,
|
||||
}
|
||||
run_release(request, ssh_alias=args.ssh_alias)
|
||||
except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import tarfile
|
||||
@@ -8,7 +9,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from archive import COMPONENTS, compose_sources, create_backup, restore_to_staging, verify_archive
|
||||
from archive import COMPONENTS, compose_sources, create_backup, create_backup_stream, restore_to_staging, verify_archive, verify_external_archive
|
||||
|
||||
|
||||
class BackupArchiveTests(unittest.TestCase):
|
||||
@@ -50,6 +51,39 @@ class BackupArchiveTests(unittest.TestCase):
|
||||
self.assertEqual((target / "review-secret").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(verify_archive(self.backup)["components"], sorted(COMPONENTS))
|
||||
|
||||
def test_external_stream_uses_no_local_archive_and_verifies_received_bytes(self):
|
||||
stream = io.BytesIO()
|
||||
with patch("archive.os.geteuid", return_value=0):
|
||||
manifest = create_backup_stream(stream, self.sources, "a" * 40)
|
||||
self.assertFalse(self.backup.exists())
|
||||
received = self.private / "received.tar.gz"
|
||||
received.write_bytes(stream.getvalue())
|
||||
verified = verify_external_archive(
|
||||
received,
|
||||
expected_sha256=hashlib.sha256(stream.getvalue()).hexdigest(),
|
||||
expected_manifest_sha256=hashlib.sha256(
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest(),
|
||||
)
|
||||
self.assertEqual(verified["git_revision"], "a" * 40)
|
||||
|
||||
def test_external_archive_rejects_changed_manifest_and_duplicate_keys(self):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
raw = b'{"format":1,"format":1,"components":[],"images":{},"deployment_files":{}}'
|
||||
info.size = len(raw)
|
||||
archive.addfile(info, io.BytesIO(raw))
|
||||
for name in COMPONENTS:
|
||||
info = tarfile.TarInfo(name + "/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
archive.addfile(info)
|
||||
received = self.private / "duplicate.tar.gz"
|
||||
received.write_bytes(stream.getvalue())
|
||||
with self.assertRaisesRegex(ValueError, "duplicate backup manifest"):
|
||||
verify_external_archive(received, expected_sha256=hashlib.sha256(stream.getvalue()).hexdigest(),
|
||||
expected_manifest_sha256="0" * 64)
|
||||
|
||||
def test_rejects_checksum_mismatch_without_creating_staging(self):
|
||||
create_backup(self.backup, self.sources, "abc123")
|
||||
self.backup.write_bytes(self.backup.read_bytes() + b"tamper")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
from external_session import (
|
||||
ArchiveFrameWriter, CHUNK_BYTES, read_ack, read_challenge, read_header, read_request, read_result,
|
||||
receive_archive, write_ack, write_challenge, write_header, write_request, write_result,
|
||||
)
|
||||
from external_session import ACK_MAGIC, MAGIC
|
||||
|
||||
|
||||
class ExternalSessionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.nonce = "a" * 64
|
||||
self.backup_id = "backup-20261031T120000Z"
|
||||
self.manifest = {"format": 1, "git_revision": "b" * 40, "images": {}}
|
||||
|
||||
def make_stream(self, payload=b"tar\x00bytes" * 100):
|
||||
stream = io.BytesIO()
|
||||
manifest_sha = write_header(
|
||||
stream, session_nonce=self.nonce, backup_id=self.backup_id, manifest=self.manifest
|
||||
)
|
||||
framed = ArchiveFrameWriter(stream, maximum=4096)
|
||||
framed.write(payload[:37])
|
||||
framed.write(payload[37:])
|
||||
size, digest = framed.finish()
|
||||
stream.seek(0)
|
||||
return stream, payload, manifest_sha, size, digest
|
||||
|
||||
def test_binary_archive_round_trip_and_live_ack_binding(self):
|
||||
stream, payload, manifest_sha, size, digest = self.make_stream()
|
||||
header = read_header(stream)
|
||||
target = io.BytesIO()
|
||||
self.assertEqual(receive_archive(stream, target, maximum=4096), (size, digest))
|
||||
self.assertEqual(target.getvalue(), payload)
|
||||
self.assertEqual(hashlib.sha256(payload).hexdigest(), digest)
|
||||
|
||||
ack = io.BytesIO()
|
||||
write_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id,
|
||||
size=size, archive_sha256=digest, manifest_sha256=manifest_sha, verified=True)
|
||||
ack.seek(0)
|
||||
self.assertEqual(read_ack(
|
||||
ack, session_nonce=header["nonce"], backup_id=header["backup_id"], size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha,
|
||||
)["verified"], True)
|
||||
|
||||
def test_single_large_write_is_split_into_bounded_frames(self):
|
||||
payload = b"x" * (CHUNK_BYTES * 2 + 13)
|
||||
stream = io.BytesIO()
|
||||
writer = ArchiveFrameWriter(stream)
|
||||
writer.write(payload)
|
||||
size, digest = writer.finish()
|
||||
stream.seek(0)
|
||||
target = io.BytesIO()
|
||||
self.assertEqual(receive_archive(stream, target), (size, digest))
|
||||
self.assertEqual(target.getvalue(), payload)
|
||||
|
||||
def test_rejects_wrong_nonce_replay_and_unverified_ack(self):
|
||||
stream, _payload, manifest_sha, size, digest = self.make_stream()
|
||||
ack = io.BytesIO()
|
||||
write_ack(ack, session_nonce="c" * 64, backup_id=self.backup_id,
|
||||
size=size, archive_sha256=digest, manifest_sha256=manifest_sha, verified=True)
|
||||
ack.seek(0)
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
read_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id, size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha)
|
||||
|
||||
def test_rejects_corrupt_digest_truncated_stream_and_oversized_chunk(self):
|
||||
stream, _payload, _manifest_sha, _size, _digest = self.make_stream()
|
||||
read_header(stream)
|
||||
corrupted = bytearray(stream.getvalue())
|
||||
corrupted[-1] ^= 1
|
||||
corrupted_stream = io.BytesIO(corrupted)
|
||||
read_header(corrupted_stream)
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
receive_archive(corrupted_stream, io.BytesIO(), maximum=4096)
|
||||
truncated = io.BytesIO(stream.getvalue()[:-3])
|
||||
read_header(truncated)
|
||||
with self.assertRaisesRegex(ValueError, "ended before"):
|
||||
receive_archive(truncated, io.BytesIO(), maximum=4096)
|
||||
with self.assertRaisesRegex(ValueError, "size limit"):
|
||||
receive_archive(io.BytesIO(b"\x00\x10\x00\x01"), io.BytesIO(), maximum=4096)
|
||||
|
||||
def test_final_result_is_bounded_and_typed(self):
|
||||
stream = io.BytesIO()
|
||||
write_result(stream, status="error", message="backup verification failed")
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_result(stream)["status"], "error")
|
||||
with self.assertRaisesRegex(ValueError, "invalid"):
|
||||
write_result(io.BytesIO(), status="maybe")
|
||||
|
||||
def test_rollback_challenge_pins_external_archive_identity(self):
|
||||
identity = {
|
||||
"backup_id": self.backup_id, "size": 128, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": hashlib.sha256(
|
||||
json.dumps(self.manifest, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest(), "manifest": self.manifest,
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
write_challenge(stream, identity, session_nonce=self.nonce)
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_challenge(stream), {"version": 1, "nonce": self.nonce, **identity})
|
||||
identity["size"] = 0
|
||||
with self.assertRaisesRegex(ValueError, "identity"):
|
||||
write_challenge(io.BytesIO(), identity, session_nonce=self.nonce)
|
||||
|
||||
def test_release_request_is_strictly_typed_and_rejects_extra_commands(self):
|
||||
request = {
|
||||
"version": 1, "action": "release", "run_id": "301", "commit": "b" * 40,
|
||||
"descriptor_sha256": "c" * 64,
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
write_request(stream, request)
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_request(stream), request)
|
||||
with self.assertRaisesRegex(ValueError, "unexpected fields"):
|
||||
write_request(io.BytesIO(), request | {"shell": "id"})
|
||||
|
||||
def test_protocol_rejects_boolean_integers_and_non_boolean_ack(self):
|
||||
payload = json.dumps({"version": True, "action": "rollback", "data_compatible": True}).encode()
|
||||
request = io.BytesIO(MAGIC + struct.pack("!I", len(payload)) + payload)
|
||||
with self.assertRaisesRegex(ValueError, "unsupported version"):
|
||||
read_request(request)
|
||||
ack_value = {
|
||||
"version": 1, "nonce": self.nonce, "backup_id": self.backup_id,
|
||||
"size": 128, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": "d" * 64, "verified": 1,
|
||||
}
|
||||
payload = json.dumps(ack_value).encode()
|
||||
ack = io.BytesIO(ACK_MAGIC + struct.pack("!I", len(payload)) + payload)
|
||||
with self.assertRaisesRegex(ValueError, "acknowledgement is invalid"):
|
||||
read_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id, size=128,
|
||||
archive_sha256="c" * 64, manifest_sha256="d" * 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,388 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import receive_external
|
||||
from archive import COMPONENTS
|
||||
from external_session import ArchiveFrameWriter, read_ack, write_header
|
||||
|
||||
|
||||
class ExternalReceiverTests(unittest.TestCase):
|
||||
def test_windows_preflight_requires_full_protocol_archive_capacity(self):
|
||||
required = receive_external.MAX_ARCHIVE_BYTES + 1024 * 1024
|
||||
receive_external.check_capacity(required)
|
||||
with self.assertRaisesRegex(ValueError, "maximum 100 GiB archive"):
|
||||
receive_external.check_capacity(required - 1)
|
||||
|
||||
def setUp(self):
|
||||
self.request = {
|
||||
"version": 1, "action": "release", "run_id": "301", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64,
|
||||
}
|
||||
self.manifest = {
|
||||
"format": 1, "created_at": "2026-10-31T12:00:00+00:00", "git_revision": "c" * 40,
|
||||
"images": {"backend": {"configured": "backend:old", "image_id": "sha256:" + "d" * 64}},
|
||||
"deployment_files": {"deploy/production/compose.yaml": "e" * 64},
|
||||
"components": sorted(COMPONENTS),
|
||||
}
|
||||
|
||||
def source_stream(self, context=None):
|
||||
archive_stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_stream, mode="w:gz") as archive:
|
||||
manifest_bytes = json.dumps(self.manifest, sort_keys=True).encode("utf-8")
|
||||
member = tarfile.TarInfo("manifest.json")
|
||||
member.size = len(manifest_bytes)
|
||||
archive.addfile(member, io.BytesIO(manifest_bytes))
|
||||
for component in COMPONENTS:
|
||||
member = tarfile.TarInfo(component + "/")
|
||||
member.type = tarfile.DIRTYPE
|
||||
archive.addfile(member)
|
||||
payload = archive_stream.getvalue()
|
||||
stream = io.BytesIO()
|
||||
manifest_sha = write_header(
|
||||
stream, session_nonce="f" * 64, backup_id="backup-20261031T120000Z",
|
||||
manifest=self.manifest,
|
||||
context=context or {key: self.request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")},
|
||||
)
|
||||
framed = ArchiveFrameWriter(stream)
|
||||
framed.write(payload)
|
||||
size, digest = framed.finish()
|
||||
stream.seek(0)
|
||||
return stream, manifest_sha, size, digest
|
||||
|
||||
def test_verified_archive_is_atomically_published_before_ack(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, manifest_sha, size, digest = self.source_stream()
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl") as acl_check:
|
||||
result = receive_external.receive_backup(
|
||||
stream, ack, repository=root, request=self.request
|
||||
)
|
||||
self.assertEqual(result, {"backup_id": final.name, "archive_sha256": digest, "size": size})
|
||||
self.assertTrue((final / "backup.tar.gz").is_file())
|
||||
if os.name == "nt":
|
||||
for path in (final, final / "backup.tar.gz", final / "backup.tar.gz.sha256"):
|
||||
acl_check.assert_any_call(path)
|
||||
else:
|
||||
self.assertEqual((final / "backup.tar.gz").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((final / "backup.tar.gz.sha256").read_text(), f"{digest} backup.tar.gz\n")
|
||||
ack.seek(0)
|
||||
self.assertTrue(read_ack(
|
||||
ack, session_nonce="f" * 64, backup_id=final.name, size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha,
|
||||
)["verified"])
|
||||
|
||||
def test_mismatched_release_binding_never_publishes_or_acks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream(
|
||||
context={"action": "release", "run_id": "999", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}
|
||||
)
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
receive_external.receive_backup(stream, ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_invalid_saved_stream_is_removed_without_publication(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream()
|
||||
raw = bytearray(stream.getvalue())
|
||||
raw[-1] ^= 1
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
receive_external.receive_backup(io.BytesIO(raw), ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(list(root.iterdir()), [])
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_post_publish_acl_failure_removes_owned_final_without_ack(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream()
|
||||
ack = io.BytesIO()
|
||||
|
||||
def fail_published_archive(path):
|
||||
if path == final / "backup.tar.gz":
|
||||
raise ValueError("post-publish ACL failed")
|
||||
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl", side_effect=fail_published_archive):
|
||||
with self.assertRaisesRegex(ValueError, "post-publish ACL failed"):
|
||||
receive_external.receive_backup(stream, ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(list(root.iterdir()), [])
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_cleanup_refuses_a_replaced_or_reparse_directory(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
owned = root / "owned"
|
||||
final = root / "final"
|
||||
owned.mkdir()
|
||||
identity = receive_external._directory_identity(owned)
|
||||
owned.rename(root / "moved-owned")
|
||||
final.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, "replaced backup directory"):
|
||||
receive_external._remove_owned_directory(final, identity)
|
||||
self.assertTrue(final.is_dir())
|
||||
self.assertTrue((root / "moved-owned").is_dir())
|
||||
if os.name != "nt":
|
||||
link = root / "link"
|
||||
link.symlink_to(final, target_is_directory=True)
|
||||
with self.assertRaisesRegex(ValueError, "real directory"):
|
||||
receive_external._remove_owned_directory(link, identity)
|
||||
self.assertTrue(final.is_dir())
|
||||
|
||||
def test_cleanup_refuses_unexpected_directory_contents(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "owned"
|
||||
path.mkdir()
|
||||
identity = receive_external._directory_identity(path)
|
||||
(path / "foreign-data").write_text("keep", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected backup content"):
|
||||
receive_external._remove_owned_directory(path, identity)
|
||||
self.assertEqual((path / "foreign-data").read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_target_requires_exact_local_git_exclude_rule(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
repository = Path(directory)
|
||||
exclude = repository / ".git/info/exclude"
|
||||
exclude.parent.mkdir(parents=True)
|
||||
exclude.write_text("/.backups/\n", encoding="utf-8")
|
||||
responses = [
|
||||
type("Result", (), {"stdout": str(repository / ".git"), "returncode": 0})(),
|
||||
type("Result", (), {
|
||||
"stdout": f"{exclude}:1:/.backups/\t.backups/backup-id/backup.tar.gz\n",
|
||||
"returncode": 0,
|
||||
})(),
|
||||
]
|
||||
with patch.object(receive_external, "_WINDOWS", False), \
|
||||
patch.object(receive_external.subprocess, "run", side_effect=responses), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
root, final = receive_external.check_repository_target(
|
||||
repository, "backup-id"
|
||||
)
|
||||
self.assertEqual(root, repository / ".backups")
|
||||
self.assertEqual(final, root / "backup-id")
|
||||
|
||||
def test_windows_path_guard_rejects_a_synthetic_non_d_destination(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
repository = Path(directory)
|
||||
with patch.object(receive_external, "_WINDOWS", True):
|
||||
with self.assertRaisesRegex(ValueError, r"restricted to D:\\Dev\\akyldash"):
|
||||
receive_external.check_repository_target(repository, "backup-id")
|
||||
|
||||
def test_windows_acl_probe_does_not_inherit_credentials(self):
|
||||
result = type("Result", (), {"returncode": 0})()
|
||||
with patch.dict("os.environ", {"GITEA_TOKEN": "must-not-pass"}, clear=True), \
|
||||
patch.object(receive_external.subprocess, "run", return_value=result) as run_mock:
|
||||
receive_external.check_private_acl(Path(r"D:\Dev\akyldash\.backups"))
|
||||
child_env = run_mock.call_args.kwargs["env"]
|
||||
self.assertNotIn("GITEA_TOKEN", child_env)
|
||||
self.assertEqual(child_env["AKYLDASH_ACL_PATH"], r"D:\Dev\akyldash\.backups")
|
||||
self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1])
|
||||
|
||||
def test_private_acl_rejects_an_unapproved_acl_owner(self):
|
||||
result = type("Result", (), {"returncode": 6})()
|
||||
with patch.object(receive_external.subprocess, "run", return_value=result) as run_mock:
|
||||
with self.assertRaisesRegex(ValueError, "PowerShell status 6"):
|
||||
receive_external.check_private_acl(Path(r"D:\Dev\akyldash\.backups"))
|
||||
command = run_mock.call_args.args[0][-1]
|
||||
self.assertIn("$acl.GetOwner(", command)
|
||||
self.assertIn("if ($aclOwner -notin $allowed) { exit 6 }", command)
|
||||
|
||||
def test_destination_preflight_fails_before_opening_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(receive_external.shutil, "which", return_value="ssh"), \
|
||||
patch.object(receive_external, "check_repository_target", side_effect=ValueError("preflight failed")) as preflight, \
|
||||
patch.object(receive_external.subprocess, "Popen") as popen:
|
||||
with self.assertRaisesRegex(ValueError, "preflight failed"):
|
||||
receive_external.run_release(self.request, repository=Path(directory))
|
||||
with self.assertRaisesRegex(ValueError, "preflight failed"):
|
||||
receive_external.run_rollback(repository=Path(directory))
|
||||
self.assertEqual(preflight.call_count, 2)
|
||||
self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list))
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_status_response_accepts_only_the_fixed_prepared_tuple(self):
|
||||
response = {"schema_version": 1, "state": "prepared", "reason": None,
|
||||
"release": {"run_id": "302", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}}
|
||||
self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()),
|
||||
{"version": 1, "action": "release", **response["release"]})
|
||||
response["reason"] = []
|
||||
with self.assertRaises(ValueError):
|
||||
receive_external.validate_status_response(json.dumps(response).encode())
|
||||
self.assertIsNone(receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}'
|
||||
))
|
||||
with self.assertRaisesRegex(ValueError, "state"):
|
||||
receive_external.validate_status_response(
|
||||
json.dumps({**response, "state": "candidate"}).encode()
|
||||
)
|
||||
|
||||
def test_status_response_rejects_extra_or_duplicate_fields(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
b'"release":null,"extra":0}')
|
||||
with self.assertRaisesRegex(ValueError, "schema"):
|
||||
receive_external.validate_status_response(payload)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
b'"release":null,"release":null}')
|
||||
|
||||
def test_status_query_uses_only_fixed_command_and_non_forwarding_ssh(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready",'
|
||||
b'"reason":"no_current_main_release","release":null}\n')
|
||||
process = type("Process", (), {
|
||||
"stdout": io.BytesIO(payload), "stderr": io.BytesIO(),
|
||||
"wait": lambda self, timeout: 0,
|
||||
})()
|
||||
with patch.object(receive_external.shutil, "which", return_value="ssh"), \
|
||||
patch.object(receive_external.subprocess, "Popen", return_value=process) as popen:
|
||||
self.assertIsNone(receive_external.query_release_status())
|
||||
command = popen.call_args.args[0]
|
||||
self.assertEqual(command[-2:], ["akyldash_release", receive_external.RELEASE_STATUS_COMMAND])
|
||||
self.assertIn("ForwardAgent=no", command)
|
||||
self.assertIn("ClearAllForwardings=yes", command)
|
||||
self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
|
||||
def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self):
|
||||
config = {"schema_version": 1, "enabled": False,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY),
|
||||
"ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trigger.json"
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
with patch.object(receive_external, "_WINDOWS", True), \
|
||||
patch.object(receive_external, "check_private_acl"), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy, \
|
||||
patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"):
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
config["enabled"] = True
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
prepared = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = prepared
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared)
|
||||
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=False):
|
||||
with self.assertRaisesRegex(ValueError, "interactive owner confirmation"):
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
)
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \
|
||||
patch.object(receive_external, "run_release") as apply:
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
confirm=lambda prompt: f"APPLY {'a' * 40}",
|
||||
)
|
||||
apply.assert_called_once_with(
|
||||
prepared, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
|
||||
def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self):
|
||||
previous = {"version": 1, "action": "release", "run_id": "301",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
latest = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "c" * 40, "descriptor_sha256": "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
prepared_file = root / "prepared.json"
|
||||
trigger_file = root / "trigger.json"
|
||||
auto_file = root / "auto.json"
|
||||
with patch.object(receive_external, "check_private_acl"):
|
||||
receive_external.write_prepared_request(previous, prepared_file)
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "query_release_status", return_value=latest) as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
query.assert_called_once()
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(prepared_file), latest)
|
||||
|
||||
query.reset_mock()
|
||||
query.return_value = latest
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
deploy.assert_called_once_with(
|
||||
latest, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release",
|
||||
)
|
||||
|
||||
def test_auto_apply_disabled_does_not_open_ssh(self):
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once()
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
def test_windows_trigger_templates_are_fixed_and_disabled(self):
|
||||
root = Path(__file__).resolve().parents[1] / "deploy"
|
||||
config = json.loads((root / "windows-release-trigger.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(config, {
|
||||
"schema_version": 1, "enabled": False,
|
||||
"repository": r"D:\Dev\akyldash", "ssh_alias": "akyldash_release",
|
||||
})
|
||||
task = ET.parse(root / "windows-release-trigger.example.xml").getroot()
|
||||
ns = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(task.findtext("t:Settings/t:MultipleInstancesPolicy", namespaces=ns), "IgnoreNew")
|
||||
self.assertEqual(task.findtext("t:Settings/t:StopIfGoingOnBatteries", namespaces=ns), "false")
|
||||
self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege")
|
||||
self.assertEqual(
|
||||
task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once',
|
||||
)
|
||||
auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(auto_config, {"schema_version": 1, "enabled": False})
|
||||
auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot()
|
||||
self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(
|
||||
auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -gt 1 || ( $# -eq 1 && $1 != status ) ]]; then
|
||||
echo "This fixed receiver is owner-run as root and accepts its request only on stdin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
helper=/usr/local/lib/akyldash/gitea-api
|
||||
token_file=/etc/akyldash/gitea-api-token
|
||||
origins_file=/etc/akyldash/gitea-download-origins
|
||||
if [[ -L $helper || ! -x $helper || $(stat -c %U:%a "$helper") != root:750 ]]; then
|
||||
echo "The reviewed root-owned Gitea helper is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -L $token_file || ! -f $token_file || $(stat -c %U:%a "$token_file") != root:600 ]]; then
|
||||
echo "A root-owned mode-0600 read-only Gitea API token file is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
GITEA_TOKEN=$(< "$token_file")
|
||||
if [[ ! $GITEA_TOKEN =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "The Gitea API token file is empty or has an invalid format." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITEA_DOWNLOAD_ORIGINS=
|
||||
if [[ -e $origins_file || -L $origins_file ]]; then
|
||||
if [[ -L $origins_file || ! -f $origins_file || $(stat -c %U:%a "$origins_file") != root:600 ]]; then
|
||||
echo "Gitea artifact redirect origins must be a root-owned mode-0600 file." >&2
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r origin || [[ -n $origin ]]; do
|
||||
[[ -z $origin ]] && continue
|
||||
if [[ ! $origin =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
|
||||
echo "Gitea artifact redirect origin configuration is invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n $GITEA_DOWNLOAD_ORIGINS ]]; then
|
||||
GITEA_DOWNLOAD_ORIGINS+=,
|
||||
fi
|
||||
GITEA_DOWNLOAD_ORIGINS+=$origin
|
||||
done < "$origins_file"
|
||||
fi
|
||||
|
||||
export GITEA_URL=https://git.vakanaut.ru
|
||||
export GITEA_TOKEN GITEA_DOWNLOAD_ORIGINS
|
||||
export PATH=/usr/local/lib/akyldash:/usr/bin:/bin
|
||||
if [[ $# -eq 1 ]]; then
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_status.py
|
||||
fi
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_deploy.py
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -ne 0 ]]; then
|
||||
echo "The restricted release dispatcher accepts only its exact SSH command." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
/usr/local/sbin/akyldash-release-status)
|
||||
exec /usr/local/sbin/akyldash-release-deploy status
|
||||
;;
|
||||
/usr/local/sbin/akyldash-release-deploy)
|
||||
exec /usr/local/sbin/akyldash-release-deploy
|
||||
;;
|
||||
*)
|
||||
echo "The restricted release dispatcher accepts only status or deploy." >&2
|
||||
exit 126
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -ne 0 ]]; then
|
||||
echo "This fixed receiver is owner-run as root and accepts its request only on stdin." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH=/usr/local/lib/akyldash:/usr/bin:/bin
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_rollback.py
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a traceable, unsigned release descriptor from verified build digests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BUILDER_IMAGE = "ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412"
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = path.read_text(encoding="ascii").strip()
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
||||
raise ValueError(f"invalid Kaniko digest in {path}")
|
||||
return value
|
||||
|
||||
|
||||
def create(repository: str, commit: str, source_run_id: str, run_id: str, backend: Path, opensearch: Path) -> dict:
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9._-]*", repository):
|
||||
raise ValueError("repository must be lowercase owner/repository")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("commit must be a 40-character lowercase Git SHA-1")
|
||||
if not re.fullmatch(r"[0-9]+", run_id):
|
||||
raise ValueError("run_id must be numeric")
|
||||
if not re.fullmatch(r"[0-9]+", source_run_id):
|
||||
raise ValueError("source_run_id must be numeric")
|
||||
registry = "git.vakanaut.ru/" + repository
|
||||
return {
|
||||
"schema_version": 4,
|
||||
"source_ref": "refs/heads/main",
|
||||
"repository": repository,
|
||||
"commit": commit,
|
||||
"workflow": ".gitea/workflows/release-images.yaml",
|
||||
"run_id": run_id,
|
||||
"source_run_id": source_run_id,
|
||||
"builder": {"name": "Kaniko", "image": BUILDER_IMAGE},
|
||||
"images": {
|
||||
"backend": f"{registry}/backend@{digest(backend)}",
|
||||
"opensearch": f"{registry}/opensearch@{digest(opensearch)}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--source-run-id", required=True)
|
||||
parser.add_argument("--backend-digest", required=True, type=Path)
|
||||
parser.add_argument("--opensearch-digest", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
manifest = create(args.repository, args.commit, args.source_run_id, args.run_id, args.backend_digest, args.opensearch_digest)
|
||||
args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS release descriptor for {manifest['commit']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Owner-run, locked production release cutover using a verified cold backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from create_release_manifest import BUILDER_IMAGE
|
||||
from deployment_lock import deployment_lock
|
||||
from release_refs import read_images, replace_images
|
||||
from release_state import read_json, validate_images, validate_state, write_json_atomic
|
||||
from validate_release import read_json as read_manifest_json, validate_manifest
|
||||
|
||||
ROOT = Path("/opt/akyldash/current")
|
||||
COMPOSE_FILE = ROOT / "deploy/production/compose.yaml"
|
||||
ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
BACKUP_DIR = Path("/var/backups/akyldash")
|
||||
BACKUP_TOOL = Path("/usr/local/lib/akyldash/archive.py")
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
JOURNAL_FILE = Path("/opt/akyldash/release-recovery.json")
|
||||
DEPLOYED_IMAGE_FILE = Path("/opt/akyldash/deployed-image.txt")
|
||||
SERVICES = ("backend", "opensearch")
|
||||
SEARCH_CANARY_URL = "https://akyldash.kg/search"
|
||||
|
||||
|
||||
def run(command: list[str], capture: bool = False) -> str:
|
||||
result = subprocess.run(command, check=True, text=True, capture_output=capture)
|
||||
return result.stdout.strip() if capture else ""
|
||||
|
||||
|
||||
def compose(*args: str, capture: bool = False) -> str:
|
||||
return run(["docker", "compose", "--env-file", str(ENV_FILE), "-f", str(COMPOSE_FILE), *args], capture)
|
||||
|
||||
|
||||
def image_ids(*, allow_stopped: bool = False) -> dict[str, dict[str, str]]:
|
||||
configured = read_images(ENV_FILE)[1]
|
||||
records = {}
|
||||
for service, key in (("backend", "BACKEND_IMAGE"), ("opensearch", "OPENSEARCH_IMAGE")):
|
||||
container = compose("ps", "--all", "--quiet", service, capture=True)
|
||||
if not container or "\n" in container:
|
||||
raise ValueError(f"expected exactly one existing {service} container")
|
||||
inspection = run(
|
||||
["docker", "inspect", "--format", "{{.State.Running}}\t{{.Image}}", container], capture=True
|
||||
)
|
||||
running, separator, image_id = inspection.partition("\t")
|
||||
if not separator or (running != "true" and not (allow_stopped and running == "false")):
|
||||
raise ValueError(f"{service} container state is not approved for the release operation")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"invalid running image ID for {service}")
|
||||
records[service] = {"configured": configured[key], "image_id": image_id}
|
||||
return records
|
||||
|
||||
|
||||
def write_deployed_backend_image(image_id: str) -> None:
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError("invalid backend image ID for deployment record")
|
||||
with tempfile.NamedTemporaryFile(dir=DEPLOYED_IMAGE_FILE.parent, prefix=".deployed-image-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(f"{image_id}\n".encode("ascii"))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, DEPLOYED_IMAGE_FILE)
|
||||
directory_fd = os.open(DEPLOYED_IMAGE_FILE.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def require_backup_matches_current(current: dict, backup: dict) -> None:
|
||||
if current != backup:
|
||||
raise ValueError("backup image references or image IDs do not match the current deployment")
|
||||
|
||||
|
||||
def restore_previous_references(old_env: dict[str, str], new_env: dict[str, str]) -> None:
|
||||
_, actual = read_images(ENV_FILE)
|
||||
if actual == new_env:
|
||||
replace_images(ENV_FILE, new_env, old_env)
|
||||
elif actual != old_env:
|
||||
raise ValueError("production image references match neither the previous nor target release")
|
||||
_, restored = read_images(ENV_FILE)
|
||||
if restored != old_env:
|
||||
raise ValueError("previous production image references could not be verified")
|
||||
|
||||
|
||||
def app_images(value: dict, label: str) -> dict[str, dict[str, str]]:
|
||||
images = value.get("images")
|
||||
if not isinstance(images, dict) or not set(SERVICES) <= images.keys():
|
||||
raise ValueError(f"{label} does not contain both application image records")
|
||||
return validate_images({service: images[service] for service in SERVICES}, label)
|
||||
|
||||
|
||||
def validate_search_response(payload: dict, language: str) -> None:
|
||||
results = payload.get("results") if isinstance(payload, dict) else None
|
||||
if not isinstance(results, list) or not results:
|
||||
raise ValueError(f"{language} search canary returned no results")
|
||||
if not isinstance(results[0], dict) or not str(results[0].get("code", "")).strip():
|
||||
raise ValueError(f"{language} search canary returned a malformed first result")
|
||||
|
||||
|
||||
def check_search_canary(language: str, query: str) -> None:
|
||||
if language not in {"ru", "ky"} or not query.strip():
|
||||
raise ValueError("both Russian and Kyrgyz search canaries must be non-empty")
|
||||
params = urllib.parse.urlencode({"q": query, "language": language, "page_size": "1"})
|
||||
output = run(
|
||||
["curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
f"{SEARCH_CANARY_URL}?{params}"], capture=True
|
||||
)
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"{language} search canary returned invalid JSON") from error
|
||||
validate_search_response(payload, language)
|
||||
|
||||
|
||||
def deploy_external(
|
||||
manifest_value: dict, policy_value: dict, backup_value: dict, backup_identity: dict,
|
||||
descriptor_sha256: str, current_before_stop: dict, canary_ru: str, canary_ky: str,
|
||||
) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("production cutover is owner-run and requires root")
|
||||
manifest = validate_manifest(manifest_value, policy_value)
|
||||
if manifest["builder"] != {"name": "Kaniko", "image": BUILDER_IMAGE}:
|
||||
raise ValueError("release descriptor uses an unapproved builder")
|
||||
from release_state import validate_backup_identity
|
||||
|
||||
backup_identity = validate_backup_identity({
|
||||
key: backup_identity[key]
|
||||
for key in ("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")
|
||||
})
|
||||
if backup_identity is None or backup_identity["manifest"] != backup_value:
|
||||
raise ValueError("release requires a source-generated, live-verified external backup")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
|
||||
raise ValueError("release descriptor digest is invalid")
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import require_runtime_compatibility
|
||||
|
||||
def verify_release_still_current() -> None:
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT,
|
||||
policy_file=Path("/etc/akyldash/release-policy.json"),
|
||||
)
|
||||
|
||||
verify_release_still_current()
|
||||
if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop:
|
||||
raise ValueError("stopped services do not match the pre-backup image snapshot")
|
||||
if app_images(backup_value, "external backup images") != current_before_stop:
|
||||
raise ValueError("external backup image identities do not match the current deployment")
|
||||
current_files = {
|
||||
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
|
||||
for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
|
||||
}
|
||||
if backup_value.get("deployment_files") != current_files:
|
||||
raise ValueError("external backup configuration does not match the checked out Compose and Caddy files")
|
||||
pinned = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
pinned = validate_state(read_json(STATE_FILE))
|
||||
if pinned["current"] != current_before_stop:
|
||||
raise ValueError("current deployment does not match the recorded release state")
|
||||
|
||||
backup_record = {
|
||||
"backup_id": backup_identity["backup_id"],
|
||||
"size": backup_identity["size"],
|
||||
"archive_sha256": backup_identity["archive_sha256"],
|
||||
"manifest_sha256": backup_identity["manifest_sha256"],
|
||||
"manifest": backup_value,
|
||||
}
|
||||
backup_record = validate_backup_identity(backup_record)
|
||||
|
||||
old_env = {"BACKEND_IMAGE": current_before_stop["backend"]["configured"], "OPENSEARCH_IMAGE": current_before_stop["opensearch"]["configured"]}
|
||||
new_env = {"BACKEND_IMAGE": manifest["images"]["backend"], "OPENSEARCH_IMAGE": manifest["images"]["opensearch"]}
|
||||
journal = read_json(JOURNAL_FILE)
|
||||
state_written = False
|
||||
try:
|
||||
for service in SERVICES:
|
||||
reference = manifest["images"][service]
|
||||
run(["docker", "pull", reference])
|
||||
pulled_id = run(["docker", "image", "inspect", "--format", "{{.Id}}", reference], capture=True)
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", pulled_id):
|
||||
raise ValueError(f"registry returned an invalid image ID for {service}")
|
||||
target = {
|
||||
service: {"configured": manifest["images"][service], "image_id": run(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", manifest["images"][service]], capture=True
|
||||
)}
|
||||
for service in SERVICES
|
||||
}
|
||||
journal.update({"status": "external-backup-verified", "target": target})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
verify_release_still_current()
|
||||
replace_images(ENV_FILE, old_env, new_env)
|
||||
journal["status"] = "references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
actual = image_ids()
|
||||
if actual != target:
|
||||
raise ValueError("started image IDs do not match the release descriptor")
|
||||
run(["curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
"https://akyldash.kg/review", "--output", "/dev/null"])
|
||||
check_search_canary("ru", canary_ru)
|
||||
check_search_canary("ky", canary_ky)
|
||||
write_deployed_backend_image(actual["backend"]["image_id"])
|
||||
state = {
|
||||
"schema_version": 2, "commit": manifest["commit"],
|
||||
"manifest_sha256": descriptor_sha256, "current": actual,
|
||||
"previous": current_before_stop, "previous_backup": backup_record,
|
||||
}
|
||||
write_json_atomic(STATE_FILE, state)
|
||||
state_written = True
|
||||
JOURNAL_FILE.unlink()
|
||||
print(f"PASS deployed release {manifest['commit']} with --no-build", file=sys.stderr)
|
||||
except Exception as error:
|
||||
if state_written:
|
||||
journal["status"] = "release-active-journal-cleanup-needed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
journal["recovery"] = "release is active; verify host and resolve the recovery journal"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"release is active; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
journal["status"] = "cutover-failed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
try:
|
||||
restore_previous_references(old_env, new_env)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
restored = image_ids()
|
||||
if restored != current_before_stop:
|
||||
raise ValueError("restored running image IDs do not match the previous release")
|
||||
write_deployed_backend_image(current_before_stop["backend"]["image_id"])
|
||||
if pinned is not None:
|
||||
write_json_atomic(STATE_FILE, pinned)
|
||||
elif (STATE_FILE.exists() or STATE_FILE.is_symlink()) and not STATE_FILE.is_symlink():
|
||||
STATE_FILE.unlink()
|
||||
journal["recovery"] = "previous references and running image IDs restored and verified"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = (
|
||||
f"manual recovery required ({type(recovery_error).__name__}); "
|
||||
"references, release state, and external backup identity are recorded"
|
||||
)
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"cutover failed; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
argparse.ArgumentParser(description=__doc__).parse_args()
|
||||
print("ERROR: local backup paths are disabled; use the fixed external session receiver", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""One root-owned lock shared by image cutover and rollback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import stat
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
LOCK_FILE = Path("/run/akyldash-deploy.lock")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def deployment_lock():
|
||||
descriptor = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o777 != 0o600:
|
||||
raise ValueError("deployment lock must be a regular mode-0600 file")
|
||||
if os.geteuid() == 0 and info.st_uid != 0:
|
||||
raise ValueError("deployment lock must be root-owned")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
raise ValueError("another deployment or rollback holds the shared lock") from error
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fixed root-side Gitea release receiver and external-backup cutover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids
|
||||
from external_session import read_request, send_backup_session, validate_request, write_result
|
||||
from release_source import fetch_release, require_runtime_compatibility
|
||||
from release_state import read_json, validate_state, write_json_atomic
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
CANARIES_FILE = Path("/etc/akyldash/release-canaries.json")
|
||||
COMPOSE_FILE = ROOT / "deploy/production/compose.yaml"
|
||||
ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
|
||||
|
||||
def owner_canaries() -> tuple[str, str]:
|
||||
info = CANARIES_FILE.lstat()
|
||||
if (CANARIES_FILE.is_symlink() or not CANARIES_FILE.is_file() or info.st_uid != 0
|
||||
or info.st_mode & 0o777 != 0o600):
|
||||
raise ValueError("release canaries must be stored in a root-owned mode-0600 file")
|
||||
value = json.loads(CANARIES_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or set(value) != {"ru", "ky"}:
|
||||
raise ValueError("release canary configuration must contain exactly ru and ky")
|
||||
queries = (value["ru"], value["ky"])
|
||||
if any(not isinstance(query, str) or not query.strip() or len(query) > 256 for query in queries):
|
||||
raise ValueError("release canaries must be non-empty strings of at most 256 characters")
|
||||
return queries
|
||||
|
||||
|
||||
def restart_current(current: dict) -> None:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted service image IDs do not match the pre-backup deployment")
|
||||
|
||||
|
||||
def execute(request: dict, *, reader, writer, policy: dict | None = None, api=None,
|
||||
current_main: str | None = None, canaries: tuple[str, str] | None = None) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("the fixed external deployment receiver must run as root")
|
||||
validate_request(request)
|
||||
policy = policy if policy is not None else read_json(POLICY_FILE)
|
||||
canary_ru, canary_ky = canaries or owner_canaries()
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
manifest, _manifest_bytes = fetch_release(
|
||||
request, policy=policy, current_main=current_main, checkout=ROOT,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
|
||||
with deployment_lock():
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
current = image_ids()
|
||||
current_record = {service: current[service] for service in SERVICES}
|
||||
revision = deploy_release_revision()
|
||||
previous_state = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
previous_state = validate_state(read_json(STATE_FILE))
|
||||
if previous_state["current"] != current_record:
|
||||
raise ValueError("current deployment does not match the recorded release state")
|
||||
if (previous_state["commit"] == manifest["commit"]
|
||||
and previous_state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
raise ValueError("release is already applied")
|
||||
journal = {
|
||||
"schema_version": 2, "status": "external-backup-pending", "commit": manifest["commit"],
|
||||
"manifest_sha256": request["descriptor_sha256"], "previous": current_record,
|
||||
}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
compose("stop")
|
||||
sources, stopped_revision, stopped_images, deployment_files = compose_sources(COMPOSE_FILE, ENV_FILE)
|
||||
if stopped_revision != revision or stopped_images.get("backend") != current["backend"] or stopped_images.get("opensearch") != current["opensearch"]:
|
||||
raise ValueError("stopped production inventory does not match its pre-backup snapshot")
|
||||
backup_identity = send_backup_session(
|
||||
writer, reader, sources=sources, revision=stopped_revision,
|
||||
images=stopped_images, deployment_files=deployment_files,
|
||||
context={key: request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")},
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
restart_current(current)
|
||||
JOURNAL_FILE.unlink(missing_ok=True)
|
||||
except Exception as recovery_error:
|
||||
journal.update({"status": "external-backup-failed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"external backup failed and current services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
|
||||
backup_record = {key: backup_identity[key] for key in
|
||||
("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")}
|
||||
journal = {
|
||||
"schema_version": 2, "status": "external-backup-verified", "commit": manifest["commit"],
|
||||
"manifest_sha256": request["descriptor_sha256"], "backup": backup_record,
|
||||
"previous": current_record,
|
||||
}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
deploy_external(
|
||||
manifest, policy, backup_identity["manifest"], backup_record,
|
||||
request["descriptor_sha256"], current_record, canary_ru, canary_ky,
|
||||
)
|
||||
except Exception as error:
|
||||
current_journal = read_json(JOURNAL_FILE) if JOURNAL_FILE.exists() else {}
|
||||
if current_journal.get("status") == "external-backup-verified":
|
||||
try:
|
||||
restart_current(current)
|
||||
JOURNAL_FILE.unlink()
|
||||
except Exception as recovery_error:
|
||||
current_journal["status"] = "pre-cutover-recovery-needed"
|
||||
current_journal["error_type"] = type(error).__name__
|
||||
current_journal["recovery_error_type"] = type(recovery_error).__name__
|
||||
write_json_atomic(JOURNAL_FILE, current_journal)
|
||||
raise ValueError(f"pre-cutover recovery failed; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
return {"backup_id": backup_record["backup_id"], "commit": manifest["commit"]}
|
||||
|
||||
|
||||
def deploy_release_revision() -> str:
|
||||
from deploy_release import run
|
||||
return run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
|
||||
|
||||
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
request = read_request(sys.stdin.buffer)
|
||||
result = execute(request, reader=sys.stdin.buffer, writer=sys.stdout.buffer)
|
||||
write_result(sys.stdout.buffer, status="success", message=result["backup_id"])
|
||||
except Exception as error:
|
||||
print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
try:
|
||||
write_result(sys.stdout.buffer, status="error", message=f"deployment failed ({type(error).__name__})")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-side rollback using a pinned external backup and a fresh external backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import COMPOSE_FILE, ENV_FILE, JOURNAL_FILE, ROOT, compose, image_ids
|
||||
from external_session import (nonce, read_ack, read_request,
|
||||
receive_archive, send_backup_session, write_challenge,
|
||||
write_result)
|
||||
from release_state import (read_json, validate_state, write_json_atomic)
|
||||
from rollback import rollback_external
|
||||
|
||||
STATE_FILE = ROOT.parent / "release-state.json"
|
||||
|
||||
|
||||
class Discard:
|
||||
def write(self, chunk: bytes) -> int:
|
||||
return len(chunk)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def execute(*, reader, writer) -> str:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("the fixed external rollback receiver must run as root")
|
||||
request = read_request(reader)
|
||||
if request != {"version": 1, "action": "rollback", "data_compatible": True}:
|
||||
raise ValueError("external rollback request is invalid")
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before rollback")
|
||||
|
||||
with deployment_lock():
|
||||
state = validate_state(read_json(STATE_FILE))
|
||||
if state["schema_version"] != 2 or state["previous_backup"] is None:
|
||||
raise ValueError("release state has no verified external previous backup")
|
||||
target = state["previous_backup"]
|
||||
session_nonce = nonce()
|
||||
write_challenge(writer, target, session_nonce=session_nonce)
|
||||
size, digest = receive_archive(reader, Discard(), maximum=target["size"])
|
||||
if size != target["size"] or digest != target["archive_sha256"]:
|
||||
raise ValueError("rollback bytes do not match the root-pinned external backup")
|
||||
read_ack(reader, session_nonce=session_nonce, backup_id=target["backup_id"], size=size,
|
||||
archive_sha256=digest, manifest_sha256=target["manifest_sha256"])
|
||||
|
||||
current = image_ids()
|
||||
if current != state["current"]:
|
||||
raise ValueError("current deployment does not match root-pinned release state")
|
||||
journal = {"schema_version": 2, "status": "external-backup-pending", "previous": current}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
compose("stop")
|
||||
sources, revision, stopped_images, deployment_files = compose_sources(COMPOSE_FILE, ENV_FILE)
|
||||
if any(stopped_images.get(service) != record for service, record in current.items()):
|
||||
raise ValueError("stopped deployment does not match its pre-rollback snapshot")
|
||||
fresh = send_backup_session(
|
||||
writer, reader, sources=sources, revision=revision, images=stopped_images,
|
||||
deployment_files=deployment_files,
|
||||
context={"action": "rollback", "backup_id": target["backup_id"],
|
||||
"archive_sha256": target["archive_sha256"],
|
||||
"manifest_sha256": target["manifest_sha256"]},
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted image IDs differ from the pre-rollback deployment")
|
||||
JOURNAL_FILE.unlink(missing_ok=True)
|
||||
except Exception as recovery_error:
|
||||
journal.update({"status": "external-backup-failed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"rollback backup failed and services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
|
||||
fresh_record = {key: fresh[key] for key in
|
||||
("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")}
|
||||
write_json_atomic(JOURNAL_FILE, {
|
||||
"schema_version": 2, "status": "external-backup-verified",
|
||||
"backup": fresh_record, "previous": current,
|
||||
})
|
||||
try:
|
||||
rollback_external(state=state, target_identity=target, current=current,
|
||||
fresh_backup=fresh_record, data_compatible=True)
|
||||
except Exception as error:
|
||||
journal = read_json(JOURNAL_FILE) if JOURNAL_FILE.exists() else {}
|
||||
if journal.get("status") == "external-backup-verified":
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted image IDs differ from the pre-rollback deployment")
|
||||
JOURNAL_FILE.unlink()
|
||||
except Exception as recovery_error:
|
||||
journal.update({
|
||||
"status": "pre-cutover-recovery-needed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__,
|
||||
})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"rollback preflight failed and services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
return fresh_record["backup_id"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
result = execute(reader=sys.stdin.buffer, writer=sys.stdout.buffer)
|
||||
write_result(sys.stdout.buffer, status="success", message=result)
|
||||
return 0
|
||||
except Exception as error:
|
||||
print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
try:
|
||||
write_result(sys.stdout.buffer, status="error", message=f"rollback failed ({type(error).__name__})")
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only preparation endpoint for one fully verified current-main release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import fetch_release, latest_release_request, runtime_compatibility_reason
|
||||
from release_state import read_json, validate_state
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
STATUS_MAX_BYTES = 1024
|
||||
|
||||
|
||||
def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE,
|
||||
policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE,
|
||||
main_lookup=latest_main_sha) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("release status command must run as root")
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
|
||||
current_main = main_lookup(checkout)
|
||||
api_args = {"api": api} if api is not None else {}
|
||||
reason = runtime_compatibility_reason(
|
||||
current_main, checkout=checkout, policy_file=policy_file, **api_args,
|
||||
)
|
||||
if reason is not None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None}
|
||||
request = latest_release_request(current_main=current_main, checkout=checkout, **api_args)
|
||||
if request is None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None}
|
||||
if state_file.exists() or state_file.is_symlink():
|
||||
state = validate_state(read_json(state_file))
|
||||
if (state["commit"] == request["commit"]
|
||||
and state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
manifest, _raw = fetch_release(
|
||||
request, policy=read_json(policy_file), current_main=current_main, checkout=checkout,
|
||||
**api_args,
|
||||
)
|
||||
if manifest["commit"] != current_main or manifest["run_id"] != request["run_id"]:
|
||||
raise ValueError("verified release descriptor does not match the selected source")
|
||||
if main_lookup(checkout) != current_main:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None}
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
if state_file.exists() or state_file.is_symlink():
|
||||
state = validate_state(read_json(state_file))
|
||||
if (state["commit"] == request["commit"]
|
||||
and state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"state": "prepared",
|
||||
"reason": None,
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
|
||||
|
||||
def write_status(value: dict, stream=None) -> None:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
if len(payload) > STATUS_MAX_BYTES:
|
||||
raise ValueError("release status response exceeds its size limit")
|
||||
(stream or sys.stdout.buffer).write(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
write_status(status_document())
|
||||
except Exception as error:
|
||||
print(f"release status unavailable ({type(error).__name__})", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${GITEA_URL:-}" ]; then
|
||||
echo "gitea-api: GITEA_URL is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "gitea-api: GITEA_TOKEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
method="${1:-GET}"
|
||||
path="${2:-}"
|
||||
body="${3:-}"
|
||||
|
||||
if [ -z "$path" ]; then
|
||||
echo "Usage: gitea-api METHOD /path [JSON]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base="${GITEA_URL%/}/api/v1"
|
||||
tmp="$(mktemp)"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
args=(
|
||||
-sS
|
||||
-o "$tmp"
|
||||
-w "%{http_code}"
|
||||
-X "$method"
|
||||
-H "Authorization: token ${GITEA_TOKEN}"
|
||||
-H "Accept: application/json"
|
||||
)
|
||||
|
||||
if [ -n "$body" ]; then
|
||||
args+=(
|
||||
-H "Content-Type: application/json"
|
||||
--data "$body"
|
||||
)
|
||||
fi
|
||||
|
||||
http_code="$(curl "${args[@]}" "${base}${path}")"
|
||||
curl_status=$?
|
||||
|
||||
cat "$tmp"
|
||||
|
||||
if [ "$curl_status" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "gitea-api: curl failed with exit code $curl_status" >&2
|
||||
exit "$curl_status"
|
||||
fi
|
||||
|
||||
case "$http_code" in
|
||||
2??)
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo >&2
|
||||
echo "gitea-api: HTTP $http_code" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,92 @@
|
||||
--- a/gitea-api
|
||||
+++ b/gitea-api
|
||||
@@ -13,0 +14,5 @@
|
||||
+if [[ ! "$GITEA_TOKEN" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
+ echo "gitea-api: GITEA_TOKEN contains unsupported characters" >&2
|
||||
+ exit 1
|
||||
+fi
|
||||
+
|
||||
@@ -30,0 +36,78 @@
|
||||
+if [ "$method" = "DOWNLOAD" ]; then
|
||||
+ output="$body"
|
||||
+ max_bytes=10485760
|
||||
+ if [ -z "$output" ] || [ ! -d "$(dirname "$output")" ] || [ -e "$output" ] || [ -L "$output" ]; then
|
||||
+ echo "Usage: gitea-api DOWNLOAD /path /new/output-file" >&2
|
||||
+ exit 2
|
||||
+ fi
|
||||
+ case "$GITEA_URL" in
|
||||
+ https://*) gitea_host="${GITEA_URL#https://}"; gitea_host="${gitea_host%%/*}"; gitea_origin="https://${gitea_host}" ;;
|
||||
+ *) echo "gitea-api: binary downloads require HTTPS" >&2; exit 2 ;;
|
||||
+ esac
|
||||
+ if [[ ! "$gitea_origin" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
|
||||
+ echo "gitea-api: invalid Gitea origin for binary download" >&2
|
||||
+ exit 2
|
||||
+ fi
|
||||
+ headers="$(mktemp)"
|
||||
+ download="$(mktemp "$(dirname "$output")/.gitea-api-download.XXXXXX")"
|
||||
+ chmod 600 "$download"
|
||||
+ cleanup() { rm -f "$tmp" "$headers" "$download"; }
|
||||
+ trap cleanup EXIT
|
||||
+
|
||||
+ http_code="$(curl -sS -D "$headers" -o /dev/null -w "%{http_code}" --max-filesize 1048576 \
|
||||
+ -H "Accept: application/octet-stream" --config - "${base}${path}" <<< "header = \"Authorization: token ${GITEA_TOKEN}\"")" || { echo "gitea-api: artifact metadata request failed" >&2; exit 1; }
|
||||
+ if [[ "$http_code" == 2?? ]]; then
|
||||
+ set +e
|
||||
+ curl -sS --fail --proto '=https' --max-filesize "$max_bytes" \
|
||||
+ --config - "$base$path" <<< "header = \"Authorization: token ${GITEA_TOKEN}\"" | head -c "$((max_bytes + 1))" > "$download"
|
||||
+ statuses=("${PIPESTATUS[@]}")
|
||||
+ set -e
|
||||
+ size="$(wc -c < "$download")"
|
||||
+ if (( size > max_bytes )); then
|
||||
+ echo "gitea-api: artifact exceeds the 10 MiB download limit" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ if (( statuses[0] != 0 || statuses[1] != 0 )); then
|
||||
+ echo "gitea-api: artifact download failed" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ elif [[ "$http_code" == 302 ]]; then
|
||||
+ location="$(awk 'tolower(substr($0,1,9)) == "location:" { sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0 } END { print value }' "$headers")"
|
||||
+ if [[ ! "$location" =~ ^https://([A-Za-z0-9.-]+(:[0-9]+)?)(/[^#]*)?$ ]]; then
|
||||
+ echo "gitea-api: artifact redirect is not an absolute HTTPS URL" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ redirect_origin="https://${BASH_REMATCH[1],,}"
|
||||
+ gitea_origin="${gitea_origin,,}"
|
||||
+ if [[ "$redirect_origin" != "$gitea_origin" && ",${GITEA_DOWNLOAD_ORIGINS:-}," != *",${redirect_origin},"* ]]; then
|
||||
+ echo "gitea-api: artifact redirect origin is not allowlisted" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ set +e
|
||||
+ curl -sS --proto '=https' --max-filesize "$max_bytes" -o - "$location" | head -c "$((max_bytes + 1))" > "$download"
|
||||
+ statuses=("${PIPESTATUS[@]}")
|
||||
+ set -e
|
||||
+ size="$(wc -c < "$download")"
|
||||
+ if (( size > max_bytes )); then
|
||||
+ echo "gitea-api: artifact exceeds the 10 MiB download limit" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ if (( statuses[0] != 0 )); then
|
||||
+ echo "gitea-api: artifact download failed" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ else
|
||||
+ echo "gitea-api: artifact endpoint returned HTTP $http_code" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ size="$(wc -c < "$download")"
|
||||
+ if (( size > max_bytes )); then
|
||||
+ echo "gitea-api: artifact exceeds the 10 MiB download limit" >&2
|
||||
+ exit 1
|
||||
+ fi
|
||||
+ chmod 600 "$download"
|
||||
+ ln -- "$download" "$output" || { echo "gitea-api: output file already exists" >&2; exit 1; }
|
||||
+ echo "PASS downloaded Gitea artifact (${size} bytes)"
|
||||
+ exit 0
|
||||
+fi
|
||||
+
|
||||
@@ -36 +118,0 @@
|
||||
- -H "Authorization: token ${GITEA_TOKEN}"
|
||||
@@ -47 +129 @@
|
||||
-http_code="$(curl "${args[@]}" "${base}${path}")"
|
||||
+http_code="$(curl "${args[@]}" --config - "${base}${path}" <<< "header = \"Authorization: token ${GITEA_TOKEN}\"")"
|
||||
@@ -8,10 +8,21 @@ fi
|
||||
|
||||
checkout=/opt/akyldash/current
|
||||
rollback_source=$checkout/infra/deploy/rollback.py
|
||||
release_refs_source=$checkout/infra/deploy/release_refs.py
|
||||
release_state_source=$checkout/infra/deploy/release_state.py
|
||||
deployment_lock_source=$checkout/infra/deploy/deployment_lock.py
|
||||
backup_source=$checkout/infra/backup/archive.py
|
||||
verify_source=$checkout/infra/bootstrap/verify-helper.sh
|
||||
compose_source=$checkout/infra/deploy/compose-helper.sh
|
||||
if [[ ! -d $checkout/.git || ! -f $rollback_source || ! -f $backup_source || ! -f $verify_source || ! -f $compose_source ]]; then
|
||||
release_deploy_source=$checkout/infra/deploy/akyldash-release-deploy.sh
|
||||
release_rollback_source=$checkout/infra/deploy/akyldash-release-rollback.sh
|
||||
release_dispatch_source=$checkout/infra/deploy/akyldash-release-dispatch.sh
|
||||
release_controller_source=$checkout/infra/deploy/external_deploy.py
|
||||
release_status_source=$checkout/infra/deploy/external_status.py
|
||||
gitea_helper_fixture=$checkout/infra/deploy/gitea-api-base.fixture
|
||||
gitea_helper_patch=$checkout/infra/deploy/gitea-api-binary-download.patch
|
||||
release_policy_source=$checkout/infra/deploy/release-policy.json
|
||||
if [[ ! -d $checkout/.git || ! -f $rollback_source || ! -f $release_refs_source || ! -f $release_state_source || ! -f $deployment_lock_source || ! -f $backup_source || ! -f $verify_source || ! -f $compose_source || ! -f $release_deploy_source || ! -f $release_rollback_source || ! -f $release_dispatch_source || ! -f $release_controller_source || ! -f $release_status_source || ! -f "$checkout/infra/deploy/external_rollback.py" || ! -f "$checkout/infra/backup/external_session.py" || ! -f "$checkout/infra/deploy/create_release_manifest.py" || ! -f "$checkout/infra/deploy/deploy_release.py" || ! -f "$checkout/infra/deploy/release_handoff.py" || ! -f "$checkout/infra/deploy/release_source.py" || ! -f "$checkout/infra/deploy/validate_release.py" || ! -f $gitea_helper_fixture || ! -f $gitea_helper_patch || ! -f $release_policy_source ]]; then
|
||||
echo "Install the approved repository checkout at $checkout first." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -43,8 +54,13 @@ if ! command -v visudo >/dev/null 2>&1; then
|
||||
echo "visudo is required to validate the restricted sudoers rule." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v patch >/dev/null 2>&1 || ! command -v sha256sum >/dev/null 2>&1; then
|
||||
echo "patch and sha256sum are required to install the reviewed binary-safe Gitea helper." >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -n "$release_deploy_source" "$release_rollback_source" "$release_dispatch_source"
|
||||
|
||||
for path in /usr/local/sbin/akyldash-verify /usr/local/sbin/akyldash-rollback /usr/local/sbin/akyldash-compose /usr/local/lib/akyldash/archive.py /etc/sudoers.d/akyldash-codex; do
|
||||
for path in /usr/local/sbin/akyldash-verify /usr/local/sbin/akyldash-rollback /usr/local/sbin/akyldash-compose /usr/local/sbin/akyldash-release-deploy /usr/local/sbin/akyldash-release-rollback /usr/local/sbin/akyldash-release-dispatch /usr/local/lib/akyldash/archive.py /usr/local/lib/akyldash/external_session.py /usr/local/lib/akyldash/create_release_manifest.py /usr/local/lib/akyldash/deploy_release.py /usr/local/lib/akyldash/deployment_lock.py /usr/local/lib/akyldash/external_deploy.py /usr/local/lib/akyldash/external_status.py /usr/local/lib/akyldash/external_rollback.py /usr/local/lib/akyldash/rollback.py /usr/local/lib/akyldash/release_handoff.py /usr/local/lib/akyldash/release_refs.py /usr/local/lib/akyldash/release_source.py /usr/local/lib/akyldash/release_state.py /usr/local/lib/akyldash/validate_release.py /usr/local/lib/akyldash/gitea-api /etc/akyldash/release-policy.json /etc/sudoers.d/akyldash-codex; do
|
||||
if [[ -L $path ]]; then
|
||||
echo "Refusing symlink at $path." >&2
|
||||
exit 1
|
||||
@@ -53,9 +69,44 @@ done
|
||||
|
||||
install -d -o root -g root -m 0755 /usr/local/lib/akyldash
|
||||
install -o root -g root -m 0644 "$backup_source" /usr/local/lib/akyldash/archive.py
|
||||
install -o root -g root -m 0644 "$release_refs_source" /usr/local/lib/akyldash/release_refs.py
|
||||
install -o root -g root -m 0644 "$release_state_source" /usr/local/lib/akyldash/release_state.py
|
||||
install -o root -g root -m 0644 "$deployment_lock_source" /usr/local/lib/akyldash/deployment_lock.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/backup/external_session.py" /usr/local/lib/akyldash/external_session.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/create_release_manifest.py" /usr/local/lib/akyldash/create_release_manifest.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/deploy_release.py" /usr/local/lib/akyldash/deploy_release.py
|
||||
install -o root -g root -m 0644 "$rollback_source" /usr/local/lib/akyldash/rollback.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/external_deploy.py" /usr/local/lib/akyldash/external_deploy.py
|
||||
install -o root -g root -m 0644 "$release_status_source" /usr/local/lib/akyldash/external_status.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/external_rollback.py" /usr/local/lib/akyldash/external_rollback.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/release_handoff.py" /usr/local/lib/akyldash/release_handoff.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/release_source.py" /usr/local/lib/akyldash/release_source.py
|
||||
install -o root -g root -m 0644 "$checkout/infra/deploy/validate_release.py" /usr/local/lib/akyldash/validate_release.py
|
||||
if [[ -e /etc/akyldash/release-policy.json ]]; then
|
||||
if [[ -L /etc/akyldash/release-policy.json || $(stat -c %U:%a /etc/akyldash/release-policy.json) != root:600 || ! -f /etc/akyldash/release-policy.json || $(cat /etc/akyldash/release-policy.json) != "$(cat "$release_policy_source")" ]]; then
|
||||
echo "The root-owned release policy exists with unexpected contents; review it manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
install -o root -g root -m 0600 "$release_policy_source" /etc/akyldash/release-policy.json
|
||||
fi
|
||||
gitea_tmp=$(mktemp -d /run/akyldash-gitea-api.XXXXXX)
|
||||
trap 'rm -rf -- "$gitea_tmp"' EXIT
|
||||
cp "$gitea_helper_fixture" "$gitea_tmp/gitea-api"
|
||||
if [[ $(sha256sum "$gitea_tmp/gitea-api" | cut -d ' ' -f 1) != 8227332d28e27bcce9b4e3801f6b8d4323636174a82c93a0177970f560629df0 ]]; then
|
||||
echo "The inspected base gitea-api helper changed; review a new patch before installing." >&2
|
||||
exit 1
|
||||
fi
|
||||
patch --batch --fuzz=0 -p1 -d "$gitea_tmp" < "$gitea_helper_patch"
|
||||
bash -n "$gitea_tmp/gitea-api"
|
||||
install -o root -g root -m 0750 "$gitea_tmp/gitea-api" /usr/local/lib/akyldash/gitea-api
|
||||
rm -rf -- "$gitea_tmp"
|
||||
install -o root -g root -m 0750 "$verify_source" /usr/local/sbin/akyldash-verify
|
||||
install -o root -g root -m 0750 "$rollback_source" /usr/local/sbin/akyldash-rollback
|
||||
install -o root -g root -m 0750 "$compose_source" /usr/local/sbin/akyldash-compose
|
||||
install -o root -g root -m 0750 "$release_deploy_source" /usr/local/sbin/akyldash-release-deploy
|
||||
install -o root -g root -m 0750 "$release_rollback_source" /usr/local/sbin/akyldash-release-rollback
|
||||
install -o root -g root -m 0750 "$release_dispatch_source" /usr/local/sbin/akyldash-release-dispatch
|
||||
|
||||
sudoers_file=/etc/sudoers.d/akyldash-codex
|
||||
expected=$'codex ALL=(root) NOPASSWD: /usr/local/sbin/akyldash-verify\ncodex ALL=(root) NOPASSWD: /usr/local/sbin/akyldash-rollback *\ncodex ALL=(root) NOPASSWD: /usr/local/sbin/akyldash-compose config\ncodex ALL=(root) NOPASSWD: /usr/local/sbin/akyldash-compose status'
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{40}$')]
|
||||
[string]$ExpectedCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BundlePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{64}$')]
|
||||
[string]$ExpectedBundleSha256
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$python = 'C:\Python313\python.exe'
|
||||
$git = 'C:\Program Files\Git\cmd\git.exe'
|
||||
$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'
|
||||
$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$appRoot = 'C:\Program Files\Akyldash'
|
||||
$app = Join-Path $appRoot "client-$ExpectedCommit"
|
||||
$programData = 'C:\ProgramData\Akyldash'
|
||||
$trigger = Join-Path $programData 'release-trigger.json'
|
||||
$autoApply = Join-Path $programData 'release-auto-apply.json'
|
||||
$mutationRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this reviewed bootstrap from an elevated administrator session.'
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $python
|
||||
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Python Software Foundation') {
|
||||
throw 'The pinned Python interpreter does not have the verified Python Software Foundation signature.'
|
||||
}
|
||||
function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" }
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
if ($AllowOwnerWrite) {
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
} else {
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
}
|
||||
if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" }
|
||||
if ($AllowOwnerWrite) {
|
||||
$allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or
|
||||
($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {
|
||||
throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path"
|
||||
}
|
||||
}
|
||||
$ownerHasFullControl = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
$_.IdentityReference.Value -eq $identity.User.Value -and
|
||||
($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl
|
||||
}).Count -gt 0
|
||||
if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" }
|
||||
} else {
|
||||
$writableUntrusted = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
(([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and
|
||||
$_.IdentityReference.Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
})
|
||||
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
|
||||
}
|
||||
}
|
||||
function Set-TrustedCodeOwner([string]$Path) {
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$administratorSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$acl.SetOwner($administratorSid)
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
}
|
||||
function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Refusing missing, non-filesystem, or reparse protected path: $Path"
|
||||
}
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
$current = if ($ParentsOnly) { $item.Parent } else { $item }
|
||||
$isLeaf = -not $ParentsOnly
|
||||
while ($null -ne $current) {
|
||||
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Protected path contains a reparse point: $($current.FullName)"
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $current.FullName
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Protected path has an untrusted owner: $($current.FullName)"
|
||||
}
|
||||
$checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights }
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }
|
||||
if ($rule.AccessControlType -eq 'Allow' -and
|
||||
(([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and
|
||||
$sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Protected path grants replacement/write access outside trusted system owners: $($current.FullName)"
|
||||
}
|
||||
}
|
||||
$current = $current.Parent
|
||||
$isLeaf = $false
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }
|
||||
|
||||
Assert-ProtectedPath (Split-Path -Parent $python)
|
||||
Assert-ProtectedPath $python
|
||||
if (-not (Test-Path -LiteralPath $appRoot -PathType Container)) { throw 'Protected receiver directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-TrustedPath $appRoot
|
||||
Assert-ProtectedPath $appRoot
|
||||
if (Test-Path -LiteralPath $app) { throw 'Versioned receiver destination already exists; inspect it manually instead of overwriting.' }
|
||||
if (-not (Test-Path -LiteralPath $programData -PathType Container)) { throw 'Protected ProgramData directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-ProtectedPath $programData -AllowOwnerWrite $true
|
||||
Assert-TrustedPath $programData -ParentsOnly $true
|
||||
if (Test-Path -LiteralPath $trigger) { Assert-ProtectedPath $trigger -AllowOwnerWrite $true }
|
||||
if (Test-Path -LiteralPath $trigger) {
|
||||
$triggerItem = Get-Item -LiteralPath $trigger
|
||||
if ($triggerItem.Length -gt 4096) { throw 'Existing trigger configuration exceeds its size limit.' }
|
||||
$triggerConfig = Get-Content -LiteralPath $trigger -Raw | ConvertFrom-Json
|
||||
$triggerFields = @($triggerConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($triggerConfig.schema_version -ne 1 -or $triggerConfig.enabled -ne $false -or
|
||||
$triggerConfig.repository -ne 'D:\Dev\akyldash' -or $triggerConfig.ssh_alias -ne 'akyldash_release' -or
|
||||
$triggerFields -ne 'enabled,repository,schema_version,ssh_alias') {
|
||||
throw 'Existing trigger configuration is not the fixed disabled configuration.'
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $autoApply) {
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
$autoItem = Get-Item -LiteralPath $autoApply
|
||||
if ($autoItem.Length -gt 1024) { throw 'Existing auto-apply configuration exceeds its size limit.' }
|
||||
$autoConfig = Get-Content -LiteralPath $autoApply -Raw | ConvertFrom-Json
|
||||
$autoFields = @($autoConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($autoConfig.schema_version -ne 1 -or $autoConfig.enabled -ne $false -or
|
||||
$autoFields -ne 'enabled,schema_version') {
|
||||
throw 'Existing auto-apply configuration must be disabled and match its fixed schema.'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-BoundedZipEntry($Entry, [long]$Maximum) {
|
||||
if ($Entry.Length -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
|
||||
$inputStream = $Entry.Open()
|
||||
$buffer = [IO.MemoryStream]::new()
|
||||
try {
|
||||
$chunk = New-Object byte[] 8192
|
||||
while (($count = $inputStream.Read($chunk, 0, $chunk.Length)) -gt 0) {
|
||||
if ($buffer.Length + $count -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
|
||||
$buffer.Write($chunk, 0, $count)
|
||||
}
|
||||
if ($buffer.Length -ne $Entry.Length) { throw "Receiver bundle entry length is invalid: $($Entry.FullName)" }
|
||||
return ,$buffer.ToArray()
|
||||
} finally {
|
||||
$inputStream.Dispose()
|
||||
$buffer.Dispose()
|
||||
}
|
||||
}
|
||||
function Assert-ReceiverBundleEntryMode($Entry) {
|
||||
$entryMode = (([long]$Entry.ExternalAttributes -shr 16) -band 0xFFFF)
|
||||
if ($entryMode -ne 0x8180) { throw "Receiver bundle entry has an unsafe file mode: $($Entry.FullName)" }
|
||||
}
|
||||
function Get-LowerHex([byte[]]$Bytes, [bool]$UseSha1 = $false) {
|
||||
$algorithm = if ($UseSha1) { [Security.Cryptography.SHA1]::Create() } else { [Security.Cryptography.SHA256]::Create() }
|
||||
try { return [BitConverter]::ToString($algorithm.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $algorithm.Dispose() }
|
||||
}
|
||||
function Get-GitObjectHex([string]$Kind, [byte[]]$Bytes) {
|
||||
$header = [Text.Encoding]::ASCII.GetBytes("$Kind $($Bytes.Length)`0")
|
||||
$payload = New-Object byte[] ($header.Length + $Bytes.Length)
|
||||
[Array]::Copy($header, 0, $payload, 0, $header.Length)
|
||||
[Array]::Copy($Bytes, 0, $payload, $header.Length, $Bytes.Length)
|
||||
return Get-LowerHex -Bytes $payload -UseSha1 $true
|
||||
}
|
||||
function Get-GitTreeEntries([byte[]]$Bytes) {
|
||||
$entries = @{}
|
||||
$offset = 0
|
||||
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
|
||||
while ($offset -lt $Bytes.Length) {
|
||||
$space = [Array]::IndexOf($Bytes, [byte]32, $offset)
|
||||
if ($space -lt $offset) { throw 'Git tree object has a malformed mode field.' }
|
||||
$nul = [Array]::IndexOf($Bytes, [byte]0, $space + 1)
|
||||
if ($nul -lt $space -or $nul + 21 -gt $Bytes.Length) { throw 'Git tree object has a malformed entry.' }
|
||||
$mode = [Text.Encoding]::ASCII.GetString($Bytes, $offset, $space - $offset)
|
||||
$name = $utf8Strict.GetString($Bytes, $space + 1, $nul - $space - 1)
|
||||
if (-not $name -or $name -in @('.', '..') -or $entries.ContainsKey($name)) { throw 'Git tree object has an invalid or duplicate name.' }
|
||||
$objectBytes = New-Object byte[] 20
|
||||
[Array]::Copy($Bytes, $nul + 1, $objectBytes, 0, 20)
|
||||
$entries[$name] = [PSCustomObject]@{
|
||||
mode = $mode
|
||||
sha1 = [BitConverter]::ToString($objectBytes).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
$offset = $nul + 21
|
||||
}
|
||||
return ,$entries
|
||||
}
|
||||
|
||||
# The package is opened once with write/delete sharing denied. Its bytes are
|
||||
# hashed and held in memory before any installation write; no checkout or Git
|
||||
# command participates in this elevated stage.
|
||||
$bundleItem = Get-Item -LiteralPath $BundlePath -Force
|
||||
if ($bundleItem -isnot [IO.FileInfo] -or ($bundleItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Receiver bundle must be a regular non-reparse file.'
|
||||
}
|
||||
$bundleStream = [IO.File]::Open([IO.Path]::GetFullPath($BundlePath), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
||||
$zip = $null
|
||||
$verifiedFiles = @{}
|
||||
try {
|
||||
if ($bundleStream.Length -le 0 -or $bundleStream.Length -gt 524288) { throw 'Receiver bundle has an invalid size.' }
|
||||
$bundleHasher = [Security.Cryptography.SHA256]::Create()
|
||||
try { $bundleHash = [BitConverter]::ToString($bundleHasher.ComputeHash($bundleStream)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $bundleHasher.Dispose() }
|
||||
if ($bundleHash -ne $ExpectedBundleSha256) { throw 'Receiver bundle SHA-256 does not match ExpectedBundleSha256.' }
|
||||
$bundleStream.Position = 0
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$zip = [System.IO.Compression.ZipArchive]::new($bundleStream, [IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
$expectedEntries = @('manifest.json', 'objects/commit.bin', 'objects/root-tree.bin',
|
||||
'objects/infra-tree.bin', 'objects/backup-tree.bin', 'objects/deploy-tree.bin',
|
||||
'files/archive.py', 'files/external_session.py',
|
||||
'files/receive_external.py', 'files/windows-release-trigger.example.xml',
|
||||
'files/windows_release_launcher.py')
|
||||
$entryNames = @($zip.Entries | ForEach-Object { $_.FullName })
|
||||
if ($entryNames.Count -ne $expectedEntries.Count -or
|
||||
(@($entryNames | Select-Object -Unique).Count -ne $expectedEntries.Count)) {
|
||||
throw 'Receiver bundle contains duplicate or unexpected entries.'
|
||||
}
|
||||
foreach ($expectedEntry in $expectedEntries) {
|
||||
if ($entryNames -cnotcontains $expectedEntry) { throw "Receiver bundle is missing an exact required entry: $expectedEntry" }
|
||||
}
|
||||
foreach ($zipEntry in $zip.Entries) {
|
||||
Assert-ReceiverBundleEntryMode -Entry $zipEntry
|
||||
}
|
||||
$manifestBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('manifest.json') -Maximum 16384
|
||||
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
|
||||
$manifest = $utf8Strict.GetString($manifestBytes) | ConvertFrom-Json
|
||||
$manifestFields = @($manifest.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($manifestFields -ne 'commit,files,schema_version,trees' -or
|
||||
($manifest.schema_version -isnot [int] -and $manifest.schema_version -isnot [long]) -or
|
||||
$manifest.schema_version -ne 1 -or $manifest.commit -isnot [string] -or
|
||||
$manifest.commit -cne $ExpectedCommit -or $manifest.files -isnot [Array] -or
|
||||
@($manifest.files).Count -ne 5 -or $manifest.trees -isnot [Array] -or @($manifest.trees).Count -ne 4) {
|
||||
throw 'Receiver bundle manifest does not match the expected commit and schema.'
|
||||
}
|
||||
$commitBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('objects/commit.bin') -Maximum 16384
|
||||
if ((Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes) -cne $ExpectedCommit) {
|
||||
throw 'Receiver bundle Git commit object does not match ExpectedCommit.'
|
||||
}
|
||||
$commitText = [Text.Encoding]::ASCII.GetString($commitBytes)
|
||||
$headerEnd = $commitText.IndexOf("`n`n", [StringComparison]::Ordinal)
|
||||
if ($headerEnd -lt 1) { throw 'Receiver bundle commit header is malformed.' }
|
||||
$treeHeaders = [regex]::Matches($commitText.Substring(0, $headerEnd), '(?m)^tree ([0-9a-f]{40})$')
|
||||
if ($treeHeaders.Count -ne 1) { throw 'Receiver bundle commit has no unique root tree reference.' }
|
||||
|
||||
$treeEntries = @{}
|
||||
$treeObjectNames = @{
|
||||
'' = 'objects/root-tree.bin'
|
||||
infra = 'objects/infra-tree.bin'
|
||||
'infra/backup' = 'objects/backup-tree.bin'
|
||||
'infra/deploy' = 'objects/deploy-tree.bin'
|
||||
}
|
||||
$seenTrees = @{}
|
||||
foreach ($tree in $manifest.trees) {
|
||||
$treeFields = @($tree.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($treeFields -ne 'path,sha1' -or $tree.path -isnot [string] -or
|
||||
$tree.path -cnotin @('', 'infra', 'infra/backup', 'infra/deploy') -or
|
||||
$seenTrees.ContainsKey($tree.path) -or $tree.sha1 -cnotmatch '^[0-9a-f]{40}$') {
|
||||
throw 'Receiver bundle tree manifest is invalid.'
|
||||
}
|
||||
$treeData = Read-BoundedZipEntry -Entry $zip.GetEntry($treeObjectNames[$tree.path]) -Maximum 262144
|
||||
if ((Get-GitObjectHex -Kind 'tree' -Bytes $treeData) -cne $tree.sha1) {
|
||||
throw "Receiver bundle tree object identity does not match its manifest: $($tree.path)"
|
||||
}
|
||||
$treeEntries[$tree.path] = Get-GitTreeEntries -Bytes $treeData
|
||||
$seenTrees[$tree.path] = $tree.sha1
|
||||
}
|
||||
if ($seenTrees.Count -ne 4 -or $treeHeaders[0].Groups[1].Value -cne $seenTrees['']) {
|
||||
throw 'Receiver bundle commit does not bind the declared root tree.'
|
||||
}
|
||||
$rootInfra = $treeEntries['']['infra']
|
||||
$infraBackup = $treeEntries['infra']['backup']
|
||||
$infraDeploy = $treeEntries['infra']['deploy']
|
||||
if ($rootInfra.mode -notin @('40000', '040000') -or $rootInfra.sha1 -cne $seenTrees['infra'] -or
|
||||
$infraBackup.mode -notin @('40000', '040000') -or $infraBackup.sha1 -cne $seenTrees['infra/backup'] -or
|
||||
$infraDeploy.mode -notin @('40000', '040000') -or $infraDeploy.sha1 -cne $seenTrees['infra/deploy']) {
|
||||
throw 'Receiver bundle directory tree chain does not match the commit.'
|
||||
}
|
||||
$allowedFiles = @('archive.py', 'external_session.py', 'receive_external.py',
|
||||
'windows-release-trigger.example.xml', 'windows_release_launcher.py')
|
||||
$seenFiles = @{}
|
||||
foreach ($file in $manifest.files) {
|
||||
$fileFields = @($file.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($fileFields -ne 'git_blob_sha1,path,sha256,size' -or $file.path -isnot [string] -or
|
||||
$file.path -cnotin $allowedFiles -or $seenFiles.ContainsKey($file.path) -or
|
||||
$file.git_blob_sha1 -isnot [string] -or $file.git_blob_sha1 -cnotmatch '^[0-9a-f]{40}$' -or
|
||||
$file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
|
||||
($file.size -isnot [int] -and $file.size -isnot [long]) -or $file.size -lt 0 -or $file.size -gt 262144) {
|
||||
throw 'Receiver bundle manifest contains an invalid file record.'
|
||||
}
|
||||
$entry = $zip.GetEntry("files/$($file.path)")
|
||||
$data = Read-BoundedZipEntry -Entry $entry -Maximum 262144
|
||||
if ($data.Length -ne [long]$file.size -or
|
||||
(Get-LowerHex -Bytes $data) -cne $file.sha256) {
|
||||
throw "Receiver bundle file digest or size does not match its manifest: $($file.path)"
|
||||
}
|
||||
if ((Get-GitObjectHex -Kind 'blob' -Bytes $data) -cne $file.git_blob_sha1) {
|
||||
throw "Receiver bundle Git blob identity does not match its manifest: $($file.path)"
|
||||
}
|
||||
$treePath = if ($file.path -in @('archive.py', 'external_session.py', 'receive_external.py')) { 'infra/backup' } else { 'infra/deploy' }
|
||||
$treeFile = $treeEntries[$treePath][$file.path]
|
||||
if ($null -eq $treeFile -or $treeFile.mode -ne '100644' -or $treeFile.sha1 -cne $file.git_blob_sha1) {
|
||||
throw "Receiver bundle commit tree does not contain the exact source file: $($file.path)"
|
||||
}
|
||||
$seenFiles[$file.path] = $true
|
||||
$verifiedFiles[$file.path] = $data
|
||||
}
|
||||
if ($seenFiles.Count -ne $allowedFiles.Count) { throw 'Receiver bundle manifest omitted a required file.' }
|
||||
} finally {
|
||||
if ($null -ne $zip) { $zip.Dispose() }
|
||||
$bundleStream.Dispose()
|
||||
}
|
||||
|
||||
$appSecurity = Get-Acl -LiteralPath $appRoot
|
||||
$appSecurity.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))
|
||||
$null = [IO.Directory]::CreateDirectory($app, $appSecurity)
|
||||
Set-TrustedCodeOwner $app
|
||||
Assert-ProtectedPath $app
|
||||
foreach ($name in @('receive_external.py', 'archive.py', 'external_session.py', 'windows_release_launcher.py')) {
|
||||
$destination = Join-Path $app $name
|
||||
[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])
|
||||
Set-TrustedCodeOwner $destination
|
||||
Assert-ProtectedPath $destination
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $trigger)) {
|
||||
$config = '{"schema_version":1,"enabled":false,"repository":"D:\\Dev\\akyldash","ssh_alias":"akyldash_release"}'
|
||||
Set-Content -LiteralPath $trigger -Value $config -Encoding Ascii -NoNewline
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $autoApply)) {
|
||||
Set-Content -LiteralPath $autoApply -Value '{"schema_version":1,"enabled":false}' -Encoding Ascii -NoNewline
|
||||
}
|
||||
Assert-ProtectedPath $trigger -AllowOwnerWrite $true
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
|
||||
$template = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
|
||||
$ns = [System.Xml.XmlNamespaceManager]::new($template.NameTable)
|
||||
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$sid = $identity.User.Value
|
||||
$template.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $ns).InnerText = $sid
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $ns).InnerText = $python
|
||||
$launcher = Join-Path $app 'windows_release_launcher.py'
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $ns).InnerText = "-B -E -s -S `"$launcher`" --once"
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $ns).InnerText = $app
|
||||
$template.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $ns).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$templatePath = Join-Path $programData 'Akyldash-ReleaseTrigger-disabled.xml'
|
||||
$template.Save($templatePath)
|
||||
$applyTemplate = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
|
||||
$applyNs = [System.Xml.XmlNamespaceManager]::new($applyTemplate.NameTable)
|
||||
$applyNs.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI', $applyNs).InnerText = '\Akyldash\ReleaseAutoApply'
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $applyNs).InnerText = $sid
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $applyNs).InnerText = $python
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $applyNs).InnerText = "-B -E -s -S `"$launcher`" --auto-apply-prepared"
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $applyNs).InnerText = $app
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $applyNs).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$applyTemplate.Save((Join-Path $programData 'Akyldash-ReleaseAutoApply-disabled.xml'))
|
||||
Write-Output "Prepared disabled receiver files from verified source commit $ExpectedCommit."
|
||||
Write-Output 'No task was registered or enabled; both configuration gates remain disabled.'
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Build a small immutable Windows receiver bundle from one Git commit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
FILES = {
|
||||
"archive.py": "infra/backup/archive.py",
|
||||
"external_session.py": "infra/backup/external_session.py",
|
||||
"receive_external.py": "infra/backup/receive_external.py",
|
||||
"windows-release-trigger.example.xml": "infra/deploy/windows-release-trigger.example.xml",
|
||||
"windows_release_launcher.py": "infra/deploy/windows_release_launcher.py",
|
||||
}
|
||||
MAX_BUNDLE_BYTES = 512 * 1024
|
||||
MAX_FILE_BYTES = 262144
|
||||
TREE_OBJECTS = {
|
||||
"": "objects/root-tree.bin",
|
||||
"infra": "objects/infra-tree.bin",
|
||||
"infra/backup": "objects/backup-tree.bin",
|
||||
"infra/deploy": "objects/deploy-tree.bin",
|
||||
}
|
||||
|
||||
|
||||
def _git(repository: Path, *arguments: str) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), *arguments],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _blob_sha(data: bytes) -> str:
|
||||
return _git_object_sha("blob", data)
|
||||
|
||||
|
||||
def _git_object_sha(kind: str, data: bytes) -> str:
|
||||
return hashlib.sha1(kind.encode("ascii") + b" " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
|
||||
|
||||
|
||||
def _tree_entry(repository: Path, tree: str, name: str) -> tuple[str, str, str]:
|
||||
records = _git(repository, "ls-tree", "-z", tree, "--", name).split(b"\0")
|
||||
matches = []
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, object_id = header.decode("ascii").split(" ")
|
||||
if raw_path.decode("utf-8") == name:
|
||||
matches.append((mode, kind, object_id))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Git tree is missing or duplicates required path: {name}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _parse_git_tree(data: bytes) -> dict[str, tuple[str, str]]:
|
||||
entries: dict[str, tuple[str, str]] = {}
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
space = data.find(b" ", offset)
|
||||
nul = data.find(b"\0", space + 1) if space >= 0 else -1
|
||||
if space < offset or nul < 0 or nul + 21 > len(data):
|
||||
raise ValueError("Git tree object is malformed")
|
||||
mode = data[offset:space].decode("ascii")
|
||||
name = data[space + 1:nul].decode("utf-8")
|
||||
object_id = data[nul + 1:nul + 21].hex()
|
||||
if not name or name in {".", ".."} or name in entries:
|
||||
raise ValueError("Git tree object has an invalid or duplicate name")
|
||||
entries[name] = (mode, object_id)
|
||||
offset = nul + 21
|
||||
return entries
|
||||
|
||||
|
||||
def _strict_json_object(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("receiver bundle manifest has duplicate keys")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def verify_bundle(payload: bytes, expected_commit: str, expected_sha256: str) -> dict[str, bytes]:
|
||||
"""Verify a generated or transferred bundle before an installer consumes it."""
|
||||
if len(payload) <= 0 or len(payload) > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("receiver bundle has an invalid size")
|
||||
if hashlib.sha256(payload).hexdigest() != expected_sha256:
|
||||
raise ValueError("receiver bundle SHA-256 does not match")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", expected_commit):
|
||||
raise ValueError("expected commit must be a full lowercase Git SHA-1")
|
||||
expected_entries = {
|
||||
"manifest.json", "objects/commit.bin", *TREE_OBJECTS.values(),
|
||||
*(f"files/{name}" for name in FILES),
|
||||
}
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(payload))
|
||||
except (zipfile.BadZipFile, OSError) as error:
|
||||
raise ValueError("receiver bundle is not a valid ZIP") from error
|
||||
with archive:
|
||||
infos = archive.infolist()
|
||||
names = [item.filename for item in infos]
|
||||
if len(names) != len(expected_entries) or len(set(names)) != len(expected_entries) or set(names) != expected_entries:
|
||||
raise ValueError("receiver bundle has duplicate, missing, or unexpected entries")
|
||||
for item in infos:
|
||||
mode = (item.external_attr >> 16) & 0xFFFF
|
||||
if mode != 0o100600 or item.compress_type != zipfile.ZIP_STORED:
|
||||
raise ValueError("receiver bundle entry has an unsafe mode or compression method")
|
||||
maximum = 16384 if item.filename == "manifest.json" else MAX_FILE_BYTES
|
||||
if item.file_size > maximum:
|
||||
raise ValueError("receiver bundle entry exceeds its size limit")
|
||||
contents = {item.filename: archive.read(item) for item in infos}
|
||||
|
||||
try:
|
||||
manifest = json.loads(contents["manifest.json"].decode("ascii"), object_pairs_hook=_strict_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("receiver bundle manifest is invalid") from error
|
||||
if (not isinstance(manifest, dict) or set(manifest) != {"schema_version", "commit", "files", "trees"}
|
||||
or type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1
|
||||
or manifest["commit"] != expected_commit):
|
||||
raise ValueError("receiver bundle manifest does not match the expected commit and schema")
|
||||
|
||||
commit_object = contents["objects/commit.bin"]
|
||||
if _git_object_sha("commit", commit_object) != expected_commit:
|
||||
raise ValueError("receiver bundle commit object does not match expected SHA")
|
||||
commit_header = commit_object.split(b"\n\n", 1)[0]
|
||||
commit_trees = re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)
|
||||
if len(commit_trees) != 1:
|
||||
raise ValueError("receiver bundle commit has no unique root tree")
|
||||
|
||||
if not isinstance(manifest["trees"], list) or len(manifest["trees"]) != len(TREE_OBJECTS):
|
||||
raise ValueError("receiver bundle tree manifest is invalid")
|
||||
tree_hashes: dict[str, str] = {}
|
||||
tree_entries: dict[str, dict[str, tuple[str, str]]] = {}
|
||||
for record in manifest["trees"]:
|
||||
if (not isinstance(record, dict) or set(record) != {"path", "sha1"}
|
||||
or not isinstance(record["path"], str) or record["path"] not in TREE_OBJECTS
|
||||
or record["path"] in tree_hashes
|
||||
or not isinstance(record["sha1"], str) or not re.fullmatch(r"[0-9a-f]{40}", record["sha1"])):
|
||||
raise ValueError("receiver bundle tree record is invalid")
|
||||
path = record["path"]
|
||||
data = contents[TREE_OBJECTS[path]]
|
||||
if _git_object_sha("tree", data) != record["sha1"]:
|
||||
raise ValueError(f"receiver bundle tree hash does not match: {path}")
|
||||
tree_hashes[path] = record["sha1"]
|
||||
tree_entries[path] = _parse_git_tree(data)
|
||||
if set(tree_hashes) != set(TREE_OBJECTS) or tree_hashes[""].encode("ascii") != commit_trees[0]:
|
||||
raise ValueError("receiver bundle commit does not bind the declared root tree")
|
||||
for parent, name, child in (("", "infra", "infra"),
|
||||
("infra", "backup", "infra/backup"),
|
||||
("infra", "deploy", "infra/deploy")):
|
||||
mode, child_sha = tree_entries[parent].get(name, ("", ""))
|
||||
if mode not in {"40000", "040000"} or child_sha != tree_hashes[child]:
|
||||
raise ValueError(f"receiver bundle directory tree chain is invalid at {parent or '/'}/{name}")
|
||||
|
||||
if not isinstance(manifest["files"], list) or len(manifest["files"]) != len(FILES):
|
||||
raise ValueError("receiver bundle file manifest is invalid")
|
||||
verified: dict[str, bytes] = {}
|
||||
for record in manifest["files"]:
|
||||
if (not isinstance(record, dict) or set(record) != {"path", "git_blob_sha1", "sha256", "size"}
|
||||
or not isinstance(record["path"], str) or record["path"] not in FILES or record["path"] in verified
|
||||
or type(record["size"]) is not int or record["size"] < 0 or record["size"] > MAX_FILE_BYTES
|
||||
or not isinstance(record["git_blob_sha1"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", record["git_blob_sha1"])
|
||||
or not isinstance(record["sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", record["sha256"])):
|
||||
raise ValueError("receiver bundle file record is invalid")
|
||||
name = record["path"]
|
||||
data = contents[f"files/{name}"]
|
||||
tree_path = "infra/backup" if FILES[name].startswith("infra/backup/") else "infra/deploy"
|
||||
mode, blob_sha = tree_entries[tree_path].get(name, ("", ""))
|
||||
if (mode != "100644" or blob_sha != record["git_blob_sha1"]
|
||||
or _git_object_sha("blob", data) != blob_sha
|
||||
or hashlib.sha256(data).hexdigest() != record["sha256"]
|
||||
or len(data) != record["size"]):
|
||||
raise ValueError(f"receiver bundle file does not match its commit tree: {name}")
|
||||
verified[name] = data
|
||||
if set(verified) != set(FILES):
|
||||
raise ValueError("receiver bundle omits a required source file")
|
||||
return verified
|
||||
|
||||
|
||||
def read_commit_files(repository: Path, commit: str) -> tuple[
|
||||
dict[str, bytes], list[dict[str, object]], dict[str, bytes], list[dict[str, str]]]:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("expected commit must be a full lowercase Git SHA-1")
|
||||
if repository.is_symlink():
|
||||
raise ValueError("repository must be a real directory")
|
||||
repository = repository.resolve(strict=True)
|
||||
if not repository.is_dir():
|
||||
raise ValueError("repository must be a real directory")
|
||||
if _git(repository, "cat-file", "-t", commit).strip() != b"commit":
|
||||
raise ValueError("expected object is not a commit")
|
||||
main_commit = _git(repository, "rev-parse", "refs/heads/main").strip().decode("ascii")
|
||||
if main_commit != commit:
|
||||
raise ValueError("expected commit must equal the local main branch tip")
|
||||
commit_object = _git(repository, "cat-file", "commit", commit)
|
||||
if _git_object_sha("commit", commit_object) != commit:
|
||||
raise ValueError("commit object bytes do not match the expected SHA")
|
||||
commit_header = commit_object.split(b"\n\n", 1)[0]
|
||||
commit_trees = [value.decode("ascii") for value in
|
||||
re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)]
|
||||
if len(commit_trees) != 1:
|
||||
raise ValueError("commit object has no unique root tree")
|
||||
tree_ids = {"": commit_trees[0]}
|
||||
tree_ids["infra"] = _tree_entry(repository, tree_ids[""], "infra")[2]
|
||||
tree_ids["infra/backup"] = _tree_entry(repository, tree_ids["infra"], "backup")[2]
|
||||
tree_ids["infra/deploy"] = _tree_entry(repository, tree_ids["infra"], "deploy")[2]
|
||||
tree_objects: dict[str, bytes] = {"objects/commit.bin": commit_object}
|
||||
manifest_trees: list[dict[str, str]] = []
|
||||
for tree_path, tree_sha in sorted(tree_ids.items()):
|
||||
data = _git(repository, "cat-file", "tree", tree_sha)
|
||||
if _git_object_sha("tree", data) != tree_sha:
|
||||
raise ValueError(f"Git tree bytes do not match their object ID: {tree_path or '/'}")
|
||||
tree_objects[TREE_OBJECTS[tree_path]] = data
|
||||
manifest_trees.append({"path": tree_path, "sha1": tree_sha})
|
||||
records = _git(repository, "ls-tree", "-z", commit, "--", *FILES.values()).split(b"\0")
|
||||
tree_entries: dict[str, tuple[str, str, str]] = {}
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, object_id = header.decode("ascii").split(" ")
|
||||
tree_entries[raw_path.decode("utf-8")] = (mode, kind, object_id)
|
||||
if set(tree_entries) != set(FILES.values()):
|
||||
raise ValueError("commit does not contain the exact receiver allowlist")
|
||||
|
||||
contents: dict[str, bytes] = {}
|
||||
manifest_files: list[dict[str, object]] = []
|
||||
for name, source_path in sorted(FILES.items()):
|
||||
mode, kind, object_id = tree_entries[source_path]
|
||||
if mode != "100644" or kind != "blob":
|
||||
raise ValueError(f"receiver source is not a regular Git blob: {source_path}")
|
||||
data = _git(repository, "cat-file", "blob", object_id)
|
||||
if _blob_sha(data) != object_id:
|
||||
raise ValueError(f"Git blob bytes do not match the commit object: {source_path}")
|
||||
contents[name] = data
|
||||
manifest_files.append({
|
||||
"path": name,
|
||||
"git_blob_sha1": object_id,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data),
|
||||
})
|
||||
for name, source_path in FILES.items():
|
||||
parent_path = "infra/backup" if source_path.startswith("infra/backup/") else "infra/deploy"
|
||||
_mode, _kind, expected_blob = tree_entries[source_path]
|
||||
mode, kind, tree_blob = _tree_entry(repository, tree_ids[parent_path], name)
|
||||
if mode != "100644" or kind != "blob" or tree_blob != expected_blob:
|
||||
raise ValueError(f"Git tree path does not bind the expected regular source blob: {source_path}")
|
||||
return contents, manifest_files, tree_objects, manifest_trees
|
||||
|
||||
|
||||
def create_bundle(repository: Path, commit: str) -> bytes:
|
||||
contents, manifest_files, tree_objects, manifest_trees = read_commit_files(repository, commit)
|
||||
manifest = json.dumps({
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": manifest_files,
|
||||
"trees": manifest_trees,
|
||||
}, sort_keys=True, separators=(",", ":")).encode("ascii") + b"\n"
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||
members = [("manifest.json", manifest)]
|
||||
members.extend((f"files/{key}", value) for key, value in sorted(contents.items()))
|
||||
members.extend(sorted(tree_objects.items()))
|
||||
for name, data in members:
|
||||
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
entry.compress_type = zipfile.ZIP_STORED
|
||||
entry.external_attr = 0o100600 << 16
|
||||
bundle.writestr(entry, data)
|
||||
result = output.getvalue()
|
||||
if len(result) > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("receiver bundle exceeds its size limit")
|
||||
verify_bundle(result, commit, hashlib.sha256(result).hexdigest())
|
||||
return result
|
||||
|
||||
|
||||
def write_bundle(repository: Path, commit: str, output_path: Path) -> str:
|
||||
if output_path.exists() or output_path.is_symlink():
|
||||
raise ValueError("refusing to overwrite an existing release bundle")
|
||||
if not output_path.parent.is_dir() or output_path.parent.is_symlink():
|
||||
raise ValueError("release bundle output directory must already exist and be a real directory")
|
||||
payload = create_bundle(repository, commit)
|
||||
with output_path.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
digest = write_bundle(args.repository, args.commit, args.output)
|
||||
print(f"Prepared receiver bundle for {args.commit}")
|
||||
print(f"Bundle SHA-256: {digest}")
|
||||
print(f"Bundle path: {args.output.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"repositories": {
|
||||
"backend": ["git.vakanaut.ru/admin/akyldash/backend"],
|
||||
"opensearch": ["git.vakanaut.ru/admin/akyldash/opensearch"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Fail-closed validation for CI-to-release and release-to-deploy workflow events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
|
||||
RUN_ID_RE = re.compile(r"[0-9]+\Z")
|
||||
DESCRIPTOR_RE = re.compile(r"release-descriptor-([0-9]+)-([0-9a-f]{64})\Z")
|
||||
|
||||
|
||||
def latest_main_sha(checkout: Path | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(checkout or Path.cwd()), "ls-remote", "--exit-code", "origin", "refs/heads/main"],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if len(result.stdout) > 256:
|
||||
raise ValueError("current main lookup exceeded its output limit")
|
||||
fields = result.stdout.split()
|
||||
if len(fields) != 2 or fields[1] != "refs/heads/main" or not SHA_RE.fullmatch(fields[0]):
|
||||
raise ValueError("could not determine the current main commit")
|
||||
return fields[0]
|
||||
|
||||
|
||||
def validate_source(
|
||||
*, workflow: str, event: str, conclusion: str, branch: str, run_id: str, head_sha: str,
|
||||
workflow_path: str, repository: str, stage: str, current_main: str,
|
||||
) -> None:
|
||||
expected = {
|
||||
"release": ("CI", "ci.yaml@refs/heads/main", "push"),
|
||||
"handoff": ("Release production images", "release-images.yaml@refs/heads/main", "workflow_run"),
|
||||
}
|
||||
if stage not in expected:
|
||||
raise ValueError("unknown release handoff stage")
|
||||
expected_workflow, expected_path, expected_event = expected[stage]
|
||||
if workflow != expected_workflow or workflow_path != expected_path or event != expected_event:
|
||||
raise ValueError("source workflow or event is not approved for this stage")
|
||||
if repository != "admin/akyldash":
|
||||
raise ValueError("source repository is not admin/akyldash")
|
||||
if conclusion != "success" or branch != "main":
|
||||
raise ValueError("only successful source runs on main may continue")
|
||||
if not RUN_ID_RE.fullmatch(run_id) or not SHA_RE.fullmatch(head_sha):
|
||||
raise ValueError("source run ID or commit SHA is invalid")
|
||||
if current_main != head_sha:
|
||||
raise ValueError("source run is stale because main has advanced")
|
||||
|
||||
|
||||
def descriptor_request(artifact_response: object, *, run_id: str, head_sha: str) -> dict[str, str]:
|
||||
if not isinstance(artifact_response, dict):
|
||||
raise ValueError("artifact metadata must be a JSON object")
|
||||
artifacts = artifact_response.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||
raise ValueError("expected exactly one release descriptor artifact")
|
||||
artifact = artifacts[0]
|
||||
if not isinstance(artifact, dict):
|
||||
raise ValueError("release artifact metadata is invalid")
|
||||
name = artifact.get("name")
|
||||
match = DESCRIPTOR_RE.fullmatch(name) if isinstance(name, str) else None
|
||||
if not match or match.group(1) != run_id:
|
||||
raise ValueError("release artifact name does not bind the source run and descriptor digest")
|
||||
if artifact.get("expired") is True:
|
||||
raise ValueError("release descriptor artifact has expired")
|
||||
source = artifact.get("workflow_run")
|
||||
if not isinstance(source, dict) or (
|
||||
str(source.get("id")) != run_id
|
||||
or source.get("head_sha") != head_sha
|
||||
or source.get("head_branch") != "main"
|
||||
or source.get("event") != "workflow_run"
|
||||
or source.get("conclusion") != "success"
|
||||
):
|
||||
raise ValueError("artifact metadata does not match the successful source release run")
|
||||
return {"run_id": run_id, "commit": head_sha, "descriptor_sha256": match.group(2)}
|
||||
|
||||
|
||||
def _source_from_environment() -> dict[str, str]:
|
||||
return {
|
||||
"workflow": os.environ.get("SOURCE_WORKFLOW", ""),
|
||||
"workflow_path": os.environ.get("SOURCE_WORKFLOW_PATH", ""),
|
||||
"event": os.environ.get("SOURCE_EVENT", ""),
|
||||
"conclusion": os.environ.get("SOURCE_CONCLUSION", ""),
|
||||
"branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||
"run_id": os.environ.get("SOURCE_RUN_ID", ""),
|
||||
"head_sha": os.environ.get("SOURCE_SHA", ""),
|
||||
"repository": os.environ.get("SOURCE_REPOSITORY", ""),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("stage", choices=("validate-ci-source", "validate-handoff-source", "dry-run"))
|
||||
parser.add_argument("--artifact-metadata", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
source = _source_from_environment()
|
||||
stage = "release" if args.stage == "validate-ci-source" else "handoff"
|
||||
validate_source(**source, stage=stage, current_main=latest_main_sha())
|
||||
if args.stage == "validate-ci-source":
|
||||
print(f"PASS successful CI source {source['head_sha']} is current main")
|
||||
return 0
|
||||
if args.artifact_metadata is None:
|
||||
print(
|
||||
"DRY-RUN only: source release is current, but artifact metadata was not read; "
|
||||
"host handoff is unavailable"
|
||||
)
|
||||
return 0
|
||||
response = json.loads(args.artifact_metadata.read_text(encoding="utf-8"))
|
||||
request = descriptor_request(response, run_id=source["run_id"], head_sha=source["head_sha"])
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, subprocess.CalledProcessError, ValueError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
"DRY-RUN only: validated handoff tuple "
|
||||
f"run_id={request['run_id']} commit={request['commit']} "
|
||||
f"descriptor_sha256={request['descriptor_sha256']}; ZIP bytes were not fetched and no host connection was opened"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Read and atomically replace the two application image references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
KEYS = {"BACKEND_IMAGE", "OPENSEARCH_IMAGE"}
|
||||
IMAGE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(?:@sha256:[0-9a-f]{64})?\Z")
|
||||
|
||||
|
||||
def read_images(path: Path) -> tuple[str, dict[str, str]]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("production env must be a regular non-symlink file")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
values: dict[str, str] = {}
|
||||
lines = text.splitlines(keepends=True)
|
||||
for line in lines:
|
||||
if not re.match(r"^\s*(?:export\s+)?(?:BACKEND_IMAGE|OPENSEARCH_IMAGE)\b", line):
|
||||
continue
|
||||
match = re.fullmatch(r"(BACKEND_IMAGE|OPENSEARCH_IMAGE)=([^\r\n]+)(\r?\n)?", line)
|
||||
if not match:
|
||||
raise ValueError("application image assignments must use plain KEY=value syntax")
|
||||
key, value = match.group(1), match.group(2)
|
||||
if key in values or not IMAGE.fullmatch(value):
|
||||
raise ValueError(f"invalid or duplicate {key} in production env")
|
||||
values[key] = value
|
||||
if set(values) != KEYS:
|
||||
raise ValueError("production env must define exactly one backend and OpenSearch image")
|
||||
return text, values
|
||||
|
||||
|
||||
def replace_images(
|
||||
path: Path,
|
||||
expected: dict[str, str],
|
||||
replacement: dict[str, str],
|
||||
*,
|
||||
require_root: bool = True,
|
||||
) -> None:
|
||||
if set(expected) != KEYS or set(replacement) != KEYS:
|
||||
raise ValueError("both complete application image references are required")
|
||||
if any(not isinstance(value, str) or not IMAGE.fullmatch(value) for value in (*expected.values(), *replacement.values())):
|
||||
raise ValueError("invalid application image reference")
|
||||
text, current = read_images(path)
|
||||
info = path.stat()
|
||||
if require_root and (os.geteuid() != 0 or info.st_uid != 0 or info.st_mode & 0o777 != 0o600):
|
||||
raise ValueError("production env must be root-owned mode 0600 before a release switch")
|
||||
if current != expected:
|
||||
raise ValueError("production image references changed; refusing partial or stale release switch")
|
||||
lines = text.splitlines(keepends=True)
|
||||
updated = []
|
||||
for line in lines:
|
||||
if not re.match(r"^\s*(?:export\s+)?(?:BACKEND_IMAGE|OPENSEARCH_IMAGE)\b", line):
|
||||
updated.append(line)
|
||||
continue
|
||||
match = re.fullmatch(r"(BACKEND_IMAGE|OPENSEARCH_IMAGE)=([^\r\n]+)(\r?\n)?", line)
|
||||
if match:
|
||||
newline = match.group(3) or ""
|
||||
updated.append(f"{match.group(1)}={replacement[match.group(1)]}{newline}")
|
||||
else:
|
||||
raise ValueError("application image assignments must use plain KEY=value syntax")
|
||||
target = path.parent
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=target, prefix=f".{path.name}.", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write("".join(updated))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.chmod(temporary, path.stat().st_mode & 0o777)
|
||||
os.replace(temporary, path)
|
||||
directory_fd = os.open(target, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Verify a Gitea release and its exact uploaded descriptor through gitea-api."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import resource
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from release_handoff import descriptor_request, latest_main_sha, validate_source
|
||||
from validate_release import read_json, unique_object, validate_manifest
|
||||
|
||||
REPOSITORY = "admin/akyldash"
|
||||
RELEASE_WORKFLOW = ".gitea/workflows/release-images.yaml"
|
||||
CI_WORKFLOW = ".gitea/workflows/ci.yaml"
|
||||
MAX_DESCRIPTOR_BYTES = 1024 * 1024
|
||||
MAX_API_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_API_DOWNLOAD_BYTES = 10 * 1024 * 1024
|
||||
MAX_CANDIDATE_RELEASE_RUNS = 10
|
||||
API_TIMEOUT_SECONDS = 15
|
||||
RUNTIME_FILES = (
|
||||
"deploy/production/compose.yaml",
|
||||
"deploy/production/Caddyfile",
|
||||
"infra/deploy/release-policy.json",
|
||||
)
|
||||
MAX_RUNTIME_FILE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def run_api_helper(command: list[str], *, env: dict | None, timeout: int,
|
||||
max_stdout_bytes: int, max_file_bytes: int) -> bytes:
|
||||
def limit_files() -> None:
|
||||
_soft, hard = resource.getrlimit(resource.RLIMIT_FSIZE)
|
||||
limit = max_file_bytes + 1 if hard == resource.RLIM_INFINITY else min(max_file_bytes + 1, hard)
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (limit, hard))
|
||||
|
||||
with tempfile.TemporaryFile() as output:
|
||||
process = subprocess.Popen(
|
||||
command, stdout=output, stderr=subprocess.DEVNULL, env=env,
|
||||
start_new_session=True, preexec_fn=limit_files,
|
||||
)
|
||||
try:
|
||||
return_code = process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
raise ValueError("Gitea helper request timed out") from error
|
||||
if return_code:
|
||||
raise ValueError("Gitea helper request failed")
|
||||
output.seek(0, os.SEEK_END)
|
||||
if output.tell() > max_stdout_bytes:
|
||||
raise ValueError("Gitea helper response exceeds its size limit")
|
||||
output.seek(0)
|
||||
return output.read(max_stdout_bytes + 1)
|
||||
|
||||
|
||||
def api_json(path: str, *, helper: str = "gitea-api", env: dict | None = None) -> dict:
|
||||
try:
|
||||
response = run_api_helper(
|
||||
[helper, "GET", path], env=env, timeout=API_TIMEOUT_SECONDS,
|
||||
max_stdout_bytes=MAX_API_RESPONSE_BYTES, max_file_bytes=MAX_API_RESPONSE_BYTES,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise ValueError("Gitea API request failed") from error
|
||||
if len(response) > MAX_API_RESPONSE_BYTES:
|
||||
raise ValueError("Gitea helper response exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(response.decode("utf-8"), object_pairs_hook=unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("Gitea API response is invalid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Gitea API returned a non-object response")
|
||||
return value
|
||||
|
||||
|
||||
def repository_file(path: str, commit: str, *, api=api_json) -> bytes:
|
||||
"""Fetch one small, pinned repository file and verify Gitea's git blob identity."""
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit) or path not in RUNTIME_FILES:
|
||||
raise ValueError("runtime file request is not allowlisted or commit-pinned")
|
||||
query = urlencode({"ref": commit})
|
||||
value = api(f"/repos/{REPOSITORY}/contents/{path}?{query}")
|
||||
if (value.get("type") != "file" or value.get("path") != path
|
||||
or value.get("encoding") != "base64"):
|
||||
raise ValueError("Gitea runtime file metadata is invalid")
|
||||
encoded, blob_sha, size = value.get("content"), value.get("sha"), value.get("size")
|
||||
if (not isinstance(encoded, str) or not isinstance(blob_sha, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", blob_sha)
|
||||
or isinstance(size, bool) or not isinstance(size, int)
|
||||
or size < 0 or size > MAX_RUNTIME_FILE_BYTES):
|
||||
raise ValueError("Gitea runtime file exceeds its limit or has invalid metadata")
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, base64.binascii.Error) as error:
|
||||
raise ValueError("Gitea runtime file content is invalid base64") from error
|
||||
git_blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode("ascii") + b"\0" + content).hexdigest()
|
||||
if len(content) != size or git_blob_sha != blob_sha:
|
||||
raise ValueError("Gitea runtime file content does not match its pinned git blob")
|
||||
return content
|
||||
|
||||
|
||||
def runtime_compatibility_reason(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> str | None:
|
||||
"""Require the bootstrap host's fixed runtime/configuration to match target source."""
|
||||
for relative in RUNTIME_FILES:
|
||||
local_path = policy_file if relative == "infra/deploy/release-policy.json" else checkout / relative
|
||||
try:
|
||||
info = local_path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError(f"protected runtime file is not a regular file: {relative}")
|
||||
local_content = local_path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError(f"protected runtime file is unavailable: {relative}") from error
|
||||
remote_content = repository_file(relative, commit, api=api)
|
||||
if local_content != remote_content:
|
||||
return "release_policy_changed" if relative.endswith("release-policy.json") else "runtime_config_changed"
|
||||
return None
|
||||
|
||||
|
||||
def require_runtime_compatibility(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> None:
|
||||
reason = runtime_compatibility_reason(
|
||||
commit, checkout=checkout, policy_file=policy_file, api=api,
|
||||
)
|
||||
if reason is not None:
|
||||
raise ValueError(f"host {reason.replace('_', ' ')}; reviewed bootstrap update is required")
|
||||
|
||||
|
||||
def latest_release_request(*, current_main: str, api=api_json,
|
||||
checkout: Path | None = None) -> dict | None:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", current_main):
|
||||
raise ValueError("current main commit is invalid")
|
||||
query = urlencode({
|
||||
"event": "workflow_run", "branch": "main", "status": "success",
|
||||
"head_sha": current_main, "page": 1, "limit": MAX_CANDIDATE_RELEASE_RUNS,
|
||||
})
|
||||
runs_response = api(
|
||||
f"/repos/{REPOSITORY}/actions/workflows/{Path(RELEASE_WORKFLOW).name}/runs?{query}"
|
||||
)
|
||||
runs = runs_response.get("workflow_runs")
|
||||
total = runs_response.get("total_count")
|
||||
if type(total) is not int or total < 0 or not isinstance(runs, list):
|
||||
raise ValueError("Gitea release-run listing is invalid")
|
||||
if total > MAX_CANDIDATE_RELEASE_RUNS or len(runs) != total:
|
||||
raise ValueError("Gitea release-run listing is incomplete")
|
||||
if not runs:
|
||||
return None
|
||||
run_ids = []
|
||||
for run in runs:
|
||||
if not isinstance(run, dict):
|
||||
raise ValueError("Gitea release-run listing contains an invalid entry")
|
||||
run_id = run.get("id")
|
||||
if (isinstance(run_id, bool) or not isinstance(run_id, (int, str))
|
||||
or not str(run_id).isdigit() or len(str(run_id)) > 20):
|
||||
raise ValueError("Gitea release-run listing contains an invalid run ID")
|
||||
head_sha = run.get("head_sha")
|
||||
if head_sha is not None and head_sha != current_main:
|
||||
raise ValueError("Gitea release-run listing does not match current main")
|
||||
run_ids.append(str(run_id))
|
||||
run_id = max(run_ids, key=int)
|
||||
verify_run(run_id, stage="handoff", commit=current_main, api=api,
|
||||
current_main=current_main, checkout=checkout)
|
||||
metadata = api(f"/repos/{REPOSITORY}/actions/runs/{run_id}/artifacts")
|
||||
return descriptor_request(metadata, run_id=run_id, head_sha=current_main)
|
||||
|
||||
|
||||
def ensure_private_scratch(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.is_symlink() or path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
|
||||
raise ValueError("artifact scratch directory must be a private root-owned directory")
|
||||
|
||||
|
||||
def verify_run(run_id: str, *, stage: str, commit: str, api=api_json, current_main: str | None = None,
|
||||
checkout: Path | None = None) -> dict:
|
||||
if not run_id.isdigit() or not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("Gitea run identity is invalid")
|
||||
run = api(f"/repos/{REPOSITORY}/actions/runs/{run_id}")
|
||||
if not isinstance(run, dict) or str(run.get("id")) != run_id:
|
||||
raise ValueError("Gitea returned a different workflow run")
|
||||
if run.get("status") not in {"completed", "success"} or run.get("conclusion") != "success":
|
||||
raise ValueError("workflow run is not successful")
|
||||
workflow_id = run.get("workflow_id")
|
||||
if not isinstance(workflow_id, (int, str)) or not str(workflow_id):
|
||||
raise ValueError("workflow run has no workflow identity")
|
||||
workflow = api(f"/repos/{REPOSITORY}/actions/workflows/{workflow_id}")
|
||||
expected_workflow, path, event = (
|
||||
("CI", CI_WORKFLOW, "push") if stage == "ci" else
|
||||
("Release production images", RELEASE_WORKFLOW, "workflow_run")
|
||||
)
|
||||
if (workflow.get("name") != expected_workflow or workflow.get("path") != path
|
||||
or str(workflow.get("id")) != str(workflow_id)):
|
||||
raise ValueError("workflow ID does not resolve to the approved workflow name and path")
|
||||
if run.get("name") not in {None, expected_workflow}:
|
||||
raise ValueError("workflow run name is not approved")
|
||||
if run.get("head_sha") != commit:
|
||||
raise ValueError("workflow run commit does not match the release request")
|
||||
branch = run.get("head_branch")
|
||||
if branch != "main" or run.get("event") != event:
|
||||
raise ValueError("workflow run is not the approved main event")
|
||||
validate_source(
|
||||
workflow=expected_workflow, workflow_path=f"{Path(path).name}@refs/heads/main", event=event,
|
||||
conclusion="success", branch=branch, run_id=run_id, head_sha=commit,
|
||||
repository=REPOSITORY, stage="release" if stage == "ci" else "handoff",
|
||||
current_main=current_main or latest_main_sha(checkout),
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def validate_artifact_zip(path: Path, *, descriptor_sha256: str, release_run_id: str,
|
||||
ci_run_id: str | None, commit: str, policy: object) -> tuple[dict, bytes]:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
|
||||
raise ValueError("requested descriptor digest is invalid")
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
members = archive.infolist()
|
||||
names = [member.filename for member in members]
|
||||
expected = {"release-manifest.json", "release-manifest.json.sha256"}
|
||||
if len(names) != 2 or set(names) != expected:
|
||||
raise ValueError("release artifact must contain exactly the manifest and checksum files")
|
||||
payloads = {}
|
||||
for member in members:
|
||||
name = PurePosixPath(member.filename)
|
||||
mode = member.external_attr >> 16
|
||||
kind = stat.S_IFMT(mode)
|
||||
if (member.is_dir() or name.is_absolute() or len(name.parts) != 1
|
||||
or name.as_posix() != member.filename
|
||||
or kind not in {0, stat.S_IFREG}
|
||||
or member.file_size > MAX_DESCRIPTOR_BYTES
|
||||
or member.compress_size > MAX_DESCRIPTOR_BYTES):
|
||||
raise ValueError("release artifact contains an unsafe or oversized member")
|
||||
with archive.open(member) as stream:
|
||||
chunks = []
|
||||
size = 0
|
||||
while chunk := stream.read(64 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_DESCRIPTOR_BYTES:
|
||||
raise ValueError("release artifact member exceeds its size limit")
|
||||
chunks.append(chunk)
|
||||
if size != member.file_size:
|
||||
raise ValueError("release artifact member size does not match its ZIP metadata")
|
||||
payloads[member.filename] = b"".join(chunks)
|
||||
except (OSError, zipfile.BadZipFile, RuntimeError) as error:
|
||||
raise ValueError(f"release artifact ZIP is invalid: {error}") from error
|
||||
|
||||
manifest_bytes = payloads["release-manifest.json"]
|
||||
if hashlib.sha256(manifest_bytes).hexdigest() != descriptor_sha256:
|
||||
raise ValueError("release descriptor bytes do not match the requested SHA-256")
|
||||
checksum = payloads["release-manifest.json.sha256"]
|
||||
expected_sidecar = f"{descriptor_sha256} release-manifest.json\n".encode("ascii")
|
||||
if checksum != expected_sidecar:
|
||||
raise ValueError("release descriptor checksum sidecar is invalid")
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes, object_pairs_hook=unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("release descriptor JSON is invalid") from error
|
||||
validated = validate_manifest(manifest, policy)
|
||||
if (validated["run_id"] != release_run_id
|
||||
or (ci_run_id is not None and validated["source_run_id"] != ci_run_id)
|
||||
or validated["commit"] != commit):
|
||||
raise ValueError("release descriptor does not bind the verified CI and release runs")
|
||||
return validated, manifest_bytes
|
||||
|
||||
|
||||
def fetch_release(request: dict, *, policy: object, helper: str = "gitea-api",
|
||||
temp_dir: Path = Path("/run/akyldash"), env: dict | None = None,
|
||||
api=api_json, current_main: str | None = None,
|
||||
checkout: Path | None = None) -> tuple[dict, bytes]:
|
||||
run_id, commit, descriptor_sha256 = (
|
||||
request["run_id"], request["commit"], request["descriptor_sha256"]
|
||||
)
|
||||
current_main = current_main or latest_main_sha(checkout)
|
||||
release_run = verify_run(run_id, stage="handoff", commit=commit, api=api,
|
||||
current_main=current_main, checkout=checkout)
|
||||
metadata = api(f"/repos/{REPOSITORY}/actions/runs/{run_id}/artifacts")
|
||||
tuple_data = descriptor_request(metadata, run_id=run_id, head_sha=commit)
|
||||
if tuple_data["descriptor_sha256"] != descriptor_sha256:
|
||||
raise ValueError("requested descriptor digest does not match the Gitea run artifact")
|
||||
artifact = metadata["artifacts"][0]
|
||||
artifact_id = artifact.get("id")
|
||||
if not isinstance(artifact_id, (int, str)) or not str(artifact_id).isdigit():
|
||||
raise ValueError("release descriptor artifact has no numeric ID")
|
||||
ensure_private_scratch(temp_dir)
|
||||
from tempfile import TemporaryDirectory
|
||||
with TemporaryDirectory(prefix="akyldash-release-", dir=temp_dir) as scratch:
|
||||
zip_path = Path(scratch) / "artifact.zip"
|
||||
run_api_helper(
|
||||
[helper, "DOWNLOAD", f"/repos/{REPOSITORY}/actions/artifacts/{artifact_id}/zip", str(zip_path)],
|
||||
env=env, timeout=30, max_stdout_bytes=4096, max_file_bytes=MAX_API_DOWNLOAD_BYTES,
|
||||
)
|
||||
manifest, manifest_bytes = validate_artifact_zip(
|
||||
zip_path, descriptor_sha256=tuple_data["descriptor_sha256"], release_run_id=run_id,
|
||||
ci_run_id=None, commit=commit, policy=policy,
|
||||
)
|
||||
source_run_id = manifest["source_run_id"]
|
||||
verify_run(source_run_id, stage="ci", commit=commit, api=api, current_main=current_main,
|
||||
checkout=checkout)
|
||||
return manifest, manifest_bytes
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Atomic, secret-free release state and recovery journal helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SERVICES = {"backend", "opensearch"}
|
||||
|
||||
|
||||
def unique_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate release state field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def validate_images(value: object, label: str) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict) or set(value) != SERVICES:
|
||||
raise ValueError(f"{label} must contain backend and opensearch")
|
||||
result = {}
|
||||
for service in sorted(SERVICES):
|
||||
record = value[service]
|
||||
if not isinstance(record, dict) or set(record) != {"configured", "image_id"}:
|
||||
raise ValueError(f"{label} has an invalid {service} record")
|
||||
configured, image_id = record["configured"], record["image_id"]
|
||||
if (
|
||||
not isinstance(configured, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(?:@sha256:[0-9a-f]{64})?", configured)
|
||||
or not isinstance(image_id, str)
|
||||
or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id)
|
||||
):
|
||||
raise ValueError(f"{label} has invalid {service} values")
|
||||
result[service] = {"configured": configured, "image_id": image_id}
|
||||
return result
|
||||
|
||||
|
||||
def write_json_atomic(path: Path, value: dict, mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
json.dump(value, stream, sort_keys=True, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError(f"release state is not a regular file: {path}")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"release state is unreadable: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("release state must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def validate_state(state: object) -> dict:
|
||||
old_fields = {"schema_version", "commit", "manifest_sha256", "current", "previous"}
|
||||
new_fields = old_fields | {"previous_backup"}
|
||||
if (not isinstance(state, dict) or type(state.get("schema_version")) is not int
|
||||
or state.get("schema_version") not in {1, 2}
|
||||
or set(state) != (old_fields if state.get("schema_version") == 1 else new_fields)):
|
||||
raise ValueError("release state has unsupported schema")
|
||||
if not isinstance(state["commit"], str) or not re.fullmatch(r"[0-9a-f]{40}|rollback", state["commit"]):
|
||||
raise ValueError("release state commit is invalid")
|
||||
if not isinstance(state["manifest_sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}|rollback", state["manifest_sha256"]):
|
||||
raise ValueError("release state provenance is invalid")
|
||||
state["current"] = validate_images(state["current"], "current images")
|
||||
state["previous"] = validate_images(state["previous"], "previous images")
|
||||
if state["schema_version"] == 2:
|
||||
state["previous_backup"] = validate_backup_identity(state["previous_backup"])
|
||||
return state
|
||||
|
||||
|
||||
def validate_backup_identity(value: object) -> dict | None:
|
||||
if value is None:
|
||||
return None
|
||||
required = {"backup_id", "size", "archive_sha256", "manifest_sha256", "manifest"}
|
||||
if not isinstance(value, dict) or set(value) != required:
|
||||
raise ValueError("external backup identity has an unsupported shape")
|
||||
if (not isinstance(value["backup_id"], str) or not re.fullmatch(r"akyldash-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}", value["backup_id"])
|
||||
or type(value["size"]) is not int or not 0 < value["size"] <= 100 * 1024**3
|
||||
or not isinstance(value["archive_sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", value["archive_sha256"])
|
||||
or not isinstance(value["manifest_sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", value["manifest_sha256"])
|
||||
or not isinstance(value["manifest"], dict)):
|
||||
raise ValueError("external backup identity has invalid values")
|
||||
canonical = json.dumps(value["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != value["manifest_sha256"]:
|
||||
raise ValueError("external backup manifest identity is inconsistent")
|
||||
return value
|
||||
+125
-80
@@ -3,9 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
@@ -16,8 +14,9 @@ from pathlib import Path
|
||||
ROOT = Path("/opt/akyldash/current")
|
||||
COMPOSE_FILE = ROOT / "deploy/production/compose.yaml"
|
||||
ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
BACKUP_DIR = Path("/var/backups/akyldash")
|
||||
BACKUP_TOOL = Path("/usr/local/lib/akyldash/archive.py")
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
JOURNAL_FILE = Path("/opt/akyldash/release-recovery.json")
|
||||
DEPLOYED_IMAGE_FILE = Path("/opt/akyldash/deployed-image.txt")
|
||||
APP_SERVICES = {"backend", "opensearch"}
|
||||
IMAGE_TAG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*")
|
||||
IMAGE_DIGEST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}")
|
||||
@@ -27,7 +26,11 @@ def is_digest_reference(reference: str) -> bool:
|
||||
return IMAGE_DIGEST_RE.fullmatch(reference) is not None
|
||||
|
||||
|
||||
def image_restore_plan(manifest: dict, configured: dict[str, str]) -> dict[str, tuple[str, str]]:
|
||||
def image_restore_plan(
|
||||
manifest: dict,
|
||||
configured: dict[str, str],
|
||||
expected_previous: dict[str, dict[str, str]] | None = None,
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
images = manifest.get("images")
|
||||
if not isinstance(images, dict) or not APP_SERVICES <= images.keys():
|
||||
raise ValueError("backup has no previous backend and OpenSearch images")
|
||||
@@ -38,7 +41,7 @@ def image_restore_plan(manifest: dict, configured: dict[str, str]) -> dict[str,
|
||||
raise ValueError(f"invalid image record for {service}")
|
||||
reference = image.get("configured")
|
||||
image_id = image.get("image_id")
|
||||
if reference != configured.get(service):
|
||||
if expected_previous is None and reference != configured.get(service):
|
||||
raise ValueError(f"Compose image reference changed for {service}; refusing rollback")
|
||||
if not isinstance(reference, str) or not (
|
||||
IMAGE_TAG_RE.fullmatch(reference) or is_digest_reference(reference)
|
||||
@@ -46,6 +49,10 @@ def image_restore_plan(manifest: dict, configured: dict[str, str]) -> dict[str,
|
||||
raise ValueError(f"invalid image reference for {service}")
|
||||
if not isinstance(image_id, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"invalid previous image ID for {service}")
|
||||
if expected_previous is not None and expected_previous.get(service) != {
|
||||
"configured": reference, "image_id": image_id
|
||||
}:
|
||||
raise ValueError(f"backup does not match the pinned previous {service} release")
|
||||
plan[service] = (image_id, reference)
|
||||
return plan
|
||||
|
||||
@@ -77,9 +84,9 @@ def require_tag_rollback(plan: dict[str, tuple[str, str]]) -> None:
|
||||
|
||||
|
||||
def tag_previous_images(plan: dict[str, tuple[str, str]]) -> None:
|
||||
require_tag_rollback(plan)
|
||||
for image_id, reference in plan.values():
|
||||
run(["docker", "image", "tag", image_id, reference])
|
||||
if not is_digest_reference(reference):
|
||||
run(["docker", "image", "tag", image_id, reference])
|
||||
|
||||
|
||||
def run(command: list[str], *, capture: bool = False) -> str:
|
||||
@@ -101,98 +108,136 @@ def record_backend_image() -> None:
|
||||
image_id = run(["docker", "inspect", "--format", "{{.Image}}", container], capture=True)
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError("backend returned an invalid image ID")
|
||||
target = Path("/opt/akyldash/deployed-image.txt")
|
||||
target = DEPLOYED_IMAGE_FILE
|
||||
with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".deployed-image-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(f"{image_id}\n".encode("ascii"))
|
||||
os.chmod(temporary, 0o600)
|
||||
try:
|
||||
os.replace(temporary, target)
|
||||
directory_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def running_image_ids() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for service in sorted(APP_SERVICES):
|
||||
container = compose("ps", "--all", "--quiet", service, capture=True)
|
||||
if not container or "\n" in container:
|
||||
raise ValueError(f"expected exactly one running {service} container")
|
||||
inspection = run(
|
||||
["docker", "inspect", "--format", "{{.State.Running}}\t{{.Image}}", container], capture=True
|
||||
)
|
||||
running, separator, image_id = inspection.partition("\t")
|
||||
if not separator or running != "true" or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"{service} container is not running with a valid image ID")
|
||||
result[service] = image_id
|
||||
return result
|
||||
|
||||
|
||||
def require_running_image_ids(expected: dict[str, str], actual: dict[str, str], label: str) -> None:
|
||||
if actual != expected:
|
||||
raise ValueError(f"running image IDs do not match the {label}")
|
||||
|
||||
|
||||
def rollback(backup: Path, data_compatible: bool) -> None:
|
||||
raise ValueError("local VPS backup paths are disabled; use the owner-controlled external rollback session")
|
||||
|
||||
|
||||
def rollback_external(
|
||||
*, state: dict, target_identity: dict, current: dict, fresh_backup: dict,
|
||||
data_compatible: bool,
|
||||
) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("external rollback must run in the root-controlled receiver")
|
||||
if not data_compatible:
|
||||
raise ValueError("confirm data compatibility with --data-compatible before rollback")
|
||||
if not backup.is_file() or backup.is_symlink() or backup.resolve().parent != BACKUP_DIR.resolve():
|
||||
raise ValueError(f"backup must be a regular file directly under {BACKUP_DIR}")
|
||||
if backup.stat().st_uid != 0 or backup.stat().st_mode & 0o777 != 0o600:
|
||||
raise ValueError("rollback backup must be root-owned mode 0600")
|
||||
checksum = Path(f"{backup}.sha256")
|
||||
if checksum.is_symlink() or not checksum.is_file() or checksum.stat().st_uid != 0 or checksum.stat().st_mode & 0o777 != 0o600:
|
||||
raise ValueError("rollback checksum sidecar must be root-owned mode 0600")
|
||||
raise ValueError("confirm data compatibility before external rollback")
|
||||
from deploy_release import image_ids
|
||||
from release_refs import read_images, replace_images
|
||||
from release_state import read_json, validate_backup_identity, validate_state, write_json_atomic
|
||||
|
||||
sys.path.insert(0, str(BACKUP_TOOL.parent))
|
||||
from archive import digest, verify_archive
|
||||
|
||||
manifest = verify_archive(backup)
|
||||
deployment_files = {
|
||||
relative: digest(ROOT / relative)
|
||||
pinned = validate_state(state)
|
||||
if pinned.get("schema_version") != 2 or pinned.get("previous_backup") is None:
|
||||
raise ValueError("release state has no verified external previous backup")
|
||||
target_identity = validate_backup_identity(target_identity)
|
||||
fresh_backup = validate_backup_identity(fresh_backup)
|
||||
if target_identity != pinned["previous_backup"]:
|
||||
raise ValueError("rollback archive is not the root-pinned previous external backup")
|
||||
if image_ids(allow_stopped=True) != current:
|
||||
raise ValueError("stopped production images differ from the pre-rollback snapshot")
|
||||
current_files = {
|
||||
relative: hashlib.sha256((ROOT / relative).read_bytes()).hexdigest()
|
||||
for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
|
||||
}
|
||||
if deployment_files != manifest.get("deployment_files"):
|
||||
raise ValueError("Compose or Caddyfile changed since this backup; restore the matching Git revision first")
|
||||
rendered = json.loads(compose("config", "--format", "json", capture=True))
|
||||
configured = {service: rendered["services"][service]["image"] for service in APP_SERVICES}
|
||||
plan = image_restore_plan(manifest, configured)
|
||||
if target_identity["manifest"].get("deployment_files") != current_files:
|
||||
raise ValueError("previous backup deployment files differ from the current checkout")
|
||||
if app_images(target_identity["manifest"], "previous external backup") != pinned["previous"]:
|
||||
raise ValueError("previous backup image identities do not match root-pinned release state")
|
||||
if app_images(fresh_backup["manifest"], "pre-rollback external backup") != current:
|
||||
raise ValueError("fresh external backup does not match the current deployment")
|
||||
|
||||
env_text, configured_values = read_images(ENV_FILE)
|
||||
del env_text
|
||||
configured = {service: configured_values[f"{service.upper()}_IMAGE"] for service in APP_SERVICES}
|
||||
plan = image_restore_plan(target_identity["manifest"], configured, pinned["previous"])
|
||||
verify_previous_images(plan)
|
||||
require_tag_rollback(plan)
|
||||
before = {"BACKEND_IMAGE": configured["backend"], "OPENSEARCH_IMAGE": configured["opensearch"]}
|
||||
after = {"BACKEND_IMAGE": plan["backend"][1], "OPENSEARCH_IMAGE": plan["opensearch"][1]}
|
||||
journal = read_json(JOURNAL_FILE)
|
||||
if journal.get("status") != "external-backup-verified" or journal.get("backup") != fresh_backup:
|
||||
raise ValueError("fresh external backup journal is missing or does not match this rollback")
|
||||
|
||||
backup_name = f"akyldash-pre-rollback-{dt.datetime.now(dt.timezone.utc):%Y%m%dT%H%M%SZ}.tar.gz"
|
||||
backup_output = BACKUP_DIR / backup_name
|
||||
compose("stop")
|
||||
try:
|
||||
run(
|
||||
[
|
||||
"python3",
|
||||
str(BACKUP_TOOL),
|
||||
"create",
|
||||
"--compose-file",
|
||||
str(COMPOSE_FILE),
|
||||
"--env-file",
|
||||
str(ENV_FILE),
|
||||
"--output",
|
||||
str(backup_output),
|
||||
]
|
||||
)
|
||||
run(["python3", str(BACKUP_TOOL), "verify", str(backup_output)])
|
||||
except Exception:
|
||||
compose("up", "--detach")
|
||||
raise
|
||||
|
||||
tag_previous_images(plan)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
run(
|
||||
[
|
||||
"curl",
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--resolve",
|
||||
"akyldash.kg:443:127.0.0.1",
|
||||
"https://akyldash.kg/review",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
]
|
||||
)
|
||||
record_backend_image()
|
||||
print(f"PASS application images restored from {backup}; pre-rollback backup: {backup_output}")
|
||||
tag_previous_images(plan)
|
||||
replace_images(ENV_FILE, before, after)
|
||||
journal["status"] = "rollback-references-switched"
|
||||
journal["rollback_from"] = target_identity
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
expected = {service: plan[service][0] for service in APP_SERVICES}
|
||||
require_running_image_ids(expected, running_image_ids(), "verified previous external backup")
|
||||
run(["curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
"https://akyldash.kg/review", "--output", "/dev/null"])
|
||||
record_backend_image()
|
||||
restored = {
|
||||
service: {"configured": plan[service][1], "image_id": plan[service][0]}
|
||||
for service in APP_SERVICES
|
||||
}
|
||||
write_json_atomic(STATE_FILE, {
|
||||
"schema_version": 2, "commit": "rollback", "manifest_sha256": "rollback",
|
||||
"current": restored, "previous": current, "previous_backup": fresh_backup,
|
||||
})
|
||||
JOURNAL_FILE.unlink()
|
||||
except Exception as error:
|
||||
journal["status"] = "rollback-failed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
try:
|
||||
_text, current_env = read_images(ENV_FILE)
|
||||
active = {"BACKEND_IMAGE": current_env["BACKEND_IMAGE"], "OPENSEARCH_IMAGE": current_env["OPENSEARCH_IMAGE"]}
|
||||
if active == after:
|
||||
replace_images(ENV_FILE, after, before)
|
||||
elif active != before:
|
||||
raise ValueError("image references match neither the current nor rollback release")
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
require_running_image_ids({service: current[service]["image_id"] for service in APP_SERVICES},
|
||||
running_image_ids(), "pre-rollback deployment")
|
||||
record_backend_image()
|
||||
write_json_atomic(STATE_FILE, pinned)
|
||||
journal["recovery"] = "pre-rollback references and image IDs restored"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = f"manual recovery required ({type(recovery_error).__name__})"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"external rollback failed; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("backup", type=Path)
|
||||
parser.add_argument("--data-compatible", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("rollback must run through the restricted root helper")
|
||||
rollback(args.backup, args.data_compatible)
|
||||
except Exception as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
print("ERROR: local VPS backup paths are disabled; use the owner-controlled external rollback session", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import deploy_release
|
||||
import deployment_lock
|
||||
import release_refs
|
||||
|
||||
|
||||
class DeploymentCutoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.current = {
|
||||
"backend": {"configured": "akyldash/backend:local", "image_id": "sha256:" + "a" * 64},
|
||||
"opensearch": {"configured": "akyldash/opensearch:local", "image_id": "sha256:" + "b" * 64},
|
||||
}
|
||||
|
||||
def test_mismatched_backup_is_rejected_before_any_cutover(self):
|
||||
changed = {**self.current, "backend": {**self.current["backend"], "image_id": "sha256:" + "c" * 64}}
|
||||
with self.assertRaisesRegex(ValueError, "do not match"):
|
||||
deploy_release.require_backup_matches_current(changed, self.current)
|
||||
|
||||
def test_search_canary_requires_nonempty_result_with_document_code(self):
|
||||
deploy_release.validate_search_response({"results": [{"code": "DOC-1"}]}, "ru")
|
||||
for payload in ({"results": []}, {"results": [{}]}, {"error": "unavailable"}):
|
||||
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, "canary"):
|
||||
deploy_release.validate_search_response(payload, "ky")
|
||||
|
||||
def test_shared_lock_rejects_concurrent_deployment(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
lock_path = Path(directory) / "deploy.lock"
|
||||
with patch.object(deployment_lock, "LOCK_FILE", lock_path):
|
||||
descriptor = lock_path.open("w")
|
||||
os.chmod(lock_path, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError, "shared lock"):
|
||||
with deploy_release.deployment_lock():
|
||||
pass
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
descriptor.close()
|
||||
|
||||
def test_fsync_error_after_atomic_replace_restores_previous_references(self):
|
||||
old = {"BACKEND_IMAGE": "akyldash/backend:old", "OPENSEARCH_IMAGE": "akyldash/opensearch:old"}
|
||||
new = {"BACKEND_IMAGE": "registry/backend@sha256:" + "c" * 64,
|
||||
"OPENSEARCH_IMAGE": "registry/opensearch@sha256:" + "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / "production.env"
|
||||
env_file.write_text("BACKEND_IMAGE=akyldash/backend:old\nOPENSEARCH_IMAGE=akyldash/opensearch:old\n")
|
||||
original_fsync = release_refs.os.fsync
|
||||
calls = 0
|
||||
|
||||
def fail_first_directory_fsync(descriptor):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError("simulated directory fsync failure after replace")
|
||||
original_fsync(descriptor)
|
||||
|
||||
with patch.object(release_refs.os, "fsync", side_effect=fail_first_directory_fsync):
|
||||
with self.assertRaisesRegex(OSError, "after replace"):
|
||||
release_refs.replace_images(env_file, old, new, require_root=False)
|
||||
self.assertEqual(release_refs.read_images(env_file)[1], new)
|
||||
with (
|
||||
patch.object(deploy_release, "ENV_FILE", env_file),
|
||||
patch.object(deploy_release, "replace_images", side_effect=lambda path, expected, replacement:
|
||||
release_refs.replace_images(path, expected, replacement, require_root=False)),
|
||||
):
|
||||
deploy_release.restore_previous_references(old, new)
|
||||
self.assertEqual(release_refs.read_images(env_file)[1], old)
|
||||
|
||||
def test_external_cutover_rejects_unvalidated_release_before_docker_operations(self):
|
||||
with patch.object(deploy_release.os, "geteuid", return_value=0), patch.object(deploy_release, "run") as run_mock, patch.object(deploy_release, "compose") as compose_mock:
|
||||
with self.assertRaisesRegex(ValueError, "manifest must contain exactly"):
|
||||
deploy_release.deploy_external(
|
||||
{}, {"schema_version": 1, "repositories": {
|
||||
"backend": ["registry/backend"], "opensearch": ["registry/search"]
|
||||
}}, {}, {}, "0" * 64, self.current, "ру", "кыргызча"
|
||||
)
|
||||
run_mock.assert_not_called()
|
||||
compose_mock.assert_not_called()
|
||||
|
||||
def test_main_advance_after_backup_aborts_before_image_reference_switch(self):
|
||||
commit = "b" * 40
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "deploy/production").mkdir(parents=True)
|
||||
for name, payload in (("compose.yaml", b"compose\n"), ("Caddyfile", b"caddy\n")):
|
||||
(root / "deploy/production" / name).write_bytes(payload)
|
||||
from hashlib import sha256
|
||||
deployment_files = {
|
||||
f"deploy/production/{name}": sha256((root / "deploy/production" / name).read_bytes()).hexdigest()
|
||||
for name in ("compose.yaml", "Caddyfile")
|
||||
}
|
||||
backup = {"images": self.current, "deployment_files": deployment_files}
|
||||
backup_identity = {
|
||||
"backup_id": "20261103T000000Z-" + "a" * 32,
|
||||
"size": 100, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": "d" * 64, "manifest": backup,
|
||||
}
|
||||
manifest = {
|
||||
"commit": commit,
|
||||
"builder": {"name": "Kaniko", "image": deploy_release.BUILDER_IMAGE},
|
||||
"images": {service: f"registry/{service}@sha256:{'e' * 64}" for service in ("backend", "opensearch")},
|
||||
}
|
||||
journal = {"status": "external-backup-verified"}
|
||||
with (
|
||||
patch.object(deploy_release.os, "geteuid", return_value=0),
|
||||
patch.object(deploy_release, "ROOT", root),
|
||||
patch.object(deploy_release, "STATE_FILE", root / "state.json"),
|
||||
patch.object(deploy_release, "JOURNAL_FILE", root / "journal.json"),
|
||||
patch.object(deploy_release, "validate_manifest", return_value=manifest),
|
||||
patch("release_state.validate_backup_identity", return_value=backup_identity),
|
||||
patch("release_handoff.latest_main_sha", side_effect=(commit, "f" * 40)),
|
||||
patch("release_source.require_runtime_compatibility"),
|
||||
patch.object(deploy_release, "image_ids", return_value=self.current),
|
||||
patch.object(deploy_release, "app_images", return_value=self.current),
|
||||
patch.object(deploy_release, "read_json", return_value=journal),
|
||||
patch.object(deploy_release, "write_json_atomic"),
|
||||
patch.object(deploy_release, "run", side_effect=(
|
||||
"", "sha256:" + "1" * 64, "", "sha256:" + "2" * 64,
|
||||
"sha256:" + "1" * 64, "sha256:" + "2" * 64,
|
||||
)),
|
||||
patch.object(deploy_release, "replace_images") as replace,
|
||||
patch.object(deploy_release, "restore_previous_references"),
|
||||
patch.object(deploy_release, "compose"),
|
||||
patch.object(deploy_release, "write_deployed_backend_image"),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "cutover failed"):
|
||||
deploy_release.deploy_external(
|
||||
manifest, {}, backup, backup_identity, "9" * 64,
|
||||
self.current, "ru", "ky",
|
||||
)
|
||||
replace.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backup"))
|
||||
|
||||
import external_deploy
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
DESCRIPTOR = "d" * 64
|
||||
CURRENT = {
|
||||
"backend": {"configured": "registry/backend:current", "image_id": "sha256:" + "1" * 64},
|
||||
"opensearch": {"configured": "registry/search:current", "image_id": "sha256:" + "2" * 64},
|
||||
}
|
||||
|
||||
|
||||
def release_state(commit: str, descriptor: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1, "commit": commit, "manifest_sha256": descriptor,
|
||||
"current": CURRENT, "previous": CURRENT,
|
||||
}
|
||||
|
||||
|
||||
class ExternalDeployTests(unittest.TestCase):
|
||||
def run_execute(self, root: Path, state: dict, *, compose_mock=None, compatibility_error=None):
|
||||
compose_mock = compose_mock or Mock()
|
||||
state_file = root / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
journal = root / "journal.json"
|
||||
request = {"version": 1, "action": "release", "run_id": "401",
|
||||
"commit": COMMIT, "descriptor_sha256": DESCRIPTOR}
|
||||
with (
|
||||
patch.object(external_deploy.os, "geteuid", return_value=0),
|
||||
patch.object(external_deploy, "validate_request"),
|
||||
patch.object(external_deploy, "POLICY_FILE", root / "policy.json"),
|
||||
patch.object(external_deploy, "CANARIES_FILE", root / "canaries.json"),
|
||||
patch.object(external_deploy, "JOURNAL_FILE", journal),
|
||||
patch.object(external_deploy, "STATE_FILE", state_file),
|
||||
patch.object(external_deploy, "fetch_release", return_value=({"commit": COMMIT}, b"")),
|
||||
patch.object(external_deploy, "require_runtime_compatibility", side_effect=compatibility_error),
|
||||
patch.object(external_deploy, "deployment_lock", return_value=nullcontext()),
|
||||
patch.object(external_deploy, "image_ids", return_value=CURRENT),
|
||||
patch.object(external_deploy, "deploy_release_revision", return_value=COMMIT),
|
||||
patch.object(external_deploy, "read_json", return_value=state),
|
||||
patch.object(external_deploy, "compose", compose_mock),
|
||||
):
|
||||
result = external_deploy.execute(
|
||||
request, reader=None, writer=None, policy={}, current_main=COMMIT, canaries=("ru", "ky")
|
||||
)
|
||||
return result, compose_mock, journal
|
||||
|
||||
def test_same_verified_release_is_rejected_before_stopping_services(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "already applied"):
|
||||
self.run_execute(root, release_state(COMMIT, DESCRIPTOR), compose_mock=compose)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_pending_journal_is_durable_before_service_stop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock(side_effect=KeyboardInterrupt)
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.run_execute(root, release_state("b" * 40, "e" * 64), compose_mock=compose)
|
||||
journal = json.loads((root / "journal.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(journal["status"], "external-backup-pending")
|
||||
self.assertEqual(journal["commit"], COMMIT)
|
||||
|
||||
def test_runtime_mismatch_fails_before_journal_or_production_operations(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "runtime config mismatch"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=ValueError("runtime config mismatch"),
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_runtime_change_after_initial_check_fails_inside_lock_before_stop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "changed after status"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=[None, ValueError("runtime changed after status")],
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backup"))
|
||||
|
||||
import external_rollback
|
||||
from external_session import read_challenge, write_request
|
||||
from release_state import read_json, validate_state
|
||||
|
||||
|
||||
class ExternalRollbackTests(unittest.TestCase):
|
||||
def test_pending_journal_is_durable_before_rollback_service_stop(self):
|
||||
manifest = {"git_revision": "a" * 40}
|
||||
manifest_sha = hashlib.sha256(
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
identity = {
|
||||
"backup_id": "akyldash-20261031T120000Z-12345678", "size": 64,
|
||||
"archive_sha256": "b" * 64, "manifest_sha256": manifest_sha, "manifest": manifest,
|
||||
}
|
||||
current = {
|
||||
"backend": {"configured": "registry/backend:current", "image_id": "sha256:" + "1" * 64},
|
||||
"opensearch": {"configured": "registry/search:current", "image_id": "sha256:" + "2" * 64},
|
||||
}
|
||||
state = {
|
||||
"schema_version": 2, "commit": "c" * 40, "manifest_sha256": "d" * 64,
|
||||
"current": current, "previous": current, "previous_backup": identity,
|
||||
}
|
||||
request = io.BytesIO()
|
||||
write_request(request, {"version": 1, "action": "rollback", "data_compatible": True})
|
||||
request.seek(0)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
journal = Path(directory) / "journal.json"
|
||||
with (
|
||||
patch.object(external_rollback.os, "geteuid", return_value=0),
|
||||
patch.object(external_rollback, "STATE_FILE", Path(directory) / "state.json"),
|
||||
patch.object(external_rollback, "JOURNAL_FILE", journal),
|
||||
patch.object(external_rollback, "read_json", return_value=state),
|
||||
patch.object(external_rollback, "deployment_lock", return_value=nullcontext()),
|
||||
patch.object(external_rollback, "nonce", return_value="e" * 64),
|
||||
patch.object(external_rollback, "receive_archive", return_value=(64, "b" * 64)),
|
||||
patch.object(external_rollback, "read_ack"),
|
||||
patch.object(external_rollback, "image_ids", return_value=current),
|
||||
patch.object(external_rollback, "compose", side_effect=KeyboardInterrupt),
|
||||
):
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
external_rollback.execute(reader=request, writer=io.BytesIO())
|
||||
self.assertEqual(read_json(journal)["status"], "external-backup-pending")
|
||||
|
||||
def test_release_state_rejects_boolean_schema_version(self):
|
||||
with self.assertRaisesRegex(ValueError, "unsupported schema"):
|
||||
validate_state({
|
||||
"schema_version": True, "commit": "a" * 40,
|
||||
"manifest_sha256": "b" * 64, "current": {}, "previous": {},
|
||||
})
|
||||
|
||||
def test_release_state_rejects_duplicate_identity_fields(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "state.json"
|
||||
path.write_text('{"schema_version":1,"schema_version":2}', encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "duplicate release state field"):
|
||||
read_json(path)
|
||||
|
||||
def test_unpinned_archive_stream_fails_before_service_operations(self):
|
||||
manifest = {"git_revision": "a" * 40}
|
||||
identity = {
|
||||
"backup_id": "akyldash-20261031T120000Z-12345678", "size": 64,
|
||||
"archive_sha256": "b" * 64,
|
||||
"manifest_sha256": hashlib.sha256(
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest(),
|
||||
"manifest": manifest,
|
||||
}
|
||||
state = {
|
||||
"schema_version": 2, "commit": "c" * 40, "manifest_sha256": "d" * 64,
|
||||
"current": {
|
||||
"backend": {"configured": "registry/backend:current", "image_id": "sha256:" + "1" * 64},
|
||||
"opensearch": {"configured": "registry/search:current", "image_id": "sha256:" + "2" * 64},
|
||||
},
|
||||
"previous": {
|
||||
"backend": {"configured": "registry/backend:old", "image_id": "sha256:" + "3" * 64},
|
||||
"opensearch": {"configured": "registry/search:old", "image_id": "sha256:" + "4" * 64},
|
||||
},
|
||||
"previous_backup": identity,
|
||||
}
|
||||
request = io.BytesIO()
|
||||
write_request(request, {"version": 1, "action": "rollback", "data_compatible": True})
|
||||
request.seek(0)
|
||||
response = io.BytesIO()
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(external_rollback.os, "geteuid", return_value=0), \
|
||||
patch.object(external_rollback, "STATE_FILE", Path(directory) / "state.json"), \
|
||||
patch.object(external_rollback, "JOURNAL_FILE", Path(directory) / "journal.json"), \
|
||||
patch.object(external_rollback, "read_json", return_value=state), \
|
||||
patch.object(external_rollback, "deployment_lock", return_value=nullcontext()), \
|
||||
patch.object(external_rollback, "nonce", return_value="e" * 64), \
|
||||
patch.object(external_rollback, "receive_archive", return_value=(64, "f" * 64)), \
|
||||
patch.object(external_rollback, "image_ids") as image_ids, \
|
||||
patch.object(external_rollback, "compose") as compose:
|
||||
with self.assertRaisesRegex(ValueError, "root-pinned"):
|
||||
external_rollback.execute(reader=request, writer=response)
|
||||
response.seek(0)
|
||||
challenge = read_challenge(response)
|
||||
self.assertEqual(challenge["backup_id"], identity["backup_id"])
|
||||
image_ids.assert_not_called()
|
||||
compose.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import external_status
|
||||
|
||||
|
||||
class ExternalStatusTests(unittest.TestCase):
|
||||
def test_status_is_prepared_only_after_release_descriptor_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
journal = root / "recovery.json"
|
||||
policy = root / "policy.json"
|
||||
state = root / "state.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "latest_main_sha", return_value=commit), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None) as compatible, \
|
||||
patch.object(external_status, "latest_release_request", return_value=request) as select, \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
value = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(value["state"], "prepared")
|
||||
self.assertEqual(value["release"], request)
|
||||
compatible.assert_called_once()
|
||||
self.assertEqual(compatible.call_args.args, (commit,))
|
||||
self.assertEqual(compatible.call_args.kwargs["checkout"], root)
|
||||
self.assertEqual(compatible.call_args.kwargs["policy_file"], policy)
|
||||
select.assert_called_once_with(current_main=commit, checkout=root, api=ANY)
|
||||
verify.assert_called_once()
|
||||
|
||||
def test_status_suppresses_an_exactly_applied_release(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
journal, policy, state = root / "journal", root / "policy.json", root / "state.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
images = {name: {"configured": f"registry.test/{name}@sha256:{'c' * 64}",
|
||||
"image_id": "sha256:" + "d" * 64}
|
||||
for name in ("backend", "opensearch")}
|
||||
state.write_text(json.dumps({
|
||||
"schema_version": 1, "commit": commit, "manifest_sha256": request["descriptor_sha256"],
|
||||
"current": images, "previous": images,
|
||||
}), encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release") as verify:
|
||||
result = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(result["reason"], "already_applied")
|
||||
verify.assert_not_called()
|
||||
|
||||
def test_status_does_not_publish_prepared_tuple_if_main_advances_during_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
policy = root / "policy.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
mains = iter((commit, "c" * 40))
|
||||
result = external_status.status_document(
|
||||
checkout=root, journal=root / "journal", policy_file=policy,
|
||||
state_file=root / "state.json", main_lookup=lambda _path: next(mains),
|
||||
)
|
||||
self.assertEqual(result, {
|
||||
"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None,
|
||||
})
|
||||
verify.assert_called_once()
|
||||
|
||||
def test_status_fails_closed_for_root_guard_recovery_and_runtime_mismatch(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
journal = root / "recovery.json"
|
||||
policy = root / "policy.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=1000):
|
||||
with self.assertRaisesRegex(ValueError, "must run as root"):
|
||||
external_status.status_document(checkout=root, journal=journal, policy_file=policy)
|
||||
with patch.object(external_status.os, "geteuid", return_value=0):
|
||||
journal.touch()
|
||||
value = external_status.status_document(checkout=root, journal=journal, policy_file=policy)
|
||||
self.assertEqual(value["reason"], "recovery_journal")
|
||||
journal.unlink()
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value="runtime_config_changed"), \
|
||||
patch.object(external_status, "latest_release_request") as select:
|
||||
value = external_status.status_document(
|
||||
checkout=root, journal=journal, policy_file=policy,
|
||||
main_lookup=lambda _path: "a" * 40,
|
||||
)
|
||||
self.assertEqual(value["reason"], "runtime_config_changed")
|
||||
select.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURE = ROOT / "infra/deploy/gitea-api-base.fixture"
|
||||
PATCH = ROOT / "infra/deploy/gitea-api-binary-download.patch"
|
||||
FIXTURE_SHA256 = "8227332d28e27bcce9b4e3801f6b8d4323636174a82c93a0177970f560629df0"
|
||||
|
||||
|
||||
FAKE_CURL = r'''#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
args = sys.argv[1:]
|
||||
config = sys.stdin.read() if "--config" in args else ""
|
||||
def value(flag, default=None):
|
||||
return args[args.index(flag) + 1] if flag in args else default
|
||||
|
||||
url = args[-1]
|
||||
mode = os.environ["FAKE_CURL_MODE"]
|
||||
log = Path(os.environ["FAKE_CURL_LOG"])
|
||||
log.write_text(log.read_text() + url + "\n" if log.exists() else url + "\n")
|
||||
headers = value("-D")
|
||||
output = value("-o")
|
||||
has_token = "Authorization: token fixture-secret" in config or any(arg == "Authorization: token fixture-secret" for arg in args)
|
||||
if any("fixture-secret" in arg for arg in args):
|
||||
Path(os.environ["FAKE_TOKEN_ARGS"]).write_text("leaked")
|
||||
if "gitea.test" in url and has_token:
|
||||
Path(os.environ["FAKE_GITEA_AUTH"]).write_text("True")
|
||||
if "blob.test" in url or not value("-w"):
|
||||
if "blob.test" in url:
|
||||
Path(os.environ["FAKE_BLOB_AUTH"]).write_text(str(has_token))
|
||||
body = b"zip-fixture-bytes"
|
||||
if mode == "oversize":
|
||||
body = b"x" * (10 * 1024 * 1024 + 10)
|
||||
try:
|
||||
if output and output != "-":
|
||||
Path(output).write_bytes(body)
|
||||
else:
|
||||
sys.stdout.buffer.write(body)
|
||||
sys.stdout.buffer.flush()
|
||||
except BrokenPipeError:
|
||||
raise SystemExit(23)
|
||||
if value("-w"):
|
||||
print("200", end="")
|
||||
raise SystemExit(0)
|
||||
|
||||
if value("-w"):
|
||||
code = "302" if mode in {"redirect", "unlisted", "oversize"} else "200"
|
||||
if headers:
|
||||
location = "https://blob.test/archive.zip" if mode != "unlisted" else "https://evil.test/archive.zip"
|
||||
Path(headers).write_text(f"HTTP/1.1 {code} Fixture\r\nLocation: {location}\r\n\r\n")
|
||||
print(code, end="")
|
||||
raise SystemExit(0)
|
||||
|
||||
raise SystemExit(99)
|
||||
'''
|
||||
|
||||
|
||||
class GiteaApiBinaryDownloadTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.helper = self.root / "gitea-api"
|
||||
shutil.copyfile(FIXTURE, self.helper)
|
||||
self.helper.chmod(0o700)
|
||||
applied = subprocess.run(
|
||||
["patch", "-p1", "-i", str(PATCH)], cwd=self.root,
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if applied.returncode:
|
||||
self.fail(applied.stderr or applied.stdout)
|
||||
self.curl = self.bin / "curl"
|
||||
self.curl.write_text(FAKE_CURL, encoding="utf-8")
|
||||
self.curl.chmod(0o700)
|
||||
self.log = self.root / "calls"
|
||||
self.auth = self.root / "blob-auth"
|
||||
self.gitea_auth = self.root / "gitea-auth"
|
||||
self.token_args = self.root / "token-args"
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def run_download(self, mode, output, origins="https://blob.test"):
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": f"{self.bin}:{os.environ['PATH']}",
|
||||
"GITEA_URL": "https://gitea.test",
|
||||
"GITEA_TOKEN": "fixture-secret",
|
||||
"GITEA_DOWNLOAD_ORIGINS": origins,
|
||||
"FAKE_CURL_MODE": mode,
|
||||
"FAKE_CURL_LOG": str(self.log),
|
||||
"FAKE_BLOB_AUTH": str(self.auth),
|
||||
"FAKE_GITEA_AUTH": str(self.gitea_auth),
|
||||
"FAKE_TOKEN_ARGS": str(self.token_args),
|
||||
}
|
||||
return subprocess.run(
|
||||
[str(self.helper), "DOWNLOAD", "/repos/admin/akyldash/actions/artifacts/7/zip", str(output)],
|
||||
env=env, text=True, capture_output=True,
|
||||
)
|
||||
|
||||
def test_fixture_is_exact_audited_helper_and_patch_applies(self):
|
||||
self.assertEqual(hashlib.sha256(FIXTURE.read_bytes()).hexdigest(), FIXTURE_SHA256)
|
||||
|
||||
def test_downloads_allowlisted_https_redirect_without_forwarding_token(self):
|
||||
output = self.root / "artifact.zip"
|
||||
result = self.run_download("redirect", output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(output.read_bytes(), b"zip-fixture-bytes")
|
||||
self.assertEqual(output.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(self.auth.read_text(), "False")
|
||||
self.assertTrue(self.gitea_auth.exists())
|
||||
self.assertFalse(self.token_args.exists())
|
||||
|
||||
def test_downloads_direct_https_response_from_gitea(self):
|
||||
output = self.root / "artifact.zip"
|
||||
result = self.run_download("direct", output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(output.read_bytes(), b"zip-fixture-bytes")
|
||||
self.assertTrue(self.gitea_auth.exists())
|
||||
self.assertFalse(self.token_args.exists())
|
||||
|
||||
def test_refuses_unlisted_redirect_before_fetching_target(self):
|
||||
output = self.root / "artifact.zip"
|
||||
result = self.run_download("unlisted", output)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("not allowlisted", result.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertNotIn("evil.test", self.log.read_text())
|
||||
|
||||
def test_caps_chunked_response_and_does_not_publish_partial_file(self):
|
||||
output = self.root / "artifact.zip"
|
||||
result = self.run_download("oversize", output)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("10 MiB", result.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_refuses_to_overwrite_existing_output(self):
|
||||
output = self.root / "artifact.zip"
|
||||
output.write_bytes(b"keep")
|
||||
result = self.run_download("redirect", output)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(output.read_bytes(), b"keep")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,25 +6,32 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from create_release_manifest import create
|
||||
from validate_release import validate_manifest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REPOSITORIES = {
|
||||
"backend": ["registry.invalid/akyldash/backend"],
|
||||
"opensearch": ["registry.invalid/akyldash/opensearch"],
|
||||
"backend": ["git.vakanaut.ru/admin/akyldash/backend"],
|
||||
"opensearch": ["git.vakanaut.ru/admin/akyldash/opensearch"],
|
||||
}
|
||||
BUILDER = {"name": "Kaniko", "image": "ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412"}
|
||||
|
||||
|
||||
class ReleaseManifestTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.policy = {"schema_version": 1, "repositories": REPOSITORIES}
|
||||
self.manifest = {
|
||||
"schema_version": 1,
|
||||
"schema_version": 4,
|
||||
"source_ref": "refs/heads/main",
|
||||
"repository": "admin/akyldash",
|
||||
"commit": "a" * 40,
|
||||
"workflow": ".gitea/workflows/release-images.yaml",
|
||||
"run_id": "49",
|
||||
"source_run_id": "48",
|
||||
"builder": BUILDER,
|
||||
"images": {
|
||||
"backend": "registry.invalid/akyldash/backend@sha256:" + "b" * 64,
|
||||
"opensearch": "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64,
|
||||
"backend": "git.vakanaut.ru/admin/akyldash/backend@sha256:" + "b" * 64,
|
||||
"opensearch": "git.vakanaut.ru/admin/akyldash/opensearch@sha256:" + "c" * 64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -36,8 +43,13 @@ class ReleaseManifestTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "source_ref"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_old_manifest_schema_with_unavailable_run_attempt(self):
|
||||
self.manifest["schema_version"] = 2
|
||||
with self.assertRaisesRegex(ValueError, "schema_version"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_tag_instead_of_digest(self):
|
||||
self.manifest["images"]["backend"] = "registry.invalid/akyldash/backend:latest"
|
||||
self.manifest["images"]["backend"] = "git.vakanaut.ru/admin/akyldash/backend:latest"
|
||||
with self.assertRaisesRegex(ValueError, "sha256"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
@@ -51,8 +63,13 @@ class ReleaseManifestTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "commit"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_manifest_from_another_repository(self):
|
||||
self.manifest["repository"] = "admin/other"
|
||||
with self.assertRaisesRegex(ValueError, "repository must be admin/akyldash"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_repository_outside_policy(self):
|
||||
self.manifest["images"]["backend"] = "registry.invalid/other/backend@sha256:" + "b" * 64
|
||||
self.manifest["images"]["backend"] = "git.vakanaut.ru/other/backend@sha256:" + "b" * 64
|
||||
with self.assertRaisesRegex(ValueError, "not allowed"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
@@ -70,8 +87,8 @@ class ReleaseManifestTests(unittest.TestCase):
|
||||
manifest_path = Path(directory) / "manifest.json"
|
||||
policy_path = Path(directory) / "policy.json"
|
||||
manifest_path.write_text(
|
||||
'{"schema_version":1,"source_ref":"refs/heads/main","commit":"' + "a" * 40
|
||||
+ '","images":{"backend":"first","backend":"second","opensearch":"third"}}',
|
||||
'{"schema_version":4,"source_ref":"refs/heads/main","repository":"admin/akyldash","commit":"' + "a" * 40
|
||||
+ '","workflow":".gitea/workflows/release-images.yaml","run_id":"49","source_run_id":"48","builder":{},"images":{"backend":"first","backend":"second","opensearch":"third"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
policy_path.write_text(json.dumps(self.policy), encoding="utf-8")
|
||||
@@ -99,8 +116,8 @@ class ReleaseManifestTests(unittest.TestCase):
|
||||
|
||||
@unittest.skipUnless(shutil.which("docker"), "Docker CLI is unavailable")
|
||||
def test_compose_override_renders_digest_images_and_preserves_build(self):
|
||||
backend = "registry.invalid/akyldash/backend@sha256:" + "b" * 64
|
||||
opensearch = "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64
|
||||
backend = "git.vakanaut.ru/admin/akyldash/backend@sha256:" + "b" * 64
|
||||
opensearch = "git.vakanaut.ru/admin/akyldash/opensearch@sha256:" + "c" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
temp = Path(directory)
|
||||
secret = temp / "review_secret"
|
||||
@@ -126,6 +143,27 @@ class ReleaseManifestTests(unittest.TestCase):
|
||||
self.assertIn("build", services["backend"])
|
||||
self.assertIn("build", services["opensearch"])
|
||||
|
||||
def test_creates_commit_and_builder_traceability_from_kaniko_digests(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, opensearch = Path(directory) / "backend", Path(directory) / "opensearch"
|
||||
backend.write_text("sha256:" + "b" * 64, encoding="ascii")
|
||||
opensearch.write_text("sha256:" + "c" * 64, encoding="ascii")
|
||||
result = create("admin/akyldash", "a" * 40, "48", "49", backend, opensearch)
|
||||
self.assertEqual(validate_manifest(result, self.policy), result)
|
||||
self.assertEqual(result["workflow"], ".gitea/workflows/release-images.yaml")
|
||||
self.assertEqual(result["builder"], BUILDER)
|
||||
self.assertEqual(result["run_id"], "49")
|
||||
self.assertEqual(result["source_run_id"], "48")
|
||||
self.assertNotIn("run_attempt", result)
|
||||
|
||||
def test_manifest_creation_rejects_missing_digest(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
backend, opensearch = Path(directory) / "backend", Path(directory) / "opensearch"
|
||||
backend.write_text("", encoding="ascii")
|
||||
opensearch.write_text("sha256:" + "c" * 64, encoding="ascii")
|
||||
with self.assertRaisesRegex(ValueError, "invalid Kaniko digest"):
|
||||
create("admin/akyldash", "a" * 40, "48", "49", backend, opensearch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from release_refs import read_images, replace_images
|
||||
|
||||
|
||||
class ReleaseReferenceTests(unittest.TestCase):
|
||||
def test_replaces_both_refs_as_one_expected_state_and_preserves_other_config(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "production.env"
|
||||
path.write_text(
|
||||
"DATA_ROOT=/srv/data\nBACKEND_IMAGE=akyldash/backend:local\n"
|
||||
"OPENSEARCH_IMAGE=akyldash/opensearch:local\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
old = {
|
||||
"BACKEND_IMAGE": "akyldash/backend:local",
|
||||
"OPENSEARCH_IMAGE": "akyldash/opensearch:local",
|
||||
}
|
||||
new = {
|
||||
"BACKEND_IMAGE": "git.vakanaut.ru/admin/akyldash/backend@sha256:" + "a" * 64,
|
||||
"OPENSEARCH_IMAGE": "git.vakanaut.ru/admin/akyldash/opensearch@sha256:" + "b" * 64,
|
||||
}
|
||||
replace_images(path, old, new, require_root=False)
|
||||
text, actual = read_images(path)
|
||||
self.assertEqual(actual, new)
|
||||
self.assertTrue(text.startswith("DATA_ROOT=/srv/data\n"))
|
||||
|
||||
def test_stale_or_partial_reference_set_is_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "production.env"
|
||||
original = "BACKEND_IMAGE=old/backend:tag\nOPENSEARCH_IMAGE=old/search:tag\n"
|
||||
path.write_text(original, encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "changed"):
|
||||
replace_images(path, {"BACKEND_IMAGE": "stale", "OPENSEARCH_IMAGE": "old/search:tag"}, {
|
||||
"BACKEND_IMAGE": "new/backend:tag", "OPENSEARCH_IMAGE": "new/search:tag"
|
||||
}, require_root=False)
|
||||
self.assertEqual(path.read_text(encoding="utf-8"), original)
|
||||
|
||||
def test_duplicate_and_missing_variables_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "production.env"
|
||||
path.write_text("BACKEND_IMAGE=a/b:tag\nBACKEND_IMAGE=a/b:tag\nOPENSEARCH_IMAGE=c/d:tag\n")
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
read_images(path)
|
||||
path.write_text("BACKEND_IMAGE=a/b:tag\n")
|
||||
with self.assertRaisesRegex(ValueError, "exactly one"):
|
||||
read_images(path)
|
||||
|
||||
def test_indented_duplicate_assignment_is_not_ignored(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "production.env"
|
||||
path.write_text(
|
||||
"BACKEND_IMAGE=a/b:tag\n OPENSEARCH_IMAGE=attacker/search:tag\n"
|
||||
"OPENSEARCH_IMAGE=c/d:tag\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "plain KEY=value"):
|
||||
read_images(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,355 @@
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import release_source
|
||||
|
||||
POLICY = {
|
||||
"schema_version": 1,
|
||||
"repositories": {
|
||||
"backend": ["registry.test/akyldash/backend"],
|
||||
"opensearch": ["registry.test/akyldash/opensearch"],
|
||||
},
|
||||
}
|
||||
COMMIT = "a" * 40
|
||||
RELEASE_RUN = "302"
|
||||
CI_RUN = "301"
|
||||
MANIFEST = {
|
||||
"schema_version": 4,
|
||||
"source_ref": "refs/heads/main",
|
||||
"repository": "admin/akyldash",
|
||||
"commit": COMMIT,
|
||||
"workflow": ".gitea/workflows/release-images.yaml",
|
||||
"run_id": RELEASE_RUN,
|
||||
"source_run_id": CI_RUN,
|
||||
"builder": {"name": "Kaniko", "image": "ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412"},
|
||||
"images": {
|
||||
"backend": "registry.test/akyldash/backend@sha256:" + "b" * 64,
|
||||
"opensearch": "registry.test/akyldash/opensearch@sha256:" + "c" * 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_artifact(path, manifest=MANIFEST, extra=()):
|
||||
raw = json.dumps(manifest, sort_keys=True).encode()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("release-manifest.json", raw)
|
||||
archive.writestr("release-manifest.json.sha256", f"{digest} release-manifest.json\n")
|
||||
for name, payload in extra:
|
||||
archive.writestr(name, payload)
|
||||
return digest, raw
|
||||
|
||||
|
||||
class ReleaseSourceTests(unittest.TestCase):
|
||||
def test_runtime_files_allow_bootstrap_a_to_release_b_only_when_configs_match(self):
|
||||
commit_b = "f" * 40
|
||||
contents = {
|
||||
"deploy/production/compose.yaml": b"compose-v1\n",
|
||||
"deploy/production/Caddyfile": b"caddy-v1\n",
|
||||
"infra/deploy/release-policy.json": b"policy-v1\n",
|
||||
}
|
||||
|
||||
def response(path):
|
||||
payload = contents[path]
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(payload)).encode() + b"\0" + payload).hexdigest()
|
||||
return {"type": "file", "path": path, "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": blob_sha}
|
||||
|
||||
def api(request):
|
||||
self.assertIn(f"?ref={commit_b}", request)
|
||||
path = request.split("/contents/", 1)[1].split("?", 1)[0]
|
||||
return response(path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
(checkout / "deploy/production").mkdir(parents=True)
|
||||
(checkout / "deploy/production/compose.yaml").write_bytes(contents["deploy/production/compose.yaml"])
|
||||
(checkout / "deploy/production/Caddyfile").write_bytes(contents["deploy/production/Caddyfile"])
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(contents["infra/deploy/release-policy.json"])
|
||||
self.assertIsNone(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
))
|
||||
|
||||
for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile"):
|
||||
local_path = checkout / relative
|
||||
expected = contents[relative]
|
||||
local_path.write_bytes(b"changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "runtime_config_changed")
|
||||
local_path.write_bytes(expected)
|
||||
policy.write_bytes(b"policy-changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "release_policy_changed")
|
||||
|
||||
def test_runtime_file_rejects_forged_git_blob_identity(self):
|
||||
payload = b"trusted content\n"
|
||||
response = {"type": "file", "path": "deploy/production/compose.yaml", "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": "a" * 40}
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
release_source.repository_file(
|
||||
"deploy/production/compose.yaml", COMMIT,
|
||||
api=lambda _path: response,
|
||||
)
|
||||
|
||||
def test_runtime_compatibility_rejects_local_symlink_even_when_bytes_match(self):
|
||||
content = b"protected\n"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
config_dir = checkout / "deploy/production"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "compose.yaml").write_bytes(content)
|
||||
(config_dir / "Caddyfile").write_bytes(content)
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(content)
|
||||
external = checkout / "outside.yaml"
|
||||
external.write_bytes(content)
|
||||
(config_dir / "Caddyfile").unlink()
|
||||
(config_dir / "Caddyfile").symlink_to(external)
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError, "not a regular file"):
|
||||
release_source.runtime_compatibility_reason(
|
||||
COMMIT, checkout=checkout, policy_file=policy,
|
||||
api=lambda _path: {
|
||||
"type": "file", "path": "deploy/production/compose.yaml",
|
||||
"encoding": "base64", "content": base64.b64encode(content).decode(),
|
||||
"size": len(content), "sha": blob_sha,
|
||||
},
|
||||
)
|
||||
|
||||
def test_verify_run_binds_run_id_workflow_path_event_branch_commit_and_main(self):
|
||||
def api(path):
|
||||
if path.endswith("/actions/runs/301"):
|
||||
return {"id": 301, "workflow_id": "ci.yaml", "name": "CI", "status": "completed",
|
||||
"conclusion": "success", "head_sha": COMMIT, "head_branch": "main", "event": "push"}
|
||||
return {"id": "ci.yaml", "name": "CI", "path": ".gitea/workflows/ci.yaml"}
|
||||
run = release_source.verify_run(CI_RUN, stage="ci", commit=COMMIT, api=api, current_main=COMMIT)
|
||||
self.assertEqual(run["head_sha"], COMMIT)
|
||||
with self.assertRaisesRegex(ValueError, "workflow run is not successful"):
|
||||
release_source.verify_run(CI_RUN, stage="ci", commit=COMMIT,
|
||||
api=lambda path: {"id": 301, "status": "failure"}, current_main=COMMIT)
|
||||
|
||||
def test_completed_run_requires_success_conclusion(self):
|
||||
responses = {
|
||||
f"/repos/admin/akyldash/actions/runs/{CI_RUN}": {
|
||||
"id": int(CI_RUN), "workflow_id": "ci.yaml", "status": "completed",
|
||||
"conclusion": "cancelled", "head_sha": COMMIT,
|
||||
"head_branch": "main", "event": "push",
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "workflow run is not successful"):
|
||||
release_source.verify_run(CI_RUN, stage="ci", commit=COMMIT,
|
||||
api=lambda path: responses[path], current_main=COMMIT)
|
||||
|
||||
def test_zip_descriptor_checks_exact_members_digest_source_ids_and_allowlist(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "artifact.zip"
|
||||
digest, _ = write_artifact(path)
|
||||
manifest, raw = release_source.validate_artifact_zip(
|
||||
path, descriptor_sha256=digest, release_run_id=RELEASE_RUN,
|
||||
ci_run_id=CI_RUN, commit=COMMIT, policy=POLICY,
|
||||
)
|
||||
self.assertEqual(manifest, MANIFEST)
|
||||
self.assertEqual(hashlib.sha256(raw).hexdigest(), digest)
|
||||
with self.assertRaisesRegex(ValueError, "requested SHA-256"):
|
||||
release_source.validate_artifact_zip(
|
||||
path, descriptor_sha256="d" * 64, release_run_id=RELEASE_RUN,
|
||||
ci_run_id=CI_RUN, commit=COMMIT, policy=POLICY,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not bind"):
|
||||
release_source.validate_artifact_zip(
|
||||
path, descriptor_sha256=digest, release_run_id=RELEASE_RUN,
|
||||
ci_run_id="999", commit=COMMIT, policy=POLICY,
|
||||
)
|
||||
|
||||
def test_zip_refuses_extra_traversal_duplicate_and_symlink_members(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
extras = (
|
||||
(("../outside", b"x"),),
|
||||
(("release-manifest.json", b"duplicate"),),
|
||||
)
|
||||
for extra in extras:
|
||||
path = root / f"bad-{len(list(root.iterdir()))}.zip"
|
||||
digest, _ = write_artifact(path, extra=extra)
|
||||
with self.assertRaises(ValueError):
|
||||
release_source.validate_artifact_zip(
|
||||
path, descriptor_sha256=digest, release_run_id=RELEASE_RUN,
|
||||
ci_run_id=CI_RUN, commit=COMMIT, policy=POLICY,
|
||||
)
|
||||
|
||||
path = root / "symlink.zip"
|
||||
raw = json.dumps(MANIFEST, sort_keys=True).encode()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
info = zipfile.ZipInfo("release-manifest.json")
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(info, raw)
|
||||
archive.writestr("release-manifest.json.sha256", f"{digest} release-manifest.json\n")
|
||||
with self.assertRaisesRegex(ValueError, "unsafe"):
|
||||
release_source.validate_artifact_zip(
|
||||
path, descriptor_sha256=digest, release_run_id=RELEASE_RUN,
|
||||
ci_run_id=CI_RUN, commit=COMMIT, policy=POLICY,
|
||||
)
|
||||
|
||||
def test_fetch_release_rechecks_both_runs_and_uses_only_gitea_api_helper(self):
|
||||
with tempfile.TemporaryDirectory() as fixture_dir:
|
||||
fixture_zip = Path(fixture_dir) / "artifact.zip"
|
||||
descriptor_sha, _ = write_artifact(fixture_zip)
|
||||
request = {"run_id": RELEASE_RUN, "commit": COMMIT, "descriptor_sha256": descriptor_sha}
|
||||
artifact_id = 77
|
||||
run_responses = {
|
||||
f"/repos/admin/akyldash/actions/runs/{RELEASE_RUN}": {
|
||||
"id": int(RELEASE_RUN), "workflow_id": "release-images.yaml", "name": "Release production images",
|
||||
"status": "completed", "conclusion": "success", "head_sha": COMMIT,
|
||||
"head_branch": "main", "event": "workflow_run",
|
||||
},
|
||||
"/repos/admin/akyldash/actions/workflows/release-images.yaml": {
|
||||
"id": "release-images.yaml", "name": "Release production images",
|
||||
"path": ".gitea/workflows/release-images.yaml",
|
||||
},
|
||||
f"/repos/admin/akyldash/actions/runs/{CI_RUN}": {
|
||||
"id": int(CI_RUN), "workflow_id": "ci.yaml", "name": "CI", "status": "completed",
|
||||
"conclusion": "success", "head_sha": COMMIT, "head_branch": "main", "event": "push",
|
||||
},
|
||||
"/repos/admin/akyldash/actions/workflows/ci.yaml": {
|
||||
"id": "ci.yaml", "name": "CI", "path": ".gitea/workflows/ci.yaml",
|
||||
},
|
||||
f"/repos/admin/akyldash/actions/runs/{RELEASE_RUN}/artifacts": {
|
||||
"artifacts": [{
|
||||
"id": artifact_id, "name": f"release-descriptor-{RELEASE_RUN}-{descriptor_sha}",
|
||||
"expired": False,
|
||||
"workflow_run": {"id": int(RELEASE_RUN), "head_sha": COMMIT,
|
||||
"head_branch": "main", "event": "workflow_run", "conclusion": "success"},
|
||||
}]
|
||||
},
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
scratch = Path(directory) / "scratch"
|
||||
scratch.mkdir(mode=0o700)
|
||||
calls = []
|
||||
|
||||
def api(path):
|
||||
calls.append(path)
|
||||
return run_responses[path]
|
||||
|
||||
def api_helper(command, **kwargs):
|
||||
calls.append(command)
|
||||
path = Path(command[-1])
|
||||
write_artifact(path)
|
||||
return b"PASS"
|
||||
|
||||
# The source helper requires a private root directory; this synthetic test isolates its owner check.
|
||||
with patch.object(release_source, "ensure_private_scratch"):
|
||||
with patch("release_source.run_api_helper", side_effect=api_helper):
|
||||
manifest, raw = release_source.fetch_release(
|
||||
request, policy=POLICY, temp_dir=scratch, api=api, current_main=COMMIT
|
||||
)
|
||||
self.assertEqual(manifest, MANIFEST)
|
||||
self.assertEqual(hashlib.sha256(raw).hexdigest(), descriptor_sha)
|
||||
self.assertTrue(any(isinstance(call, list) and call[1] == "DOWNLOAD" for call in calls))
|
||||
|
||||
def test_latest_release_request_uses_bounded_filtered_listing_and_verifies_details(self):
|
||||
descriptor_sha = "e" * 64
|
||||
responses = {
|
||||
"/repos/admin/akyldash/actions/workflows/release-images.yaml/runs": {
|
||||
"total_count": 2,
|
||||
"workflow_runs": [
|
||||
{"id": 410, "head_sha": None, "workflow_id": None, "head_branch": None},
|
||||
{"id": 411, "head_sha": None, "workflow_id": None, "head_branch": None},
|
||||
],
|
||||
},
|
||||
"/repos/admin/akyldash/actions/runs/411": {
|
||||
"id": 411, "workflow_id": "release-images.yaml", "name": "Release production images",
|
||||
"status": "completed", "conclusion": "success", "head_sha": COMMIT,
|
||||
"head_branch": "main", "event": "workflow_run",
|
||||
},
|
||||
"/repos/admin/akyldash/actions/workflows/release-images.yaml": {
|
||||
"id": "release-images.yaml", "name": "Release production images",
|
||||
"path": ".gitea/workflows/release-images.yaml",
|
||||
},
|
||||
"/repos/admin/akyldash/actions/runs/411/artifacts": {
|
||||
"artifacts": [{
|
||||
"id": 9, "name": f"release-descriptor-411-{descriptor_sha}", "expired": False,
|
||||
"workflow_run": {"id": 411, "head_sha": COMMIT, "head_branch": "main",
|
||||
"event": "workflow_run", "conclusion": "success"},
|
||||
}]
|
||||
},
|
||||
}
|
||||
calls = []
|
||||
|
||||
def api(path):
|
||||
calls.append(path)
|
||||
parsed = urlsplit(path)
|
||||
return responses[parsed.path]
|
||||
|
||||
request = release_source.latest_release_request(current_main=COMMIT, api=api)
|
||||
self.assertEqual(request, {"run_id": "411", "commit": COMMIT, "descriptor_sha256": descriptor_sha})
|
||||
filters = parse_qs(urlsplit(calls[0]).query)
|
||||
self.assertEqual(filters, {
|
||||
"event": ["workflow_run"], "branch": ["main"], "status": ["success"],
|
||||
"head_sha": [COMMIT], "page": ["1"], "limit": ["10"],
|
||||
})
|
||||
self.assertIn("/actions/workflows/release-images.yaml/runs?", calls[0])
|
||||
self.assertIn("/repos/admin/akyldash/actions/runs/411", calls)
|
||||
|
||||
def test_latest_release_request_fails_closed_on_incomplete_listing(self):
|
||||
with self.assertRaisesRegex(ValueError, "incomplete"):
|
||||
release_source.latest_release_request(
|
||||
current_main=COMMIT,
|
||||
api=lambda _path: {"total_count": 11, "workflow_runs": [{"id": i} for i in range(10)]},
|
||||
)
|
||||
|
||||
def test_latest_release_request_rejects_wrong_detailed_source(self):
|
||||
responses = {
|
||||
"/repos/admin/akyldash/actions/workflows/release-images.yaml/runs": {
|
||||
"total_count": 1, "workflow_runs": [{"id": 411}],
|
||||
},
|
||||
"/repos/admin/akyldash/actions/runs/411": {
|
||||
"id": 411, "workflow_id": "release-images.yaml", "status": "completed",
|
||||
"conclusion": "success", "head_sha": "f" * 40,
|
||||
"head_branch": "main", "event": "workflow_run",
|
||||
},
|
||||
"/repos/admin/akyldash/actions/workflows/release-images.yaml": {
|
||||
"id": "release-images.yaml", "name": "Release production images",
|
||||
"path": ".gitea/workflows/release-images.yaml",
|
||||
},
|
||||
}
|
||||
def api(path):
|
||||
return responses[urlsplit(path).path]
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
release_source.latest_release_request(current_main=COMMIT, api=api)
|
||||
|
||||
def test_api_json_caps_response_and_passes_no_token_argument(self):
|
||||
with patch.object(release_source, "run_api_helper", return_value=b'{"ok":true}') as run:
|
||||
self.assertEqual(release_source.api_json("/repos/admin/akyldash"), {"ok": True})
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(command, ["gitea-api", "GET", "/repos/admin/akyldash"])
|
||||
self.assertEqual(run.call_args.kwargs["timeout"], release_source.API_TIMEOUT_SECONDS)
|
||||
with patch.object(release_source, "run_api_helper", return_value=b"x" * (release_source.MAX_API_RESPONSE_BYTES + 1)):
|
||||
with self.assertRaisesRegex(ValueError, "size limit"):
|
||||
release_source.api_json("/repos/admin/akyldash")
|
||||
|
||||
def test_api_helper_timeout_is_bounded_and_fails_closed(self):
|
||||
with self.assertRaisesRegex(ValueError, "timed out"):
|
||||
release_source.run_api_helper(
|
||||
[sys.executable, "-c", "import time; time.sleep(10)"], env=None,
|
||||
timeout=0.05, max_stdout_bytes=32, max_file_bytes=32,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,372 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import release_handoff
|
||||
from release_handoff import descriptor_request, validate_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GITEA_UPLOAD_ARTIFACT = "https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc"
|
||||
|
||||
|
||||
def workflow_step_env(path, step_name, payload):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
marker = f" - name: {step_name}"
|
||||
start = lines.index(marker)
|
||||
block = []
|
||||
for line in lines[start + 1 :]:
|
||||
if line.startswith(" - "):
|
||||
break
|
||||
block.append(line)
|
||||
env_start = block.index(" env:") + 1
|
||||
env = {}
|
||||
for line in block[env_start:]:
|
||||
match = re.fullmatch(r" ([A-Z_]+): \$\{\{ (gitea(?:\.[a-z_]+)+) \}\}", line)
|
||||
if not match:
|
||||
if line.startswith(" ") and not line.startswith(" "):
|
||||
break
|
||||
continue
|
||||
key, expression = match.groups()
|
||||
value = payload
|
||||
for component in expression.split("."):
|
||||
value = value[component]
|
||||
env[key] = str(value)
|
||||
return env
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
def test_latest_main_lookup_uses_explicit_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
release_handoff.subprocess, "run",
|
||||
return_value=type("Result", (), {"stdout": "a" * 40 + "\trefs/heads/main\n"})(),
|
||||
) as run:
|
||||
self.assertEqual(release_handoff.latest_main_sha(Path(directory)), "a" * 40)
|
||||
self.assertEqual(run.call_args.args[0][:3], ["git", "-C", directory])
|
||||
|
||||
def test_publisher_runs_only_after_successful_main_ci_and_binds_source_sha(self):
|
||||
workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn('workflows: ["CI"]', workflow)
|
||||
self.assertIn("branches: [main]", workflow)
|
||||
self.assertIn("types: [completed]", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.conclusion == 'success'", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.event == 'push'", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.head_branch == 'main'", workflow)
|
||||
self.assertIn("ref: ${{ gitea.event.workflow_run.head_sha }}", workflow)
|
||||
self.assertIn("python3 infra/deploy/release_handoff.py validate-ci-source", workflow)
|
||||
self.assertNotIn("workflow_dispatch:", workflow)
|
||||
self.assertIn("secrets.REGISTRY_USERNAME", workflow)
|
||||
self.assertIn("secrets.REGISTRY_PASSWORD", workflow)
|
||||
self.assertIn(GITEA_UPLOAD_ARTIFACT, workflow)
|
||||
self.assertIn("release/release-manifest.json.sha256", workflow)
|
||||
self.assertIn("retention-days: 90", workflow)
|
||||
self.assertIn("if-no-files-found: error", workflow)
|
||||
self.assertNotIn("gitea.run_attempt", workflow)
|
||||
self.assertIn("${{ gitea.run_id }}", workflow)
|
||||
self.assertIn("${{ gitea.event.workflow_run.head_sha }}", workflow)
|
||||
self.assertIn("steps.descriptor.outputs.descriptor_sha256", workflow)
|
||||
self.assertIn("(cd release && sha256sum -c release-manifest.json.sha256)", workflow)
|
||||
self.assertNotIn("docker compose", workflow)
|
||||
|
||||
guard = workflow.index("Verify successful CI source before using registry credentials")
|
||||
registry_secret = workflow.index("Require explicitly configured Gitea registry credentials")
|
||||
self.assertLess(guard, registry_secret)
|
||||
|
||||
def test_workflow_yaml_maps_gitea_125_payload_into_both_source_guards(self):
|
||||
head_sha = "a" * 40
|
||||
cases = (
|
||||
("release-images.yaml", "Verify successful CI source before using registry credentials",
|
||||
"validate-ci-source", "CI", "ci.yaml@refs/heads/main", "push", "583"),
|
||||
("deploy-handoff.yaml", "Verify source release identity before artifact lookup",
|
||||
"validate-handoff-source", "Release production images",
|
||||
"release-images.yaml@refs/heads/main", "workflow_run", "584"),
|
||||
)
|
||||
for filename, step, command, name, path, event, run_id in cases:
|
||||
payload = {
|
||||
"action": "completed",
|
||||
"workflow": {"id": "workflow-id", "name": name, "path": path.split("@")[0], "state": "active"},
|
||||
"workflow_run": {
|
||||
"id": int(run_id), "path": path, "event": event, "conclusion": "success",
|
||||
"head_branch": "main", "head_sha": head_sha,
|
||||
},
|
||||
"repository": {"full_name": "admin/akyldash"},
|
||||
}
|
||||
workflow_path = ROOT / ".gitea/workflows" / filename
|
||||
source_env = workflow_step_env(workflow_path, step, {"gitea": {"event": payload}})
|
||||
self.assertEqual(set(source_env), {
|
||||
"SOURCE_WORKFLOW", "SOURCE_WORKFLOW_PATH", "SOURCE_EVENT", "SOURCE_CONCLUSION",
|
||||
"SOURCE_BRANCH", "SOURCE_RUN_ID", "SOURCE_SHA", "SOURCE_REPOSITORY",
|
||||
})
|
||||
self.assertIn(f"release_handoff.py {command}", workflow_path.read_text(encoding="utf-8"))
|
||||
with (
|
||||
patch.dict(os.environ, source_env),
|
||||
patch.object(sys, "argv", ["release_handoff.py", command]),
|
||||
patch.object(release_handoff, "latest_main_sha", return_value=head_sha),
|
||||
):
|
||||
self.assertEqual(release_handoff.main(), 0)
|
||||
|
||||
with self.subTest(filename=filename, invalid="repository"), patch.dict(
|
||||
os.environ, source_env | {"SOURCE_REPOSITORY": "attacker/akyldash"}
|
||||
), patch.object(sys, "argv", ["release_handoff.py", command]), patch.object(
|
||||
release_handoff, "latest_main_sha", return_value=head_sha
|
||||
):
|
||||
self.assertEqual(release_handoff.main(), 1)
|
||||
|
||||
def test_deploy_handoff_is_dry_and_uses_the_source_release_run_id(self):
|
||||
workflow = (ROOT / ".gitea/workflows/deploy-handoff.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("name: Production handoff status (not deployed)", workflow)
|
||||
self.assertIn("name: NOT DEPLOYED - handoff status only", workflow)
|
||||
self.assertIn('workflows: ["Release production images"]', workflow)
|
||||
self.assertIn("branches: [main]", workflow)
|
||||
self.assertIn("types: [completed]", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.conclusion == 'success'", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.event == 'workflow_run'", workflow)
|
||||
self.assertIn("gitea.event.workflow_run.head_branch == 'main'", workflow)
|
||||
self.assertIn("SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}", workflow)
|
||||
self.assertNotIn("SOURCE_RUN_ID: ${{ gitea.run_id }}", workflow)
|
||||
self.assertIn("gitea-api GET", workflow)
|
||||
self.assertNotIn("curl ", workflow)
|
||||
self.assertNotIn("AKYLDASH_DEPLOY_SSH_KEY", workflow)
|
||||
self.assertNotIn("docker.sock", workflow)
|
||||
self.assertIn("host handoff are not verified", workflow)
|
||||
self.assertIn("DEPLOYMENT STATUS: NOT DEPLOYED", workflow)
|
||||
self.assertIn("No Windows execution runner or automatic runtime binding has been verified.", workflow)
|
||||
self.assertNotIn("receive_external.py", workflow)
|
||||
self.assertNotIn("akyldash-production-windows", workflow)
|
||||
|
||||
def test_source_guards_reject_wrong_event_or_stale_main(self):
|
||||
valid = dict(
|
||||
workflow="CI", workflow_path="ci.yaml@refs/heads/main", repository="admin/akyldash",
|
||||
event="push", conclusion="success", branch="main",
|
||||
run_id="301", head_sha="a" * 40, stage="release", current_main="a" * 40,
|
||||
)
|
||||
validate_source(**valid)
|
||||
for change in (
|
||||
{"event": "pull_request"},
|
||||
{"branch": "feature/test"},
|
||||
{"conclusion": "failure"},
|
||||
{"workflow": "untrusted workflow"},
|
||||
{"workflow_path": "untrusted.yaml@refs/heads/main"},
|
||||
{"repository": "untrusted/repository"},
|
||||
{"head_sha": "A" * 40},
|
||||
{"run_id": "3/../1"},
|
||||
{"current_main": "b" * 40},
|
||||
):
|
||||
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||
validate_source(**(valid | change))
|
||||
|
||||
def test_handoff_tuple_binds_artifact_run_commit_and_digest(self):
|
||||
run_id = "302"
|
||||
head_sha = "a" * 40
|
||||
digest = "b" * 64
|
||||
response = {
|
||||
"artifacts": [{
|
||||
"id": 9,
|
||||
"name": f"release-descriptor-{run_id}-{digest}",
|
||||
"expired": False,
|
||||
"workflow_run": {
|
||||
"id": int(run_id), "head_sha": head_sha, "head_branch": "main",
|
||||
"event": "workflow_run", "conclusion": "success",
|
||||
},
|
||||
}]
|
||||
}
|
||||
request = descriptor_request(response, run_id=run_id, head_sha=head_sha)
|
||||
self.assertEqual(request, {"run_id": run_id, "commit": head_sha, "descriptor_sha256": digest})
|
||||
self.assertEqual(set(request), {"run_id", "commit", "descriptor_sha256"})
|
||||
for artifact in (
|
||||
{**response["artifacts"][0], "name": f"release-descriptor-303-{digest}"},
|
||||
{**response["artifacts"][0], "name": f"release-descriptor-{run_id}-not-a-digest"},
|
||||
{**response["artifacts"][0], "workflow_run": {"id": 303}},
|
||||
{**response["artifacts"][0], "expired": True},
|
||||
):
|
||||
with self.subTest(artifact=artifact), self.assertRaises(ValueError):
|
||||
descriptor_request({"artifacts": [artifact]}, run_id=run_id, head_sha=head_sha)
|
||||
with self.assertRaises(ValueError):
|
||||
descriptor_request({"artifacts": [response["artifacts"][0], response["artifacts"][0]]}, run_id=run_id, head_sha=head_sha)
|
||||
|
||||
def test_dry_run_validates_the_tuple_without_opening_a_host_connection(self):
|
||||
run_id = "302"
|
||||
head_sha = "a" * 40
|
||||
artifacts = {
|
||||
"artifacts": [{
|
||||
"name": f"release-descriptor-{run_id}-{'b' * 64}",
|
||||
"expired": False,
|
||||
"workflow_run": {
|
||||
"id": int(run_id), "head_sha": head_sha, "head_branch": "main",
|
||||
"event": "workflow_run", "conclusion": "success",
|
||||
},
|
||||
}]
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
metadata = Path(directory) / "artifacts.json"
|
||||
metadata.write_text(json.dumps(artifacts), encoding="utf-8")
|
||||
environment = {
|
||||
"SOURCE_WORKFLOW": "Release production images",
|
||||
"SOURCE_WORKFLOW_PATH": "release-images.yaml@refs/heads/main",
|
||||
"SOURCE_EVENT": "workflow_run",
|
||||
"SOURCE_CONCLUSION": "success",
|
||||
"SOURCE_BRANCH": "main",
|
||||
"SOURCE_RUN_ID": run_id,
|
||||
"SOURCE_SHA": head_sha,
|
||||
"SOURCE_REPOSITORY": "admin/akyldash",
|
||||
}
|
||||
output = io.StringIO()
|
||||
with (
|
||||
patch.dict(os.environ, environment),
|
||||
patch.object(sys, "argv", ["release_handoff.py", "dry-run", "--artifact-metadata", str(metadata)]),
|
||||
patch.object(release_handoff, "latest_main_sha", return_value=head_sha),
|
||||
contextlib.redirect_stdout(output),
|
||||
):
|
||||
self.assertEqual(release_handoff.main(), 0)
|
||||
self.assertIn("descriptor_sha256=" + "b" * 64, output.getvalue())
|
||||
self.assertIn("ZIP bytes were not fetched", output.getvalue())
|
||||
self.assertIn("no host connection was opened", output.getvalue())
|
||||
|
||||
def test_pr_ci_has_small_synthetic_artifact_upload_without_registry_secrets(self):
|
||||
workflow = (ROOT / ".gitea/workflows/ci.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("Verify Gitea runtime artifact upload", workflow)
|
||||
self.assertIn(GITEA_UPLOAD_ARTIFACT, workflow)
|
||||
self.assertIn("retention-days: 1", workflow)
|
||||
self.assertIn("if-no-files-found: error", workflow)
|
||||
self.assertNotIn("github.run_attempt", workflow)
|
||||
self.assertIn("${{ github.run_id }}", workflow)
|
||||
self.assertIn("artifact-smoke/marker.txt", workflow)
|
||||
self.assertNotIn("REGISTRY_PASSWORD", workflow)
|
||||
|
||||
def test_release_checksum_survives_flattened_artifact_layout(self):
|
||||
workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8")
|
||||
generate = "(cd release && sha256sum release-manifest.json > release-manifest.json.sha256)"
|
||||
verify = "(cd release && sha256sum -c release-manifest.json.sha256)"
|
||||
self.assertIn(generate, workflow)
|
||||
self.assertIn(verify, workflow)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
release = root / "release"
|
||||
release.mkdir()
|
||||
(release / "release-manifest.json").write_text(
|
||||
"synthetic manifest\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
generated = subprocess.run(
|
||||
["bash", "-e", "-c", generate], cwd=root, text=True, capture_output=True
|
||||
)
|
||||
self.assertEqual(generated.returncode, 0, generated.stderr)
|
||||
verified = subprocess.run(
|
||||
["bash", "-e", "-c", verify], cwd=root, text=True, capture_output=True
|
||||
)
|
||||
self.assertEqual(verified.returncode, 0, verified.stderr)
|
||||
|
||||
extracted = root / "artifact-root"
|
||||
extracted.mkdir()
|
||||
(extracted / "release-manifest.json").write_bytes((release / "release-manifest.json").read_bytes())
|
||||
(extracted / "release-manifest.json.sha256").write_bytes(
|
||||
(release / "release-manifest.json.sha256").read_bytes()
|
||||
)
|
||||
downloaded = subprocess.run(
|
||||
["sha256sum", "-c", "release-manifest.json.sha256"],
|
||||
cwd=extracted,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(downloaded.returncode, 0, downloaded.stderr)
|
||||
|
||||
def test_pr_build_remains_no_push_and_push_action_has_no_engine_socket(self):
|
||||
build_action = (ROOT / ".gitea/actions/kaniko-build/action.yml").read_text(encoding="utf-8")
|
||||
push_action = (ROOT / ".gitea/actions/kaniko-push/release-build.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("--no-push", build_action)
|
||||
self.assertIn("--no-push-cache", build_action)
|
||||
self.assertIn("REGISTRY_USERNAME:?", push_action)
|
||||
self.assertIn("REGISTRY_PASSWORD:?", push_action)
|
||||
self.assertNotIn("docker.sock", push_action)
|
||||
self.assertNotIn("DOCKER_HOST", push_action)
|
||||
self.assertIn("--digest-file=", push_action)
|
||||
|
||||
def test_release_validator_accepts_workflow_tags_without_credentials_or_kaniko(self):
|
||||
workflow = (ROOT / ".gitea/workflows/release-images.yaml").read_text(encoding="utf-8")
|
||||
dockerfile = ROOT / ".gitea/actions/kaniko-push/Dockerfile"
|
||||
release_builder = (ROOT / ".gitea/actions/kaniko-push/release-build.sh").read_text(encoding="utf-8")
|
||||
validator = ROOT / ".gitea/actions/kaniko-push/validate-destination.sh"
|
||||
self.assertIn(
|
||||
"COPY validate-destination.sh /usr/local/bin/validate-destination.sh",
|
||||
dockerfile.read_text(encoding="utf-8"),
|
||||
)
|
||||
self.assertIn(
|
||||
'/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination"',
|
||||
release_builder,
|
||||
)
|
||||
self.assertIn(
|
||||
"destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}",
|
||||
workflow,
|
||||
)
|
||||
self.assertIn(
|
||||
"destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}",
|
||||
workflow,
|
||||
)
|
||||
examples = (
|
||||
("deploy/production/Dockerfile.backend", "git.vakanaut.ru/admin/akyldash/backend"),
|
||||
("deploy/production/Dockerfile.opensearch", "git.vakanaut.ru/admin/akyldash/opensearch"),
|
||||
)
|
||||
environment = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
|
||||
for dockerfile_path, repository in examples:
|
||||
destination = f"{repository}:{'a' * 40}-567"
|
||||
with self.subTest(repository=repository):
|
||||
accepted = subprocess.run(
|
||||
["sh", str(validator), dockerfile_path, destination],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertEqual(accepted.returncode, 0, accepted.stderr)
|
||||
|
||||
for invalid in (
|
||||
destination + "-1",
|
||||
destination.replace(repository, "registry.invalid/other/image"),
|
||||
f"{repository}:{'A' * 40}-567",
|
||||
f"{repository}:{'a' * 39}-567",
|
||||
f"{repository}:{'a' * 40}-",
|
||||
f"{repository}:{'a' * 40}-not-a-run-id",
|
||||
):
|
||||
rejected = subprocess.run(
|
||||
["sh", str(validator), dockerfile_path, invalid],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertNotEqual(rejected.returncode, 0, invalid)
|
||||
|
||||
rejected_dockerfile = subprocess.run(
|
||||
["sh", str(validator), "deploy/production/Dockerfile.unapproved", destination],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
)
|
||||
self.assertNotEqual(rejected_dockerfile.returncode, 0)
|
||||
|
||||
def test_forced_command_dispatcher_has_only_exact_status_and_deploy_literals(self):
|
||||
dispatcher = ROOT / "infra/deploy/akyldash-release-dispatch.sh"
|
||||
installer = (ROOT / "infra/deploy/install-helper.sh").read_text(encoding="utf-8")
|
||||
source = dispatcher.read_text(encoding="utf-8")
|
||||
self.assertEqual(subprocess.run(["bash", "-n", str(dispatcher)]).returncode, 0)
|
||||
self.assertIn('/usr/local/sbin/akyldash-release-status)', source)
|
||||
self.assertIn('/usr/local/sbin/akyldash-release-deploy)', source)
|
||||
self.assertIn('[[ $EUID -ne 0 || $# -ne 0 ]]', source)
|
||||
self.assertNotIn("eval", source)
|
||||
self.assertIn('install -o root -g root -m 0750 "$release_dispatch_source" /usr/local/sbin/akyldash-release-dispatch', installer)
|
||||
self.assertIn('bash -n "$release_deploy_source" "$release_rollback_source" "$release_dispatch_source"', installer)
|
||||
rejected = subprocess.run(
|
||||
["bash", str(dispatcher)], env={**os.environ, "SSH_ORIGINAL_COMMAND": "id"},
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
self.assertNotEqual(rejected.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,22 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import rollback
|
||||
from rollback import image_restore_plan, require_tag_rollback, tag_previous_images, verify_previous_images
|
||||
import deployment_lock
|
||||
import release_refs
|
||||
import release_state
|
||||
from rollback import (
|
||||
image_restore_plan,
|
||||
require_running_image_ids,
|
||||
require_tag_rollback,
|
||||
tag_previous_images,
|
||||
verify_previous_images,
|
||||
)
|
||||
|
||||
|
||||
class RollbackPlanTests(unittest.TestCase):
|
||||
@@ -27,10 +37,30 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
self.assertEqual(plan["backend"][1], "akyldash/backend:local")
|
||||
self.assertEqual(plan["opensearch"][1], "akyldash/opensearch:local")
|
||||
|
||||
def test_rollback_rejects_running_images_that_differ_from_restore_plan(self):
|
||||
expected = {"backend": "sha256:" + "a" * 64, "opensearch": "sha256:" + "b" * 64}
|
||||
actual = {**expected, "opensearch": "sha256:" + "c" * 64}
|
||||
with self.assertRaisesRegex(ValueError, "rollback plan"):
|
||||
require_running_image_ids(expected, actual, "rollback plan")
|
||||
|
||||
def test_refuses_changed_compose_image_reference(self):
|
||||
with self.assertRaisesRegex(ValueError, "reference changed"):
|
||||
image_restore_plan(self.manifest, {**self.configured, "backend": "other/backend:tag"})
|
||||
|
||||
def test_release_state_allows_guarded_digest_to_legacy_and_legacy_to_digest_restore(self):
|
||||
previous = self.manifest["images"].copy()
|
||||
self.configured["backend"] = "registry/backend@sha256:" + "d" * 64
|
||||
plan = image_restore_plan(self.manifest, self.configured, previous)
|
||||
self.assertEqual(plan["backend"][1], "akyldash/backend:local")
|
||||
old_digest = "registry/backend@sha256:" + "c" * 64
|
||||
self.manifest["images"]["backend"] = {
|
||||
"configured": old_digest, "image_id": "sha256:" + "a" * 64
|
||||
}
|
||||
previous["backend"] = self.manifest["images"]["backend"]
|
||||
self.configured["backend"] = "akyldash/backend:local"
|
||||
plan = image_restore_plan(self.manifest, self.configured, previous)
|
||||
self.assertEqual(plan["backend"][1], old_digest)
|
||||
|
||||
def test_refuses_digest_reference_that_cannot_be_retagged(self):
|
||||
self.manifest["images"]["backend"]["configured"] = "registry/backend@sha256:" + "c" * 64
|
||||
self.configured["backend"] = self.manifest["images"]["backend"]["configured"]
|
||||
@@ -68,53 +98,19 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "previous image is unavailable"):
|
||||
verify_previous_images(plan)
|
||||
|
||||
def test_digest_reference_is_never_tagged(self):
|
||||
def test_digest_reference_is_restored_without_retagging(self):
|
||||
digest = "registry/backend@sha256:" + "c" * 64
|
||||
plan = {"backend": ("sha256:" + "a" * 64, digest)}
|
||||
with patch("rollback.run") as run_mock:
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
tag_previous_images(plan)
|
||||
tag_previous_images(plan)
|
||||
run_mock.assert_not_called()
|
||||
|
||||
def test_digest_rollback_fails_before_stopping_or_changing_references(self):
|
||||
backend_id = "sha256:" + "a" * 64
|
||||
opensearch_id = "sha256:" + "b" * 64
|
||||
digest = "registry/backend@sha256:" + "c" * 64
|
||||
manifest = {
|
||||
"images": {
|
||||
"backend": {"configured": digest, "image_id": backend_id},
|
||||
"opensearch": self.manifest["images"]["opensearch"],
|
||||
},
|
||||
"deployment_files": {
|
||||
"deploy/production/compose.yaml": "compose-hash",
|
||||
"deploy/production/Caddyfile": "caddy-hash",
|
||||
},
|
||||
}
|
||||
rendered = {
|
||||
"services": {
|
||||
"backend": {"image": digest},
|
||||
"opensearch": {"image": self.configured["opensearch"]},
|
||||
}
|
||||
}
|
||||
fake_archive = SimpleNamespace(
|
||||
digest=lambda path: "compose-hash" if path.name == "compose.yaml" else "caddy-hash",
|
||||
verify_archive=lambda _backup: manifest,
|
||||
)
|
||||
backup = rollback.BACKUP_DIR / "synthetic.tar.gz"
|
||||
with (
|
||||
patch("rollback.Path.is_file", return_value=True),
|
||||
patch("rollback.Path.is_symlink", return_value=False),
|
||||
patch("rollback.Path.stat", return_value=SimpleNamespace(st_uid=0, st_mode=0o100600)),
|
||||
patch("rollback.compose", return_value=json.dumps(rendered)) as compose_mock,
|
||||
patch("rollback.run", side_effect=[backend_id, backend_id, opensearch_id]) as run_mock,
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
compose_mock.assert_called_once_with("config", "--format", "json", capture=True)
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
self.assertTrue(all(call.args[0][:3] == ["docker", "image", "inspect"]
|
||||
for call in run_mock.call_args_list))
|
||||
def test_local_vps_backup_path_is_disabled_without_docker_operations(self):
|
||||
with patch("rollback.compose") as compose_mock, patch("rollback.run") as run_mock:
|
||||
with self.assertRaisesRegex(ValueError, "local VPS backup paths are disabled"):
|
||||
rollback.rollback(rollback.Path("/var/backups/akyldash/old.tar.gz"), data_compatible=True)
|
||||
compose_mock.assert_not_called()
|
||||
run_mock.assert_not_called()
|
||||
|
||||
def test_tag_rollback_keeps_existing_image_tag_operation(self):
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PythonPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installerPath = Join-Path $PSScriptRoot 'install_windows_release_client.ps1'
|
||||
$fixtureScript = Join-Path $PSScriptRoot 'test_windows_release_launcher.py'
|
||||
$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$temporaryBase = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $temporaryBase).Path).TrimEnd('\')
|
||||
$temporaryName = 'akyldash-bundle-mode-' + [guid]::NewGuid().ToString('N')
|
||||
$temporaryRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
|
||||
if (Test-Path -LiteralPath $temporaryRoot) { throw 'Temporary fixture directory already exists.' }
|
||||
[void][IO.Directory]::CreateDirectory($temporaryRoot)
|
||||
$temporaryRootItem = Get-Item -LiteralPath $temporaryRoot -Force
|
||||
if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Temporary fixture root must not be a reparse point.'
|
||||
}
|
||||
$temporaryRootCreated = $temporaryRootItem.CreationTimeUtc
|
||||
$temporaryOwnerSid = (Get-Acl -LiteralPath $temporaryRoot).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' }
|
||||
$bundlePath = Join-Path $temporaryRoot 'fixture.zip'
|
||||
|
||||
function Get-VerifiedFixtureRoot([string]$Candidate) {
|
||||
$expectedRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
|
||||
$candidatePath = [IO.Path]::GetFullPath($Candidate)
|
||||
if ([string]::Compare($candidatePath, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0) {
|
||||
throw 'Refusing a fixture root outside the exact temporary base/name.'
|
||||
}
|
||||
$item = Get-Item -LiteralPath $candidatePath -Force
|
||||
if ($item -isnot [IO.DirectoryInfo] -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Fixture root must remain a real directory, not a reparse point.'
|
||||
}
|
||||
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $candidatePath).Path).TrimEnd('\')
|
||||
if ([string]::Compare($resolved, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
[string]::Compare([IO.Path]::GetDirectoryName($resolved), $temporaryBase,
|
||||
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
[string]::Compare([IO.Path]::GetFileName($resolved), $temporaryName,
|
||||
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
$item.CreationTimeUtc -ne $temporaryRootCreated) {
|
||||
throw 'Fixture root was replaced, moved, or resolved outside its original identity.'
|
||||
}
|
||||
$ownerSid = (Get-Acl -LiteralPath $candidatePath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture root owner changed.'
|
||||
}
|
||||
return $resolved
|
||||
}
|
||||
|
||||
function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Fixture entry must be a regular file or directory without reparse points.'
|
||||
}
|
||||
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $item.FullName).Path)
|
||||
if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Fixture entry resolved outside its verified temporary root.'
|
||||
}
|
||||
$ownerSid = (Get-Acl -LiteralPath $resolved).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture entry is not owned by the current user.'
|
||||
}
|
||||
return Get-Item -LiteralPath $resolved -Force
|
||||
}
|
||||
|
||||
$wrongRootRejected = $false
|
||||
try { [void](Get-VerifiedFixtureRoot -Candidate $temporaryBase) }
|
||||
catch { $wrongRootRejected = $true }
|
||||
if (-not $wrongRootRejected) { throw 'Fixture cleanup accepted an incorrect root path.' }
|
||||
$passMessage = $null
|
||||
|
||||
try {
|
||||
$fixtureOutput = & $PythonPath -B -E -s -S $fixtureScript --write-bundle-fixture $bundlePath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'The temporary packager fixture failed.' }
|
||||
$fixture = ($fixtureOutput -join "`n") | ConvertFrom-Json
|
||||
|
||||
$gitObjectDirectory = Join-Path $temporaryRoot 'fixture-repository\.git\objects'
|
||||
$readonlyObject = Get-ChildItem -LiteralPath $gitObjectDirectory -File -Force -Recurse | Select-Object -First 1
|
||||
if ($null -eq $readonlyObject) { throw 'Packager fixture did not create a loose Git object.' }
|
||||
$verifiedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verifiedObject = Get-VerifiedFixtureEntry -Path $readonlyObject.FullName -ResolvedRoot $verifiedRoot
|
||||
[IO.File]::SetAttributes($verifiedObject.FullName,
|
||||
[IO.FileAttributes]([int]$verifiedObject.Attributes -bor [int][IO.FileAttributes]::ReadOnly))
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$installerAst = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installerPath, [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count -gt 0) { throw 'Installer PowerShell syntax is invalid.' }
|
||||
$validatorAst = $installerAst.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -ceq 'Assert-ReceiverBundleEntryMode'
|
||||
}, $true)
|
||||
if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' }
|
||||
. ([scriptblock]::Create($validatorAst.Extent.Text))
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$mutationRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
foreach ($guardName in @('Assert-ProtectedPath', 'Assert-TrustedPath')) {
|
||||
$guardAst = $installerAst.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -ceq $guardName
|
||||
}, $true)
|
||||
if ($null -eq $guardAst) { throw "Installer ACL guard was not found: $guardName" }
|
||||
. ([scriptblock]::Create($guardAst.Extent.Text))
|
||||
}
|
||||
|
||||
$unknownSid = [Security.Principal.SecurityIdentifier]::new(
|
||||
'S-1-5-21-3141592653-2718281828-1618033988-987654')
|
||||
$unmapped = $false
|
||||
try { $null = $unknownSid.Translate([Security.Principal.NTAccount]) }
|
||||
catch [Security.Principal.IdentityNotMappedException] { $unmapped = $true }
|
||||
if (-not $unmapped) { throw 'The synthetic ACL SID unexpectedly resolves to an NTAccount.' }
|
||||
|
||||
$savedMockAcl = $script:akyldashAclFixture
|
||||
function Get-Acl([string]$LiteralPath) { return $script:akyldashAclFixture }
|
||||
try {
|
||||
$sidSystem = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
||||
$sidAdmins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$privateFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$privateFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$privateFixtureAcl.SetOwner($identity.User)
|
||||
foreach ($sid in @($sidSystem, $sidAdmins, $identity.User)) {
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
}
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $privateFixtureAcl
|
||||
$privateRejected = $false
|
||||
try { Assert-ProtectedPath -Path $temporaryRoot -AllowOwnerWrite $true }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Private state path ACL must grant only full control*') { throw }
|
||||
$privateRejected = $true
|
||||
}
|
||||
if (-not $privateRejected) { throw 'Unmapped SID was not rejected by the protected-path guard.' }
|
||||
|
||||
$trustedFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$trustedFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$trustedFixtureAcl.SetOwner($sidSystem)
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sidSystem, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::WriteData,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $trustedFixtureAcl
|
||||
$trustedRejected = $false
|
||||
try { Assert-TrustedPath -Path $temporaryRoot }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Protected path grants replacement/write access*') { throw }
|
||||
$trustedRejected = $true
|
||||
}
|
||||
if (-not $trustedRejected) { throw 'Unmapped SID was not rejected by the trusted-path guard.' }
|
||||
} finally {
|
||||
Remove-Item Function:\Get-Acl -ErrorAction SilentlyContinue
|
||||
if ($null -eq $savedMockAcl) { Remove-Variable -Name akyldashAclFixture -Scope Script -ErrorAction SilentlyContinue }
|
||||
else { $script:akyldashAclFixture = $savedMockAcl }
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$stream = [IO.File]::OpenRead($bundlePath)
|
||||
try {
|
||||
$archive = [System.IO.Compression.ZipArchive]::new(
|
||||
$stream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
try {
|
||||
foreach ($entry in $archive.Entries) {
|
||||
Assert-ReceiverBundleEntryMode -Entry $entry
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
} finally { $stream.Dispose() }
|
||||
|
||||
$alteredPath = Join-Path $temporaryRoot 'altered-mode.zip'
|
||||
$sourceStream = [IO.File]::OpenRead($bundlePath)
|
||||
$targetStream = [IO.File]::Create($alteredPath)
|
||||
try {
|
||||
$sourceArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$sourceStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
$targetArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$targetStream, [System.IO.Compression.ZipArchiveMode]::Create, $true)
|
||||
try {
|
||||
$changed = $false
|
||||
foreach ($entry in $sourceArchive.Entries) {
|
||||
$copy = $targetArchive.CreateEntry($entry.FullName, [System.IO.Compression.CompressionLevel]::NoCompression)
|
||||
$copy.ExternalAttributes = $entry.ExternalAttributes
|
||||
if (-not $changed) {
|
||||
$copy.ExternalAttributes = [int]-2120220672
|
||||
$changed = $true
|
||||
}
|
||||
$entryInput = $entry.Open()
|
||||
$entryOutput = $copy.Open()
|
||||
try { $entryInput.CopyTo($entryOutput) }
|
||||
finally { $entryOutput.Dispose(); $entryInput.Dispose() }
|
||||
}
|
||||
if (-not $changed) { throw 'Packager fixture had no entries to mutate.' }
|
||||
} finally { $targetArchive.Dispose(); $sourceArchive.Dispose() }
|
||||
} finally { $targetStream.Dispose(); $sourceStream.Dispose() }
|
||||
|
||||
$alteredStream = [IO.File]::OpenRead($alteredPath)
|
||||
try {
|
||||
$alteredArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$alteredStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
try {
|
||||
$alteredEntry = $alteredArchive.Entries[0]
|
||||
$alteredMode = (([long]$alteredEntry.ExternalAttributes -shr 16) -band 0xFFFF)
|
||||
if ($alteredMode -ne 0x81A0) { throw 'Altered ZIP fixture does not contain the intended unsafe mode.' }
|
||||
$rejected = $false
|
||||
try { Assert-ReceiverBundleEntryMode -Entry $alteredEntry }
|
||||
catch {
|
||||
if ($_.Exception.Message -notmatch 'unsafe file mode') { throw }
|
||||
$rejected = $true
|
||||
}
|
||||
if (-not $rejected) { throw 'Installer validator accepted an altered ZIP entry mode.' }
|
||||
} finally { $alteredArchive.Dispose() }
|
||||
} finally { $alteredStream.Dispose() }
|
||||
|
||||
$passMessage = ("PASS: installer validator accepted the actual packager bundle for commit {0} (SHA-256 {1}) and rejected altered mode; read-only Git object cleanup succeeded." -f $fixture.commit, $fixture.sha256)
|
||||
} finally {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$entries = @(Get-ChildItem -LiteralPath $resolvedRoot -Force -Recurse)
|
||||
$verifiedEntries = @(
|
||||
foreach ($entry in $entries) {
|
||||
Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
}
|
||||
)
|
||||
foreach ($entry in ($verifiedEntries | Sort-Object { $_.FullName.Length } -Descending)) {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
$writableAttributes = [IO.FileAttributes]([int]$verified.Attributes -band
|
||||
(-bnot [int][IO.FileAttributes]::ReadOnly))
|
||||
[IO.File]::SetAttributes($verified.FullName, $writableAttributes)
|
||||
$verified = Get-VerifiedFixtureEntry -Path $verified.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
throw 'Could not clear the read-only attribute from the verified fixture entry.'
|
||||
}
|
||||
}
|
||||
}
|
||||
$remainingRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$remainingEntries = @(Get-ChildItem -LiteralPath $remainingRoot -Force -Recurse)
|
||||
$initialPaths = @($entries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
|
||||
$remainingPaths = @($remainingEntries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
|
||||
if (($initialPaths -join "`n") -cne ($remainingPaths -join "`n")) {
|
||||
throw 'Temporary fixture contents changed during cleanup; refusing deletion.'
|
||||
}
|
||||
foreach ($entry in ($remainingEntries | Sort-Object { $_.FullName.Length } -Descending)) {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
throw 'Refusing fixture deletion while a verified entry remains read-only.'
|
||||
}
|
||||
if ($verified -is [IO.DirectoryInfo]) {
|
||||
[IO.Directory]::Delete($verified.FullName, $false)
|
||||
} elseif ($verified -is [IO.FileInfo]) {
|
||||
[IO.File]::Delete($verified.FullName)
|
||||
} else {
|
||||
throw 'Refusing to delete an unknown temporary fixture entry type.'
|
||||
}
|
||||
}
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
[IO.Directory]::Delete($resolvedRoot, $false)
|
||||
}
|
||||
|
||||
Write-Output $passMessage
|
||||
@@ -0,0 +1,415 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import prepare_windows_release_bundle as release_bundle
|
||||
from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class WindowsReleaseBootstrapTests(unittest.TestCase):
|
||||
def _installer_mutation_mask(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
match = re.search(r"\$mutationRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
|
||||
self.assertIsNotNone(match, "installer must declare one explicit FileSystemRights mutation mask")
|
||||
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
|
||||
values = {
|
||||
"WriteData": 0x0002,
|
||||
"AppendData": 0x0004,
|
||||
"WriteExtendedAttributes": 0x0010,
|
||||
"WriteAttributes": 0x0100,
|
||||
"DeleteSubdirectoriesAndFiles": 0x0040,
|
||||
"Delete": 0x10000,
|
||||
"ChangePermissions": 0x40000,
|
||||
"TakeOwnership": 0x80000,
|
||||
}
|
||||
self.assertEqual(names, set(values), "mutation mask must name each mutating filesystem right explicitly")
|
||||
mask = 0
|
||||
for name in names:
|
||||
mask |= values[name]
|
||||
return script, mask, values
|
||||
|
||||
def test_installer_acl_guard_rejects_each_mutation_right(self):
|
||||
script, mask, rights = self._installer_mutation_mask()
|
||||
trusted = {"S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"}
|
||||
|
||||
def safe_acl(owner, entries):
|
||||
if owner not in trusted:
|
||||
return False
|
||||
for sid, access_type, granted, inherit_only in entries:
|
||||
if inherit_only:
|
||||
continue
|
||||
if access_type == "Allow" and granted & mask and sid not in trusted:
|
||||
return False
|
||||
return True
|
||||
|
||||
for name, right in rights.items():
|
||||
with self.subTest(right=name):
|
||||
self.assertNotEqual(mask & right, 0)
|
||||
self.assertFalse(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", right, False)]))
|
||||
self.assertTrue(safe_acl(
|
||||
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
|
||||
[("S-1-5-32-544", "Allow", mask, False), ("S-1-5-11", "Allow", 0x0001, False)],
|
||||
))
|
||||
self.assertTrue(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", mask, True)]))
|
||||
self.assertIn("([int]$_.FileSystemRights -band [int]$mutationRights)", script)
|
||||
self.assertIn("([int]$rule.FileSystemRights -band [int]$checkedRights)", script)
|
||||
self.assertIn("PropagationFlags]::InheritOnly", script)
|
||||
owner_branch = re.search(
|
||||
r"if \(\$AllowOwnerWrite\) \{\s*\$approvedOwners = @\((.*?)\)\s*\} else \{\s*\$approvedOwners = @\((.*?)\)\s*\}",
|
||||
script, re.S,
|
||||
)
|
||||
self.assertIsNotNone(owner_branch)
|
||||
self.assertIn("$identity.User.Value", owner_branch.group(1))
|
||||
self.assertNotIn("$identity.User.Value", owner_branch.group(2))
|
||||
self.assertIn("$trustedInstaller", owner_branch.group(2))
|
||||
|
||||
def test_parent_path_guard_allows_create_sibling_but_rejects_replacement_rights(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
match = re.search(r"\$pathReplacementRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
|
||||
self.assertIsNotNone(match)
|
||||
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
|
||||
values = {"DeleteSubdirectoriesAndFiles": 0x40, "Delete": 0x10000,
|
||||
"ChangePermissions": 0x40000, "TakeOwnership": 0x80000}
|
||||
self.assertEqual(names, set(values))
|
||||
mask = sum(values.values())
|
||||
self.assertEqual(mask & 0x0004, 0) # AppendData/CreateDirectories on C:\ creates a sibling.
|
||||
for right in values.values():
|
||||
self.assertNotEqual(mask & right, 0)
|
||||
|
||||
def test_installer_source_is_hash_bound_locked_and_never_runs_git(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("[IO.FileShare]::Read", script)
|
||||
self.assertIn("[System.IO.Compression.ZipArchive]::new($bundleStream", script)
|
||||
self.assertIn("Assert-ReceiverBundleEntryMode -Entry $zipEntry", script)
|
||||
self.assertIn("$entryMode -ne 0x8180", script)
|
||||
self.assertIn("$bundleHash -ne $ExpectedBundleSha256", script)
|
||||
self.assertIn("manifest.commit -cne $ExpectedCommit", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'blob' -Bytes $data", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'tree' -Bytes $treeData", script)
|
||||
self.assertIn("Receiver bundle commit tree does not contain the exact source file", script)
|
||||
self.assertIn("objects/commit.bin", script)
|
||||
self.assertIn("objects/root-tree.bin", script)
|
||||
self.assertIn("$treeHeaders[0].Groups[1].Value -cne $seenTrees['']", script)
|
||||
self.assertIn("$verifiedFiles[$file.path] = $data", script)
|
||||
self.assertNotIn("(& $git", script)
|
||||
self.assertNotIn("Start-Process", script)
|
||||
self.assertNotIn("Copy-Item", script)
|
||||
self.assertLess(script.index("$bundleHash -ne $ExpectedBundleSha256"),
|
||||
script.index("[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])"))
|
||||
self.assertLess(script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }"),
|
||||
script.index("$bundleStream = [IO.File]::Open"))
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script)
|
||||
self.assertIn("$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value", script)
|
||||
self.assertGreaterEqual(script.count("$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])"), 2)
|
||||
self.assertIn("$rule.IdentityReference.Value", script)
|
||||
self.assertNotIn(".Translate(", script)
|
||||
self.assertIn("Assert-TrustedPath $appRoot", script)
|
||||
self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script)
|
||||
self.assertIn("Set-TrustedCodeOwner $app", script)
|
||||
self.assertIn("Set-TrustedCodeOwner $destination", script)
|
||||
for name in release_bundle.FILES:
|
||||
self.assertIn(f"files/{name}", script)
|
||||
|
||||
def test_windows_acl_harness_exercises_unmapped_sid_with_real_installer_guards(self):
|
||||
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("Assert-ProtectedPath", harness)
|
||||
self.assertIn("Assert-TrustedPath", harness)
|
||||
self.assertIn("IdentityNotMappedException", harness)
|
||||
self.assertIn("$unknownSid", harness)
|
||||
self.assertIn("$guardAst.Extent.Text", harness)
|
||||
self.assertIn("Unmapped SID was not rejected by the protected-path guard", harness)
|
||||
|
||||
def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository = root / "repo"
|
||||
repository.mkdir()
|
||||
self._git(repository, "init", "-b", "main")
|
||||
self._git(repository, "config", "user.name", "Fixture")
|
||||
self._git(repository, "config", "user.email", "fixture@example.invalid")
|
||||
for name, relative in release_bundle.FILES.items():
|
||||
path = repository / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"committed:{name}\n".encode())
|
||||
self._git(repository, "add", "--", *release_bundle.FILES.values())
|
||||
self._git(repository, "commit", "-m", "fixture")
|
||||
commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "switch", "-c", "not-main")
|
||||
self._git(repository, "commit", "--allow-empty", "-m", "alternate")
|
||||
alternate = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "switch", "main")
|
||||
with self.assertRaisesRegex(ValueError, "local main branch tip"):
|
||||
release_bundle.create_bundle(repository, alternate)
|
||||
|
||||
executable_path = repository / release_bundle.FILES["archive.py"]
|
||||
self._git(repository, "switch", "-c", "executable-mode")
|
||||
executable_path.chmod(0o755)
|
||||
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
|
||||
self._git(repository, "commit", "-m", "executable source")
|
||||
executable_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "update-ref", "refs/heads/main", executable_commit)
|
||||
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
|
||||
release_bundle.create_bundle(repository, executable_commit)
|
||||
self._git(repository, "update-ref", "refs/heads/main", commit)
|
||||
self._git(repository, "switch", "main")
|
||||
|
||||
symlink_path = repository / release_bundle.FILES["archive.py"]
|
||||
self._git(repository, "switch", "-c", "symlink-source")
|
||||
symlink_path.unlink()
|
||||
symlink_path.symlink_to("missing-target")
|
||||
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
|
||||
self._git(repository, "commit", "-m", "symlink source")
|
||||
symlink_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "update-ref", "refs/heads/main", symlink_commit)
|
||||
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
|
||||
release_bundle.create_bundle(repository, symlink_commit)
|
||||
self._git(repository, "update-ref", "refs/heads/main", commit)
|
||||
self._git(repository, "switch", "main")
|
||||
|
||||
edited = repository / release_bundle.FILES["receive_external.py"]
|
||||
edited.write_bytes(b"uncommitted attacker-controlled edit\n")
|
||||
hook_marker = root / "fsmonitor-ran"
|
||||
hook = root / "fsmonitor.sh"
|
||||
hook.write_text(f"#!/bin/sh\nprintf ran > {shlex.quote(str(hook_marker))}\n", encoding="utf-8")
|
||||
hook.chmod(0o700)
|
||||
self._git(repository, "config", "core.fsmonitor", str(hook))
|
||||
|
||||
payload = release_bundle.create_bundle(repository, commit)
|
||||
expected_sha256 = hashlib.sha256(payload).hexdigest()
|
||||
self.assertEqual(set(release_bundle.verify_bundle(payload, commit, expected_sha256)),
|
||||
set(release_bundle.FILES))
|
||||
self.assertFalse(hook_marker.exists(), "bundle creation must not invoke config-driven fsmonitor hooks")
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as bundle:
|
||||
manifest = json.loads(bundle.read("manifest.json"))
|
||||
self.assertEqual(manifest["commit"], commit)
|
||||
self.assertEqual(set(member.filename for member in bundle.infolist()),
|
||||
{"manifest.json", *(f"files/{name}" for name in release_bundle.FILES),
|
||||
*release_bundle.TREE_OBJECTS.values(), "objects/commit.bin"})
|
||||
self.assertEqual({(member.external_attr >> 16) & 0xFFFF for member in bundle.infolist()},
|
||||
{0o100600})
|
||||
self.assertEqual(release_bundle._git_object_sha("commit", bundle.read("objects/commit.bin")), commit)
|
||||
for tree in manifest["trees"]:
|
||||
object_name = release_bundle.TREE_OBJECTS[tree["path"]]
|
||||
self.assertEqual(release_bundle._git_object_sha("tree", bundle.read(object_name)), tree["sha1"])
|
||||
for name in release_bundle.FILES:
|
||||
self.assertEqual(bundle.read(f"files/{name}"), f"committed:{name}\n".encode())
|
||||
for member in ("objects/commit.bin", "objects/infra-tree.bin", "files/receive_external.py"):
|
||||
with self.subTest(member=member):
|
||||
corrupted = self._rewrite_bundle(payload, member, lambda content: content[:-1] + bytes([content[-1] ^ 1]))
|
||||
with self.assertRaises(ValueError):
|
||||
release_bundle.verify_bundle(corrupted, commit, hashlib.sha256(corrupted).hexdigest())
|
||||
|
||||
traversal = self._rewrite_bundle(payload, "../escape", lambda _unused: b"outside")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected entries"):
|
||||
release_bundle.verify_bundle(traversal, commit, hashlib.sha256(traversal).hexdigest())
|
||||
duplicate = self._rewrite_bundle(payload, "manifest.json", lambda content: content, duplicate=True)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
release_bundle.verify_bundle(duplicate, commit, hashlib.sha256(duplicate).hexdigest())
|
||||
symlink_entry = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
|
||||
external_attr=0o120777 << 16)
|
||||
with self.assertRaisesRegex(ValueError, "unsafe mode"):
|
||||
release_bundle.verify_bundle(symlink_entry, commit, hashlib.sha256(symlink_entry).hexdigest())
|
||||
altered_mode = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
|
||||
external_attr=0o100755 << 16)
|
||||
with self.assertRaisesRegex(ValueError, "unsafe mode"):
|
||||
release_bundle.verify_bundle(altered_mode, commit, hashlib.sha256(altered_mode).hexdigest())
|
||||
with self.assertRaisesRegex(ValueError, "invalid size"):
|
||||
release_bundle.verify_bundle(b"x" * (release_bundle.MAX_BUNDLE_BYTES + 1), commit, "0" * 64)
|
||||
|
||||
@staticmethod
|
||||
def _git(repository, *arguments):
|
||||
return subprocess.run(["git", "-C", str(repository), *arguments], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_bundle(payload, target, transform, *, duplicate=False, external_attr=None):
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as original, zipfile.ZipFile(output, "w") as altered:
|
||||
for item in original.infolist():
|
||||
content = original.read(item)
|
||||
if item.filename == target:
|
||||
content = transform(content)
|
||||
info = zipfile.ZipInfo(item.filename, item.date_time)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.external_attr = external_attr if item.filename == target and external_attr is not None else item.external_attr
|
||||
altered.writestr(info, content)
|
||||
if duplicate and item.filename == target:
|
||||
altered.writestr(info, content)
|
||||
if target not in {item.filename for item in original.infolist()}:
|
||||
info = zipfile.ZipInfo(target)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.external_attr = 0o100600 << 16
|
||||
altered.writestr(info, transform(b""))
|
||||
return output.getvalue()
|
||||
|
||||
def test_installer_has_no_child_processes_and_checks_tools_before_bundle_read(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
trusted_tools = script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }")
|
||||
bundle_open = script.index("$bundleStream = [IO.File]::Open")
|
||||
self.assertLess(trusted_tools, bundle_open)
|
||||
self.assertNotIn("(& $git", script)
|
||||
self.assertNotIn("Start-Process", script)
|
||||
self.assertNotIn("Process]::Start", script)
|
||||
self.assertIn("while ($null -ne $current)", script)
|
||||
self.assertIn("$current = $current.Parent", script)
|
||||
self.assertIn("$ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
|
||||
|
||||
def test_windows_bundle_mode_harness_uses_safe_python_and_validates_cleanup_target(self):
|
||||
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("-B -E -s -S", harness)
|
||||
self.assertIn("$item.CreationTimeUtc -ne $temporaryRootCreated", harness)
|
||||
self.assertIn("$ownerSid -cne $temporaryOwnerSid", harness)
|
||||
self.assertIn("[IO.FileAttributes]::ReparsePoint", harness)
|
||||
self.assertIn("[IO.Directory]::Delete($resolvedRoot, $false)", harness)
|
||||
self.assertNotIn("[IO.Directory]::Delete($resolvedRoot, $true)", harness)
|
||||
self.assertIn("$wrongRootRejected", harness)
|
||||
self.assertIn("$readonlyObject", harness)
|
||||
self.assertIn("-bnot [int][IO.FileAttributes]::ReadOnly", harness)
|
||||
self.assertIn("[IO.File]::Delete($verified.FullName)", harness)
|
||||
self.assertIn("-not $resolved.StartsWith($ResolvedRoot + '\\'", harness)
|
||||
self.assertLess(harness.index("if ($alteredMode -ne 0x81A0)"),
|
||||
harness.index("Assert-ReceiverBundleEntryMode -Entry $alteredEntry"))
|
||||
|
||||
def test_windows_tool_environment_discards_path_and_git_overrides(self):
|
||||
environment = {
|
||||
"PATH": r"C:\Users\attacker\bin",
|
||||
"PATHEXT": ".COM;.EXE;.BAT",
|
||||
"PSModulePath": r"C:\Users\attacker\modules",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "core.sshCommand",
|
||||
"GIT_CONFIG_VALUE_0": "attacker.exe",
|
||||
"SAFE_SETTING": "preserved",
|
||||
}
|
||||
calls = []
|
||||
|
||||
def which(command):
|
||||
calls.append(command)
|
||||
return str(WINDOWS_TOOLS[command])
|
||||
|
||||
configure_windows_tool_environment(environ=environment, which=which)
|
||||
self.assertEqual(environment["PATH"], WINDOWS_PATH)
|
||||
self.assertEqual(environment["PATHEXT"], ".EXE")
|
||||
self.assertEqual(environment["SystemRoot"], r"C:\Windows")
|
||||
self.assertEqual(environment["PSModulePath"], r"C:\Windows\System32\WindowsPowerShell\v1.0\Modules")
|
||||
self.assertEqual(environment["SAFE_SETTING"], "preserved")
|
||||
self.assertFalse(any(key.upper().startswith("GIT_") for key in environment))
|
||||
self.assertEqual(set(calls), set(WINDOWS_TOOLS))
|
||||
|
||||
def test_windows_tool_environment_rejects_unexpected_resolution(self):
|
||||
environment = {"PATH": r"C:\attacker"}
|
||||
with self.assertRaisesRegex(RuntimeError, "protected path"):
|
||||
configure_windows_tool_environment(environ=environment, which=lambda _command: r"C:\attacker\ssh.exe")
|
||||
|
||||
def test_launcher_ignores_poisoned_working_directory_and_pythonpath(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
app = root / "Program Files" / "Akyldash"
|
||||
poison = root / "poison"
|
||||
app.mkdir(parents=True)
|
||||
poison.mkdir()
|
||||
launcher = app / "windows_release_launcher.py"
|
||||
launcher.write_bytes((ROOT / "infra/deploy/windows_release_launcher.py").read_bytes())
|
||||
(app / "receive_external.py").write_text("def main(): return 0\n", encoding="utf-8")
|
||||
(app / "archive.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
(app / "external_session.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
marker = root / "sitecustomize-ran"
|
||||
(poison / "json.py").write_text("raise RuntimeError('poison json imported')\n", encoding="utf-8")
|
||||
(poison / "sitecustomize.py").write_text(
|
||||
"import os\nfrom pathlib import Path\nPath(os.environ['POISON_MARKER']).touch()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
driver = app / "driver.py"
|
||||
driver.write_text(
|
||||
"import sys\n"
|
||||
"from windows_release_launcher import configure_import_path\n"
|
||||
"configure_import_path(executable=sys.executable, app_dir=__import__('pathlib').Path(__file__).parent, "
|
||||
"python_root=__import__('pathlib').Path(sys.prefix), expected_executable=__import__('pathlib').Path(sys.executable), "
|
||||
"protected_app_root=__import__('pathlib').Path(__file__).parents[1])\n"
|
||||
"import json, receive_external, archive, external_session\n"
|
||||
"from pathlib import Path\n"
|
||||
"assert Path(json.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())\n"
|
||||
"assert Path(receive_external.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(archive.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(external_session.__file__).resolve().parent == Path(__file__).parent.resolve()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = {**os.environ, "PYTHONPATH": str(poison), "POISON_MARKER": str(marker)}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-E", "-s", "-S", str(driver)], cwd=poison,
|
||||
env=environment, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_disabled_task_uses_pinned_python_and_hardened_launcher(self):
|
||||
path = ROOT / "infra/deploy/windows-release-trigger.example.xml"
|
||||
tree = ET.parse(path)
|
||||
namespace = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
task = tree.getroot()
|
||||
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=namespace), "false")
|
||||
trigger = task.find("t:Triggers/t:CalendarTrigger", namespace)
|
||||
self.assertEqual(trigger.findtext("t:Enabled", namespaces=namespace), "false")
|
||||
self.assertEqual(task.findtext("t:Actions/t:Exec/t:Command", namespaces=namespace), r"C:\Python313\python.exe")
|
||||
arguments = task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=namespace)
|
||||
self.assertIn("-B -E -s -S", arguments)
|
||||
self.assertIn("windows_release_launcher.py", arguments)
|
||||
|
||||
def test_installer_never_registers_or_enables_task_and_uses_canonical_trigger(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("release-trigger.json", script)
|
||||
self.assertIn("release-auto-apply.json", script)
|
||||
self.assertIn('"enabled":false', script)
|
||||
self.assertIn("C:\\Python313\\python.exe", script)
|
||||
self.assertIn("Get-AuthenticodeSignature", script)
|
||||
self.assertIn(".Status -ne 'Valid'", script)
|
||||
self.assertIn("client-$ExpectedCommit", script)
|
||||
self.assertIn("--auto-apply-prepared", script)
|
||||
self.assertIn("Akyldash-ReleaseAutoApply-disabled.xml", script)
|
||||
self.assertIn("Assert-TrustedPath $tool", script)
|
||||
self.assertIn(r"$git = 'C:\Program Files\Git\cmd\git.exe'", script)
|
||||
self.assertIn(r"$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'", script)
|
||||
self.assertIn(r"$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'", script)
|
||||
self.assertIn("$current = $current.Parent", script)
|
||||
self.assertIn("PropagationFlags]::InheritOnly", script)
|
||||
self.assertNotIn("Register-ScheduledTask", script)
|
||||
self.assertNotIn("Enable-ScheduledTask", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--write-bundle-fixture":
|
||||
output_path = Path(sys.argv[2])
|
||||
repository = output_path.parent / "fixture-repository"
|
||||
repository.mkdir()
|
||||
subprocess.run(["git", "-C", str(repository), "init", "-b", "main"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["git", "-C", str(repository), "config", "user.name", "Akyldash Test"], check=True)
|
||||
subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.invalid"], check=True)
|
||||
for name, relative in release_bundle.FILES.items():
|
||||
path = repository / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"fixture:{name}\n".encode("ascii"))
|
||||
subprocess.run(["git", "-C", str(repository), "add", "--", *release_bundle.FILES.values()], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["git", "-C", str(repository), "commit", "-m", "bundle fixture"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
commit = subprocess.run(["git", "-C", str(repository), "rev-parse", "HEAD"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip()
|
||||
payload = release_bundle.create_bundle(repository, commit)
|
||||
output_path.write_bytes(payload)
|
||||
print(json.dumps({"commit": commit, "sha256": hashlib.sha256(payload).hexdigest()}))
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVICES = {"backend", "opensearch"}
|
||||
REPOSITORY = "admin/akyldash"
|
||||
COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")
|
||||
REPOSITORY_RE = re.compile(
|
||||
r"[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?"
|
||||
@@ -58,16 +59,27 @@ def validate_policy(policy: object) -> dict[str, list[str]]:
|
||||
def validate_manifest(manifest: object, policy: object) -> dict[str, object]:
|
||||
allowed_repositories = validate_policy(policy)
|
||||
if not isinstance(manifest, dict) or set(manifest) != {
|
||||
"schema_version", "source_ref", "commit", "images"
|
||||
"schema_version", "source_ref", "repository", "commit", "workflow", "run_id", "source_run_id", "builder", "images"
|
||||
}:
|
||||
raise ValueError("manifest must contain exactly schema_version, source_ref, commit, and images")
|
||||
if type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1:
|
||||
raise ValueError("manifest must contain exactly the release source, builder, run, and image fields")
|
||||
if type(manifest["schema_version"]) is not int or manifest["schema_version"] != 4:
|
||||
raise ValueError("unsupported manifest schema_version")
|
||||
if manifest["source_ref"] != "refs/heads/main":
|
||||
raise ValueError("manifest source_ref must be refs/heads/main")
|
||||
commit = manifest["commit"]
|
||||
if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit):
|
||||
raise ValueError("manifest commit must be a 40-character lowercase Git SHA-1")
|
||||
if manifest["repository"] != REPOSITORY:
|
||||
raise ValueError(f"manifest repository must be {REPOSITORY}")
|
||||
if manifest["workflow"] != ".gitea/workflows/release-images.yaml":
|
||||
raise ValueError("manifest workflow is not the approved image release workflow")
|
||||
if not isinstance(manifest["run_id"], str) or not re.fullmatch(r"[0-9]+", manifest["run_id"]):
|
||||
raise ValueError("manifest run_id must be a numeric Gitea Actions run ID")
|
||||
if not isinstance(manifest["source_run_id"], str) or not re.fullmatch(r"[0-9]+", manifest["source_run_id"]):
|
||||
raise ValueError("manifest source_run_id must be a numeric Gitea CI run ID")
|
||||
builder = manifest["builder"]
|
||||
if builder != {"name": "Kaniko", "image": "ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412"}:
|
||||
raise ValueError("manifest builder is not the pinned release builder")
|
||||
images = manifest["images"]
|
||||
if not isinstance(images, dict) or set(images) != SERVICES:
|
||||
raise ValueError("manifest images must contain exactly backend and opensearch")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"enabled": false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation.</Description>
|
||||
<URI>\Akyldash\ReleaseAutoApply</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
<Interval>PT5M</Interval>
|
||||
<Duration>P1D</Duration>
|
||||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||||
</Repetition>
|
||||
</CalendarTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Owner">
|
||||
<UserId>REPLACE_WITH_OWNER_SID</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<Enabled>false</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --auto-apply-prepared</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"enabled": false,
|
||||
"repository": "D:\\Dev\\akyldash",
|
||||
"ssh_alias": "akyldash_release"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>One-shot poll for a root-verified Akyldash release. Disabled template; requires owner review.</Description>
|
||||
<URI>\Akyldash\ReleaseTrigger</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
<Interval>PT5M</Interval>
|
||||
<Duration>P1D</Duration>
|
||||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||||
</Repetition>
|
||||
</CalendarTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Owner">
|
||||
<UserId>REPLACE_WITH_OWNER_SID</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<Enabled>false</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --once</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Harden the fixed Windows Python process before loading the release receiver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import ntpath
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PYTHON_EXE = Path(r"C:\Python313\python.exe")
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
WINDOWS_TOOLS = {
|
||||
"ssh.exe": Path(r"C:\Windows\System32\OpenSSH\ssh.exe"),
|
||||
"git.exe": Path(r"C:\Program Files\Git\cmd\git.exe"),
|
||||
"powershell.exe": Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
|
||||
}
|
||||
WINDOWS_PATH = os.pathsep.join((
|
||||
r"C:\Windows\System32\OpenSSH",
|
||||
r"C:\Program Files\Git\cmd",
|
||||
r"C:\Windows\System32",
|
||||
r"C:\Windows",
|
||||
))
|
||||
|
||||
|
||||
def configure_windows_tool_environment(*, environ=None, which=shutil.which,
|
||||
windows_root: Path = Path(r"C:\Windows")) -> None:
|
||||
"""Remove inherited command lookup and Git overrides before receiver imports."""
|
||||
environment = os.environ if environ is None else environ
|
||||
system_root = str(windows_root)
|
||||
environment["SystemRoot"] = system_root
|
||||
environment["PATH"] = WINDOWS_PATH
|
||||
environment["PATHEXT"] = ".EXE"
|
||||
environment["PSModulePath"] = ntpath.join(
|
||||
str(windows_root), "System32", "WindowsPowerShell", "v1.0", "Modules")
|
||||
for name in tuple(environment):
|
||||
if name.upper().startswith("GIT_"):
|
||||
del environment[name]
|
||||
for command, expected in WINDOWS_TOOLS.items():
|
||||
resolved = which(command)
|
||||
if resolved is None or os.path.normcase(os.path.abspath(resolved)) != os.path.normcase(os.path.abspath(expected)):
|
||||
raise RuntimeError(f"required Windows tool is missing from its protected path: {command}")
|
||||
|
||||
|
||||
def _is_beneath(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def configure_import_path(*, executable: Path, app_dir: Path, python_root: Path,
|
||||
path_entries: list[str] | None = None,
|
||||
expected_executable: Path = PYTHON_EXE,
|
||||
protected_app_root: Path = Path(r"C:\Program Files")) -> None:
|
||||
"""Keep only stdlib paths and the protected app directory; discard CWD/env paths."""
|
||||
executable = Path(executable).resolve()
|
||||
app_dir = Path(app_dir).resolve()
|
||||
python_root = Path(python_root).resolve()
|
||||
if os.path.normcase(str(executable)) != os.path.normcase(str(expected_executable.resolve())):
|
||||
raise RuntimeError("release trigger must use the verified Python 3.13 installation")
|
||||
if not app_dir.is_dir() or not _is_beneath(app_dir, protected_app_root):
|
||||
raise RuntimeError("release receiver directory is outside protected Program Files")
|
||||
if not python_root.is_dir() or not _is_beneath(executable, python_root):
|
||||
raise RuntimeError("Python executable is outside its protected installation")
|
||||
entries = sys.path if path_entries is None else path_entries
|
||||
safe_stdlib = []
|
||||
for value in entries:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value)
|
||||
if _is_beneath(candidate, python_root):
|
||||
safe_stdlib.append(str(candidate))
|
||||
if not safe_stdlib:
|
||||
raise RuntimeError("Python standard library path is unavailable")
|
||||
sys.path[:] = safe_stdlib + [str(app_dir)]
|
||||
|
||||
|
||||
def _verify_module_origin(module, root: Path) -> None:
|
||||
origin = getattr(module, "__file__", None)
|
||||
if not isinstance(origin, str) or not _is_beneath(Path(origin), root):
|
||||
raise RuntimeError(f"release module was loaded outside its protected directory: {module.__name__}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flags = sys.flags
|
||||
if not (flags.ignore_environment and flags.no_user_site and flags.no_site):
|
||||
raise RuntimeError("launch Python with -E -s -S to disable environment and site imports")
|
||||
configure_import_path(executable=Path(sys.executable), app_dir=APP_DIR,
|
||||
python_root=PYTHON_EXE.parent)
|
||||
if os.name == "nt":
|
||||
configure_windows_tool_environment()
|
||||
for name in ("json", "ssl", "subprocess", "pathlib"):
|
||||
module = importlib.import_module(name)
|
||||
_verify_module_origin(module, PYTHON_EXE.parent)
|
||||
receiver = importlib.import_module("receive_external")
|
||||
_verify_module_origin(receiver, APP_DIR)
|
||||
for name in ("archive", "external_session"):
|
||||
_verify_module_origin(importlib.import_module(name), APP_DIR)
|
||||
return receiver.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,50 +2,42 @@ const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const artifacts = path.join(repo, 'docs/product/visual-review');
|
||||
const artifacts = process.env.VISUAL_REVIEW_ARTIFACTS || path.join(repo, 'docs/product/visual-review');
|
||||
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
|
||||
const root = path.join(repo, 'backend/search/static/fonts');
|
||||
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
|
||||
fs.mkdirSync(artifacts, {recursive: true});
|
||||
const docs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
|
||||
];
|
||||
const reviewDocs = [
|
||||
{code:'review-a',name:'Fixture document A',type:'fixture',status:'active',edition:'2026'},
|
||||
{code:'review-b',name:'Fixture document B',type:'fixture',status:'active',edition:'2025'}
|
||||
];
|
||||
const fixtureState = {users:[{username:'fixture-user',role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
let failNextEdition = false; let delayNextEdition = false; let failReviewDocumentB = false; let delayReviewDocumentA = false;
|
||||
let fixtureAccount = passwordChangeAccount;
|
||||
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[],lastReviewSubmission:null,rejectNextReviewSave:false};
|
||||
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
|
||||
(async()=>{
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
|
||||
const page = await browser.newPage({viewport:{width:1440,height:800},deviceScaleFactor:1});
|
||||
await page.addInitScript(()=>{if(!sessionStorage.getItem('visual-review-splitter-reset')){localStorage.removeItem('akyldash-search-results-share');sessionStorage.setItem('visual-review-splitter-reset','1');}});
|
||||
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/*', async route=>{
|
||||
const req=route.request(), url=new URL(req.url());
|
||||
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
|
||||
const p=url.pathname;
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}});
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
|
||||
if(p==='/search/filters') return route.fulfill({json:catalogs});
|
||||
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:{username:'admin',role:'admin',created_at:1780000000,must_change_password:false}}}); }
|
||||
if(p==='/search-reviews' && req.method()==='POST') { const submission=req.postDataJSON(); fixtureState.reviewSaves++; fixtureState.lastReviewSubmission=submission; fixtureState.lastReviewRatings=submission.results.map((result)=>({code:result.code,rating:result.rating})); if(fixtureState.rejectNextReviewSave){fixtureState.rejectNextReviewSave=false;return route.fulfill({status:400,json:{error:'Fixture review save error'}});} return route.fulfill({status:201,json:{id:42}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
|
||||
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
|
||||
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
|
||||
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
|
||||
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
|
||||
if(resetMatch && req.method()==='POST') { fixtureState.passwordResets++; const username=decodeURIComponent(resetMatch[1]); const user=fixtureState.users.find((candidate)=>candidate.username===username); if(user) user.must_change_password=true; return route.fulfill({json:{username}}); }
|
||||
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
|
||||
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
|
||||
if(p==='/search') {
|
||||
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}});
|
||||
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} if(url.searchParams.get('q')==='empty-review-fixture') return route.fulfill({json:{results:[],has_next:false,review_token:'fixture-signed-empty-snapshot-token'}}); return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
|
||||
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
|
||||
}
|
||||
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
|
||||
if(p==='/documents/doc-3/editions') return route.fulfill({status:503,json:{error:'Fixture editions error'}});
|
||||
if(p==='/documents/doc-1/editions') return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}});
|
||||
if(p==='/documents/doc-1/editions') { if(delayNextPublicMetadata){delayNextPublicMetadata=false;await new Promise(resolve=>setTimeout(resolve,250));} return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}}); }
|
||||
if(p==='/documents/doc-1/editions/2023') return route.fulfill({json:{content:{ru:{html:'<h2>Russian-only edition</h2><p>Regression fixture.</p>'}}}});
|
||||
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:article}}}}); }
|
||||
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:articleKy}}}}); }
|
||||
if(p==='/documents/review-a/editions/2026') { if(delayReviewDocumentA){delayReviewDocumentA=false;await new Promise(resolve=>setTimeout(resolve,350));} return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document A</h2><p>Content for fixture A only.</p>'}}}}); }
|
||||
if(p==='/documents/review-b/editions/2025') { if(failReviewDocumentB) return route.fulfill({status:503,json:{error:'Fixture document B unavailable'}}); return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document B</h2><p>Content for fixture B only.</p>'}}}}); }
|
||||
if(p==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
|
||||
@@ -53,21 +45,31 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
|
||||
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
|
||||
});
|
||||
await page.goto('https://fixture.invalid/'); await page.locator('#search-results-heading').waitFor();
|
||||
await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36');
|
||||
const freshSplitter=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect(),tracks=getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ');return {filters:r('#search-filters').width,results:r('.search__results').width,gutter:parseFloat(tracks[2]),document:r('#public-document-panel').width,brand:r('.search__brand').width,descriptor:getComputedStyle(document.querySelector('.search__brand-description')).display,brandLabel:document.querySelector('.search__brand').getAttribute('aria-label')};});
|
||||
await page.setViewportSize({width:1280,height:800});
|
||||
const initialFooterCount=await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length); const initialSearch=await page.evaluate(()=>({state:document.querySelector('#public-results').dataset.state,list:document.querySelector('#public-results').textContent.trim(),count:document.querySelector('#result-count').textContent,paginationHidden:document.querySelector('.search__pagination').hidden,breadcrumbHidden:document.querySelector('.search__breadcrumbs').hidden,documentText:document.querySelector('#document-facts').textContent,headingHidden:getComputedStyle(document.querySelector('#public-document-heading')).display==='none',opened:document.querySelectorAll('.search__opened').length}));
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
const labNavColor=await page.locator('#search-review-nav').evaluate(el=>getComputedStyle(el).color);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
const tocState=await page.evaluate(()=>{const root=document.querySelector('#document-toc-list'),links=[...root.querySelectorAll('a')];return {titles:links.map(link=>link.textContent),nested:root.querySelector('ol ol')!==null,targetsUnique:new Set(links.map(link=>link.hash)).size===links.length,anchorsExist:links.every(link=>document.querySelector(link.hash)),ordinaryMentionIncluded:links.some(link=>link.textContent.includes('обычное упоминание')),languageSelectorHidden:document.querySelector('.search__document-language').hidden,splitter:{role:document.querySelector('#search-splitter').getAttribute('role'),orientation:document.querySelector('#search-splitter').getAttribute('aria-orientation'),value:document.querySelector('#search-splitter').getAttribute('aria-valuenow')}};});
|
||||
const safeLinks=await page.evaluate(()=>{const body=document.querySelector('#public-document-body'),official=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Official')),privateLink=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Private address'));return {relative:body.querySelector('a[href*=old]')?.getAttribute('href')||null,legacy:[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Legacy'))?.getAttribute('href')||null,official:official?.href||null,ping:official?.getAttribute('ping')||null,privateHref:privateLink?.getAttribute('href')||null,image:body.querySelector('img')?.getAttribute('src')||null,srcset:body.querySelector('img')?.getAttribute('srcset')||null,privateImage:[...body.querySelectorAll('img')].find(image=>image.alt==='Private address image')?.getAttribute('src')||null};});
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('#document-find').fill('налог на прибыль');
|
||||
await page.waitForTimeout(200);
|
||||
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:0;padding-inline-start:12px;border-inline-start:1px solid #ded5cd;color:#786469;font-size:13px}' });
|
||||
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('#active-filters').after(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
|
||||
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const label=document.querySelector('.search__fixture-label'),range=document.createRange();range.selectNodeContents(label);const text=range.getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),language:box('.search__language-chip'),status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})(),demoText:{left:text.left,right:text.right}}}); console.log('navGeometry',navGeometry); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
|
||||
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#document-find').fill('налог на прибыль'); await page.locator('#document-find-next').click(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>({open:d.open,reviewVisible:!d.querySelector('#review-nav').hidden,usersVisible:!d.querySelector('#search-users-open').hidden,logoutVisible:!d.querySelector('#search-logout').hidden})); await page.locator('#account-dialog').evaluate(d=>d.close()); await page.locator('#public-query').fill('not-found'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#result-count').textContent.startsWith('0 ')); await page.locator('#ui-ky').click(); const kyrgyzEmpty=await page.evaluate(()=>({count:document.querySelector('#result-count').textContent,heading:document.querySelector('#public-results .review__empty strong').textContent})); await page.locator('#ui-ru').click(); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active'); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.evaluate(()=>document.fonts.ready); await page.locator('#document-find').fill('налог на прибыль'); await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:12px;padding:5px 10px;border:1px solid #A37B32;border-radius:6px;color:#786469;font-size:14px}'}); await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('.search__subnav').append(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false;document.querySelector('#public-docuLine truncated
|
||||
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const language=document.querySelector('.search__filter-language');const select=language.querySelector('select');const r=language.getBoundingClientRect();const filterHeader=document.querySelector('.search__filters .review__panel-heading').getBoundingClientRect(),filterHeading=document.querySelector('#filters-heading').getBoundingClientRect(),filterReset=document.querySelector('#clear-filters').getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),lab:box('#search-review-nav'),labNavVisible:!document.querySelector('#search-review-nav').hidden,filterHeadingSameRow:filterHeading.top<filterReset.bottom&&filterHeading.bottom>filterReset.top,filterLanguageGap:Math.round(language.getBoundingClientRect().top-filterHeader.bottom),splitterWidth:document.querySelector('#search-splitter').getBoundingClientRect().width,filterHeadingSize:parseFloat(getComputedStyle(document.querySelector('#filters-heading')).fontSize),resultsHeadingSize:parseFloat(getComputedStyle(document.querySelector('#search-results-heading')).fontSize),languageInFilters:document.querySelector('#search-filters').contains(language),languageWidth:r.width,languageSelectWidth:select.getBoundingClientRect().width,searchTextAlign:getComputedStyle(document.querySelector('#search-nav')).justifyContent,status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})()}}); console.log('navGeometry',navGeometry); const documentTitleStyle=await page.locator('#public-document-heading').evaluate(el=>({fontSize:parseFloat(getComputedStyle(el).fontSize),lineHeight:parseFloat(getComputedStyle(el).lineHeight),fontFamily:getComputedStyle(el).fontFamily})); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
|
||||
const splitterDefault=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect();return {filters:r('#search-filters').width,results:r('.search__results').width,document:r('#public-document-panel').width};}); const splitter=page.locator('#search-splitter'); const splitterStart=Number(await splitter.getAttribute('aria-valuenow')); await splitter.focus(); await page.keyboard.press('ArrowRight'); const splitterKeyboard=Number(await splitter.getAttribute('aria-valuenow')); const keyboardMin=Number(await splitter.getAttribute('aria-valuemin')),keyboardMax=Number(await splitter.getAttribute('aria-valuemax')); await page.keyboard.press('Home'); const splitterHome=Number(await splitter.getAttribute('aria-valuenow')); await page.keyboard.press('End'); const splitterEnd=Number(await splitter.getAttribute('aria-valuenow')); const drag=async x=>{const box=await splitter.boundingBox();await page.mouse.move(box.x+6,box.y+box.height/2);await page.mouse.down();await page.mouse.move(x,box.y+box.height/2,{steps:5});await page.mouse.up();}; await drag(0); const splitterLeft=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await drag(1280); const splitterRight=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const splitterPointer=splitterLeft.value; const splitterStored=await page.evaluate(()=>localStorage.getItem('akyldash-search-results-share'));
|
||||
delayNextPublicMetadata=true; const delayedPublicRequest=page.waitForRequest(request=>new URL(request.url()).pathname==='/documents/doc-1/editions'); await page.locator('.search__result-title').first().click(); await delayedPublicRequest; await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.waitForTimeout(300); const stalePublicSelection=await page.evaluate(()=>({selected:document.querySelector('.search__result--selected').dataset.code,markers:document.querySelectorAll('.search__opened').length})); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); const markerAfterReload=await page.locator('.search__opened').count();
|
||||
await page.evaluate(()=>{localStorage.setItem('akyldash-search-results-share','36');}); await page.reload(); await page.locator('#search-results-heading').waitFor(); await page.locator('.search__result-title').first().waitFor(); const splitterRestored=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin')),max:Number(el.getAttribute('aria-valuemax'))})); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','55')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMax=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),max:Number(el.getAttribute('aria-valuemax'))})); await page.setViewportSize({width:1440,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); const splitterResize=await page.evaluate(()=>({results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await page.setViewportSize({width:1280,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','25')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMin=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin'))}));
|
||||
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); const findClearedAfterFilterChange=!(await page.locator('#document-find-next').isVisible()); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-find').fill('налог на прибыль'); await page.waitForFunction(()=>!document.querySelector('#document-find-next').disabled); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>{const close=d.querySelector('.review__dialog-heading button'),profile=d.querySelector('dl');return {open:d.open,heading:d.querySelector('#account-heading').textContent,reviewVisible:!d.querySelector('#review-nav').hidden,reviewHref:d.querySelector('#review-nav').getAttribute('href'),usersVisible:!d.querySelector('#search-users-open').hidden,usersHref:d.querySelector('#search-users-open').getAttribute('href'),usersLabel:d.querySelector('#search-users-open').textContent,logoutVisible:!d.querySelector('#search-logout').hidden,closeIcon:Boolean(close.querySelector('svg')),closeText:close.textContent.trim(),closeLabel:close.getAttribute('aria-label'),profileGrid:getComputedStyle(profile).display,profileRows:profile.children.length,headingHeight:d.querySelector('.review__dialog-heading').getBoundingClientRect().height,headingFontSize:parseFloat(getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontSize),headingFontFamily:getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontFamily,dataBeforeActions:Boolean(profile.compareDocumentPosition(d.querySelector('.search__account-actions'))&Node.DOCUMENT_POSITION_FOLLOWING),dialogFits:d.scrollWidth<=d.clientWidth};}); const accountGeometry=await page.locator('#account-dialog').evaluate(d=>{const actions=d.querySelector('.search__account-actions'),logout=d.querySelector('#search-logout'),first=actions.querySelector('a').getBoundingClientRect(),out=logout.getBoundingClientRect();return {width:d.getBoundingClientRect().width,logoutSeparate:out.top>=first.bottom,logoutMuted:getComputedStyle(logout).backgroundColor==='rgba(0, 0Line truncated
|
||||
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
|
||||
const widths={}; for(const width of [1440,1280,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
|
||||
const widths={}; for(const width of [1440,1280,1024,1713]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.reload(); await page.evaluate(()=>document.fonts.ready);
|
||||
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
|
||||
@@ -79,13 +81,39 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
|
||||
await page.locator('#document-back').click();
|
||||
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
|
||||
await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible()}; const labLayout=await page.evaluate(()=>{const panel=document.querySelector('#document-panel'),body=document.querySelector('#document-body');for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный текст длинной фиктивной редакции для проверки прокрутки панели документа. '.repeat(4);body.append(p);}const save=document.querySelector('#save'),footer=document.querySelector('body > footer.review__header'),r=panel.getBoundingClientRect();return {initialSaveDisabled:save.disabled,initialSaveText:save.textContent,initialDraft:document.querySelector('#draft-status').textContent,panelHeight:r.height,panelClient:panel.clientHeight,panelScroll:panel.scrollHeight,viewport:innerHeight,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
|
||||
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},target);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
|
||||
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); await page.locator('#search-form button[type=submit]').click(); await page.locator('.review__result').first().waitFor(); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,reviewSaved:await page.locator('#status').textContent()};
|
||||
await page.setViewportSize({width:1440,height:900}); await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible(),visibleFooters:await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length)}; const labLayout=await page.evaluate(()=>{const panel=document.querySelector('#document-panel'),body=document.querySelector('#document-body');for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный текст длинной фиктивной редакции для проверки прокрутки панели документа. '.repeat(4);body.append(p);}const save=document.querySelector('#save'),footer=document.querySelector('body > footer.review__header'),r=panel.getBoundingClientRect();return {initialSaveDisabled:save.disabled,initialSaveText:save.textContent,initialDraft:document.querySelector('#draft-status').textContent,panelHeight:r.height,panelClient:panel.clientHeight,panelScroll:panel.scrollHeight,viewport:innerHeight,navHeight:document.querySelector('body > header.review__header .review__nav').getBoundingClientRect().height,titlebarHeight:document.querySelector('body > header.review__header .review__header-content').getBoundingClientRect().height,actionsHeight:document.querySelector('body > .review__actions').getBoundingClientRect().height,footerHeight:document.querySelector('body > footer.review__header').getBoundingClientRect().height,labTitleSize:parseFloat(getComputedStyle(document.querySelector('#route-heading')).fontSize),sectionHeadingSize:parseFloat(getComputedStyle(document.querySelector('.review__results .review__panel-heading h2')).fontSize),headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
|
||||
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
|
||||
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},selector);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}: ${JSON.stringify(geometry)}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
|
||||
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
|
||||
const nonEmptyReviewSubmission=fixtureState.lastReviewSubmission;
|
||||
if(!nonEmptyReviewSubmission||nonEmptyReviewSubmission.results.length!==2||nonEmptyReviewSubmission.review_token!=='fixture-review-token') throw new Error(`Non-empty review flow changed: ${JSON.stringify(nonEmptyReviewSubmission)}`);
|
||||
await page.locator('#query').fill('empty-review-fixture');
|
||||
await page.locator('#search-form button[type=submit]').click();
|
||||
await page.locator('.review__empty').waitFor();
|
||||
const emptyPrompt=await page.evaluate(()=>({buttonText:document.querySelector('#save').textContent,buttonEnabled:!document.querySelector('#save').disabled,ariaRequired:document.querySelector('#overall-comment').getAttribute('aria-required'),placeholder:document.querySelector('#overall-comment').placeholder,emptyState:document.querySelector('#results').textContent.includes('Результатов пока нет')}));
|
||||
await page.screenshot({path:path.join(artifacts,'empty-review-required-comment.png'),fullPage:false});
|
||||
const attemptsBeforeInvalid=fixtureState.reviewSaves;
|
||||
await page.locator('#save').focus(); await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Добавьте комментарий к пустой выдаче'));
|
||||
const invalidEmptyComment=await page.evaluate(()=>({focus:document.activeElement.id,message:document.querySelector('#status').textContent,role:document.querySelector('#status').getAttribute('role'),live:document.querySelector('#status').getAttribute('aria-live')}));
|
||||
const invalidRequestCount=fixtureState.reviewSaves-attemptsBeforeInvalid;
|
||||
await page.locator('#overall-comment').fill('Пустая выдача проверена в синтетическом browser fixture.');
|
||||
fixtureState.rejectNextReviewSave=true;
|
||||
await page.locator('#save').focus(); await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Не удалось сохранить'));
|
||||
const failedEmptySubmission=fixtureState.lastReviewSubmission;
|
||||
const failedSaveFeedback=await page.locator('#status').evaluate(el=>({message:el.textContent,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
|
||||
await page.locator('#save').focus(); await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
|
||||
const emptyReviewSubmission=fixtureState.lastReviewSubmission;
|
||||
const emptyReviewFlow={emptyPrompt,invalidEmptyComment,invalidRequestCount,failedSaveFeedback,failedSubmission:failedEmptySubmission,savedSubmission:emptyReviewSubmission,feedback:await page.locator('#status').textContent()};
|
||||
await page.screenshot({path:path.join(artifacts,'empty-review-saved.png'),fullPage:false});
|
||||
console.log('emptyReviewFlow',JSON.stringify(emptyReviewFlow));
|
||||
if(!emptyPrompt.buttonEnabled||emptyPrompt.buttonText!=='Сохранить пустую выдачу'||emptyPrompt.ariaRequired!=='true'||!emptyPrompt.placeholder.includes('обязательно')||!emptyPrompt.emptyState||invalidEmptyComment.focus!=='overall-comment'||invalidEmptyComment.role!=='alert'||invalidRequestCount!==0||failedSaveFeedback.role!=='alert'||failedSaveFeedback.live!=='assertive'||failedEmptySubmission.review_token!=='fixture-signed-empty-snapshot-token'||failedEmptySubmission.results.length!==0||!failedEmptySubmission.overall_comment||emptyReviewSubmission.review_token!=='fixture-signed-empty-snapshot-token'||emptyReviewSubmission.results.length!==0||emptyReviewSubmission.overall_comment!=='Пустая выдача проверена в синтетическом browser fixture.'||!emptyReviewFlow.feedback.includes('Оценка сохранена')) throw new Error(`Empty review browser contract failed: ${JSON.stringify(emptyReviewFlow)}`);
|
||||
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
|
||||
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
|
||||
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();
|
||||
failReviewDocumentB=true; await page.locator('.review__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#status').textContent==='Не удалось загрузить документ. Повторите попытку.');
|
||||
failReviewDocumentB=true; await page.locator('.review__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#status').textContent==='Не удалось загрузить документ. Повторите попытку.'); const errorStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
|
||||
const failedDocumentB=await page.evaluate(()=>({selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent,heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,dialogBody:document.querySelector('#dialog-body').textContent,sourceCount:document.querySelectorAll('#document-meta a').length,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
failReviewDocumentB=false; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor();
|
||||
const successfulDocumentB=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
@@ -95,7 +123,7 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
const documentSwitch={ratingBeforeDocumentSwitch,failedDocumentB,successfulDocumentB,staleDocumentA,lastReviewRatings:fixtureState.lastReviewRatings,reviewSaves:fixtureState.reviewSaves}; console.log('documentSwitchFixture',JSON.stringify(documentSwitch));
|
||||
if(!ratingBeforeDocumentSwitch||failedDocumentB.selected!=='Fixture document B'||failedDocumentB.heading!=='Fixture document B'||failedDocumentB.body||failedDocumentB.dialogBody||failedDocumentB.sourceCount||failedDocumentB.rating!=='2'||failedDocumentB.comment!=='Fixture-only note for result A.'||successfulDocumentB.heading!=='Fixture document B'||!successfulDocumentB.body.includes('Content for fixture B only.')||!successfulDocumentB.source.includes('/review-b/edition/2025/ru')||successfulDocumentB.rating!=='2'||successfulDocumentB.comment!=='Fixture-only note for result A.'||staleDocumentA.heading!=='Fixture document B'||!staleDocumentA.body.includes('Content for fixture B only.')||!staleDocumentA.source.includes('/review-b/edition/2025/ru')||staleDocumentA.selected!=='Fixture document B'||!fixtureState.lastReviewRatings.some((item)=>item.code==='review-a'&&item.rating===2)||fixtureState.reviewSaves<2) process.exit(1);
|
||||
await page.setViewportSize({width:1280,height:800}); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent()};
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent(),selectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditionExpandedText=await page.locator('#public-document-body').textContent(); const noEditionExpandedHeading=await page.locator('#public-document-heading').textContent(); const noEditionExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); const editionErrorExpandedText=await page.locator('#public-document-body').textContent(); const editionErrorExpandedHeading=await page.locator('#public-document-heading').textContent(); const editionErrorExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
@@ -106,20 +134,18 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
|
||||
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
|
||||
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')}; await page.locator('#public-query').fill('налог на прибыль'); await page.locator('#public-search-form button[type=submit]').click(); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}));
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')}))};
|
||||
await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length}));
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})),languageSelectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
|
||||
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.setViewportSize({width:1280,height:800});
|
||||
await page.locator('#search-account-open').click(); await page.evaluate(()=>{const d=document.querySelector('#account-dialog');const controls=[...d.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e=>e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden');controls.at(-1).focus();}); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>({id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape');
|
||||
const viewportWidths=[]; for (const width of [320,390,640,1024,1280,1440]) { await page.setViewportSize({width,height:800}); viewportWidths.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth}))); } await page.setViewportSize({width:1280,height:800});
|
||||
const breakpointLayouts=[]; for (const width of [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:false}))); }
|
||||
await page.evaluate(()=>{document.querySelector('#search-layout').dataset.filtersHidden='true';});
|
||||
for (const width of [1101,1175,1176]) { await page.setViewportSize({width,height:800}); breakpointLayouts.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').length,filtersHidden:document.querySelector('#search-layout').dataset.filtersHidden==='true'}))); }
|
||||
await page.evaluate(()=>delete document.querySelector('#search-layout').dataset.filtersHidden); await page.setViewportSize({width:1280,height:800});
|
||||
const viewportWidths=[]; for (const width of [320,390,640,1024,1101,1175,1176,1280,1440,1713]) { await page.setViewportSize({width,height:800}); viewportWidths.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth}))); } await page.setViewportSize({width:1280,height:800});
|
||||
const breakpointLayouts=[]; for (const width of [1024,1101,1175,1191]) { await page.setViewportSize({width,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); breakpointLayouts.push(await page.evaluate(()=>{const rect=(selector)=>document.querySelector(selector).getBoundingClientRect();return {width:innerWidth,scrollWidth:document.documentElement.scrollWidth,filtersWidth:rect('#search-filters').width,resultsWidth:rect('.search__results').width,splitterWidth:rect('#search-splitter').width,documentWidth:rect('#public-document-panel').width,filtersOpen:document.querySelector('#search-filters').classList.contains('is-open')}})); }
|
||||
await page.setViewportSize({width:1024,height:800}); await page.locator('#mobile-filters-open').click(); const filterDrawer=await page.evaluate(()=>({open:document.querySelector('#search-filters').classList.contains('is-open'),modal:document.querySelector('#search-filters').getAttribute('aria-modal'),overlay:getComputedStyle(document.querySelector('#filter-overlay')).display!=='none',results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const filterCloseVisible=await page.locator('#mobile-filters-close').isVisible(); await page.locator('#mobile-filters-close').click(); await page.locator('#mobile-filters-open').click(); await page.keyboard.press('Escape'); const filterDrawerEscape=await page.evaluate(()=>({closed:!document.querySelector('#search-filters').classList.contains('is-open'),focus:document.activeElement.id})); await page.setViewportSize({width:1280,height:800});
|
||||
const longContent=await page.evaluate(()=>{const result=document.querySelector('.search__result-title'),heading=document.querySelector('#public-document-heading'),oldResult=result.textContent,oldHeading=heading.textContent;const longName='Кыргызская Республика '.repeat(8);result.textContent=longName;heading.textContent=longName;const image=document.createElement('img');image.width=2200;image.height=100;image.src='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="2200" height="100"><rect width="2200" height="100" fill="gold"/></svg>');document.querySelector('#public-document-body').append(image);const titleWidths=[result,heading].map(el=>({client:el.clientWidth,scroll:el.scrollWidth}));const imageWidth=image.getBoundingClientRect().width,bodyWidth=document.querySelector('#public-document-body').clientWidth,pageWidth=document.documentElement.scrollWidth;result.textContent=oldResult;heading.textContent=oldHeading;image.remove();return {titleWidths,imageWidth,bodyWidth,pageWidth,viewport:innerWidth}});
|
||||
console.log('safeLinks',JSON.stringify(safeLinks)); console.log('viewportWidths',JSON.stringify(viewportWidths)); console.log('breakpointLayouts',JSON.stringify(breakpointLayouts));
|
||||
console.log('safeLinks',JSON.stringify(safeLinks)); console.log('initialSearch',JSON.stringify(initialSearch)); console.log('tocState',JSON.stringify(tocState)); console.log('splitterState',JSON.stringify({splitterStart,splitterKeyboard,keyboardMin,keyboardMax,splitterHome,splitterEnd,splitterLeft,splitterRight,splitterPointer,splitterStored,splitterRestored,splitterStoredMax,splitterStoredMin,splitterResize,stalePublicSelection,markerAfterReload})); console.log('freshSplitter',JSON.stringify(freshSplitter)); console.log('splitterDefault',JSON.stringify(splitterDefault)); console.log('filterDrawer',JSON.stringify({filterDrawer,filterCloseVisible,filterDrawerEscape})); console.log('visualDetails',JSON.stringify({successStatus,errorStatus,labLoadingStatus,compactResult,accountGeometry})); console.log('viewportWidths',JSON.stringify(viewportWidths)); console.log('breakpointLayouts',JSON.stringify(breakpointLayouts));
|
||||
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,noEditionExpandHidden,editionErrorExpandedText,editionErrorExpandedHeading,editionErrorExpandHidden,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens,viewportWidths,breakpointLayouts}));
|
||||
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
|
||||
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,findClearedAfterFilterChange,nextFindCount,documentTitleStyle,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
|
||||
const referencePath=process.env.REFERENCE_IMAGE || path.join(repo, 'docs/design/tunduk-search-target.png');
|
||||
if(fs.existsSync(referencePath)) {
|
||||
const comparePage=await browser.newPage();
|
||||
@@ -154,17 +180,54 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
["safeLinks.privateImage!==null", safeLinks.privateImage!==null],
|
||||
["viewportWidths.some(item=>item.scrollWidth>item.width)", viewportWidths.some(item=>item.scrollWidth>item.width)],
|
||||
["breakpointLayouts.some(item=>item.scrollWidth>item.width)", breakpointLayouts.some(item=>item.scrollWidth>item.width)],
|
||||
["visible layouts do not switch before 1176px", breakpointLayouts.slice(0,3).some((item,index)=>item.columns!==(index===2?3:2))],
|
||||
["hidden filters are lost during resize", breakpointLayouts.slice(3).some(item=>item.columns!==2||!item.filtersHidden)],
|
||||
["compact filters stay behind the drawer while both panels fit", breakpointLayouts.some(item=>item.filtersWidth!==0||item.resultsWidth<360||item.splitterWidth!==0||item.documentWidth<560)],
|
||||
["compact filter drawer preserves access without replacing the two content panels", !filterDrawer.open||filterDrawer.modal!=="true"||!filterDrawer.overlay||filterDrawer.results<360||filterDrawer.document<560||!filterCloseVisible||!filterDrawerEscape.closed||filterDrawerEscape.focus!=='mobile-filters-open'],
|
||||
["resultHighlight!=='\u043d\u0430\u043b\u043e\u0433\u0435 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", resultHighlight!=='налоге на прибыль'],
|
||||
["navGeometry.search.left!==26", navGeometry.search.left!==26],
|
||||
["navGeometry.search.right!==123", navGeometry.search.right!==123],
|
||||
["navGeometry.sources.left!==146", navGeometry.sources.left!==146],
|
||||
["Math.abs(navGeometry.sources.right-244)>3", Math.abs(navGeometry.sources.right-244)>3],
|
||||
["Math.abs(navGeometry.language.left-284)>2", Math.abs(navGeometry.language.left-284)>2],
|
||||
["Math.abs(navGeometry.language.right-372)>2", Math.abs(navGeometry.language.right-372)>2],
|
||||
["navGeometry.status.left!==381", navGeometry.status.left!==381],
|
||||
["Math.abs(navGeometry.demoText.left-510)>3", Math.abs(navGeometry.demoText.left-510)>3],
|
||||
["initialSearch.state!=='initial'", initialSearch.state!=='initial'],
|
||||
["initialSearch.count!==''", initialSearch.count!=='' ],
|
||||
["!initialSearch.paginationHidden", !initialSearch.paginationHidden],
|
||||
["!initialSearch.breadcrumbHidden", !initialSearch.breadcrumbHidden],
|
||||
["initialSearch.opened!==0", initialSearch.opened!==0],
|
||||
["!initialSearch.headingHidden", !initialSearch.headingHidden],
|
||||
["!tocState.nested", !tocState.nested],
|
||||
["documentTitleStyle.fontSize!==30", documentTitleStyle.fontSize!==30],
|
||||
["documentTitleStyle.lineHeight!==38", documentTitleStyle.lineHeight!==38],
|
||||
["!documentTitleStyle.fontFamily.includes('Noto Serif')", !documentTitleStyle.fontFamily.includes('Noto Serif')],
|
||||
["!tocState.targetsUnique", !tocState.targetsUnique],
|
||||
["!tocState.anchorsExist", !tocState.anchorsExist],
|
||||
["tocState.ordinaryMentionIncluded", tocState.ordinaryMentionIncluded],
|
||||
["tocState.languageSelectorHidden", tocState.languageSelectorHidden],
|
||||
["tocState.splitter.role!=='separator'", tocState.splitter.role!=='separator'],
|
||||
["navGeometry.languageInFilters!==true", navGeometry.languageInFilters!==true],
|
||||
["!navGeometry.labNavVisible", !navGeometry.labNavVisible],
|
||||
["labNavColor!=='rgb(157, 38, 51)'", labNavColor!=='rgb(157, 38, 51)'],
|
||||
["!navGeometry.filterHeadingSameRow", !navGeometry.filterHeadingSameRow],
|
||||
["navGeometry.filterLanguageGap>10", navGeometry.filterLanguageGap>10],
|
||||
["navGeometry.splitterWidth!==12", navGeometry.splitterWidth!==12],
|
||||
["navGeometry.filterHeadingSize!==20", navGeometry.filterHeadingSize!==20],
|
||||
["navGeometry.resultsHeadingSize!==20", navGeometry.resultsHeadingSize!==20],
|
||||
["navGeometry.languageSelectWidth<120", navGeometry.languageSelectWidth<120],
|
||||
["navGeometry.searchTextAlign!=='center'", navGeometry.searchTextAlign!=='center'],
|
||||
["splitterKeyboard<=splitterStart", splitterKeyboard<=splitterStart],
|
||||
["splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1", splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1],
|
||||
["successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success", successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success],
|
||||
["errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'", errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'],
|
||||
["labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy", labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy],
|
||||
["compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44", compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44],
|
||||
["accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted", accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted],
|
||||
["splitterHome!==keyboardMin", splitterHome!==keyboardMin],
|
||||
["splitterEnd!==keyboardMax", splitterEnd!==keyboardMax],
|
||||
["splitterPointer<25||splitterPointer>55", splitterPointer<25||splitterPointer>55],
|
||||
["splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256", splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256],
|
||||
["splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256", splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256],
|
||||
["Number(splitterStored)!==splitterRight.value", Number(splitterStored)!==splitterRight.value],
|
||||
["splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max", splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max],
|
||||
["splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min", splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min],
|
||||
["splitterResize.results<360||splitterResize.document<560", splitterResize.results<360||splitterResize.document<560],
|
||||
["freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'", freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'],
|
||||
["stalePublicSelection.selected!=='doc-3'", stalePublicSelection.selected!=='doc-3'],
|
||||
["stalePublicSelection.markers!==0", stalePublicSelection.markers!==0],
|
||||
["markerAfterReload!==1", markerAfterReload!==1],
|
||||
["errors.length", errors.length],
|
||||
["!fonts.sans", !fonts.sans],
|
||||
["!fonts.serif", !fonts.serif],
|
||||
@@ -189,7 +252,8 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
["preservedFilters.documentType!=='code'", preservedFilters.documentType!=='code'],
|
||||
["urlFilters.status!==null", urlFilters.status!==null],
|
||||
["urlFilters.documentType!==null", urlFilters.documentType!==null],
|
||||
["firstFindCount!=='1 \u0438\u0437 3'", firstFindCount!=='1 из 3'],
|
||||
["firstFindCount!==''", firstFindCount!==''],
|
||||
["!findClearedAfterFilterChange", !findClearedAfterFilterChange],
|
||||
["nextFindCount!=='2 \u0438\u0437 3'", nextFindCount!=='2 из 3'],
|
||||
["documentLanguage!=='ru'", documentLanguage!=='ru'],
|
||||
["kyrgyzUi.htmlLang!=='ky'", kyrgyzUi.htmlLang!=='ky'],
|
||||
@@ -204,18 +268,33 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
["kyrgyzUi.findCount!=='2 \u0438\u0447\u0438\u043d\u0435\u043d 3'", kyrgyzUi.findCount!=='2 ичинен 3'],
|
||||
["kyrgyzUi.opened!=='\u0410\u0447\u044b\u043b\u0434\u044b'", kyrgyzUi.opened!=='Ачылды'],
|
||||
["kyrgyzEmpty.count!=='0 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzEmpty.count!=='0 натыйжа бетте'],
|
||||
["kyrgyzEmpty.heading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.heading!=='Документтер табылган жок'],
|
||||
["kyrgyzEmpty.message!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.message!=='Документтер табылган жок'],
|
||||
["!expanded", !expanded],
|
||||
["!expandedEscapeClosed", !expandedEscapeClosed],
|
||||
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
|
||||
["!accountActions.open", !accountActions.open],
|
||||
["!accountActions.reviewVisible", !accountActions.reviewVisible],
|
||||
["!accountActions.heading", !accountActions.heading],
|
||||
["accountActions.reviewVisible", accountActions.reviewVisible],
|
||||
["accountActions.reviewHref!=='/review'", accountActions.reviewHref!=='/review'],
|
||||
["!accountActions.usersVisible", !accountActions.usersVisible],
|
||||
["accountActions.usersHref!=='/users'", accountActions.usersHref!=='/users'],
|
||||
["!accountActions.usersLabel.includes('Управление пользователями')", !accountActions.usersLabel.includes('Управление пользователями')],
|
||||
["!accountActions.logoutVisible", !accountActions.logoutVisible],
|
||||
["desktopFilters.visible!==true", desktopFilters.visible!==true],
|
||||
["!accountActions.closeIcon", !accountActions.closeIcon],
|
||||
["accountActions.closeText!==''", accountActions.closeText!=='' ],
|
||||
["accountActions.closeLabel!=='Закрыть'", accountActions.closeLabel!=='Закрыть'],
|
||||
["accountActions.profileGrid!=='grid'", accountActions.profileGrid!=='grid'],
|
||||
["accountActions.profileRows!==6", accountActions.profileRows!==6],
|
||||
["accountActions.headingHeight!==44", accountActions.headingHeight!==44],
|
||||
["accountActions.headingFontSize!==20", accountActions.headingFontSize!==20],
|
||||
["!accountActions.headingFontFamily.includes('Noto Sans')", !accountActions.headingFontFamily.includes('Noto Sans')],
|
||||
["!accountActions.dataBeforeActions", !accountActions.dataBeforeActions],
|
||||
["!accountActions.dialogFits", !accountActions.dialogFits],
|
||||
["desktopFilters.visible!==false", desktopFilters.visible!==false],
|
||||
["desktopFilters.ariaHidden!==null", desktopFilters.ariaHidden!==null],
|
||||
["routes.searchHidden!==true", routes.searchHidden!==true],
|
||||
["routes.reviewVisible!==true", routes.reviewVisible!==true],
|
||||
["initialFooterCount!==1||routes.visibleFooters!==1", initialFooterCount!==1||routes.visibleFooters!==1],
|
||||
["!sourcesDialog.open", !sourcesDialog.open],
|
||||
["sourcesDialog.labelledBy!=='sources-heading'", sourcesDialog.labelledBy!=='sources-heading'],
|
||||
["!sourcesDialog.focusInside", !sourcesDialog.focusInside],
|
||||
@@ -243,16 +322,21 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
["previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'", previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'],
|
||||
["!sourceDuringEditionLoad.hidden", !sourceDuringEditionLoad.hidden],
|
||||
["sourceDuringEditionLoad.href!==null", sourceDuringEditionLoad.href!==null],
|
||||
["sourceDuringEditionLoad.opened!==0", sourceDuringEditionLoad.opened!==0],
|
||||
["!sourceAfterEditionError.hidden", !sourceAfterEditionError.hidden],
|
||||
["sourceAfterEditionError.href!==null", sourceAfterEditionError.href!==null],
|
||||
["sourceAfterEditionError.opened!==0", sourceAfterEditionError.opened!==0],
|
||||
["sourceAfterEditionRetry.hidden", sourceAfterEditionRetry.hidden],
|
||||
["sourceAfterEditionRetry.opened!==1", sourceAfterEditionRetry.opened!==1],
|
||||
["sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'", sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'],
|
||||
["noEditions.message!=='\u0414\u043b\u044f \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0440\u0435\u0434\u0430\u043a\u0446\u0438\u0439 \u0441 \u0442\u0435\u043a\u0441\u0442\u043e\u043c.'", noEditions.message!=='Для документа нет доступных редакций с текстом.'],
|
||||
["noEditions.bodyChildren!==0", noEditions.bodyChildren!==0],
|
||||
["!noEditions.source.hidden", !noEditions.source.hidden],
|
||||
["noEditions.source.href!==null", noEditions.source.href!==null],
|
||||
["!noEditions.languageSelectorHidden", !noEditions.languageSelectorHidden],
|
||||
["languageFallback.language!=='ru'", languageFallback.language!=='ru'],
|
||||
["languageFallback.heading!=='Russian-only edition'", languageFallback.heading!=='Russian-only edition'],
|
||||
["!languageFallback.selectorHidden", !languageFallback.selectorHidden],
|
||||
["noEditionExpandedText!==''", noEditionExpandedText!==''],
|
||||
["!noEditionExpandedHeading.includes('\u041e \u0432\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0432 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435')", !noEditionExpandedHeading.includes('О введении в действие')],
|
||||
["!noEditionExpandHidden", !noEditionExpandHidden],
|
||||
@@ -264,45 +348,119 @@ let failNextEdition = false; let delayNextEdition = false; let failReviewDocumen
|
||||
["!accountTabTarget.dialog", !accountTabTarget.dialog],
|
||||
["!accountReverseTabWrap.dialog", !accountReverseTabWrap.dialog],
|
||||
["longContent.titleWidths.some(item=>item.scroll>item.client)", longContent.titleWidths.some(item=>item.scroll>item.client)],
|
||||
["longContent.imageWidth>longContent.bodyWidth", longContent.imageWidth>longContent.bodyWidth],
|
||||
["longContent.imageWidth>longContent.bodyWidth+1", longContent.imageWidth>longContent.bodyWidth+1],
|
||||
["longContent.pageWidth>longContent.viewport", longContent.pageWidth>longContent.viewport],
|
||||
["!labInitial.saveDisabled", !labLayout.initialSaveDisabled],
|
||||
["!labLayout.initialSaveText.includes('Сначала выполните поиск')", !labLayout.initialSaveText.includes('Сначала выполните поиск')],
|
||||
["labLayout.panelScroll<=labLayout.panelClient", labLayout.panelScroll<=labLayout.panelClient],
|
||||
["labLayout.panelHeight>labLayout.viewport*0.69", labLayout.panelHeight>labLayout.viewport*0.69],
|
||||
["!labLayout.saveAfterPanel", !labLayout.saveAfterPanel],
|
||||
["!labLayout.footerAfterSave", !labLayout.footerAfterSave],
|
||||
["labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416", labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416],
|
||||
["labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1", labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1],
|
||||
["labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44", labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44],
|
||||
["!labLayout.footerVisible", !labLayout.footerVisible],
|
||||
["labLayout.publicFooterVisible", labLayout.publicFooterVisible],
|
||||
["labLayout.navHeight!==64", labLayout.navHeight!==64],
|
||||
["labLayout.titlebarHeight!==64", labLayout.titlebarHeight!==64],
|
||||
["labLayout.actionsHeight!==96", labLayout.actionsHeight!==96],
|
||||
["labLayout.footerHeight!==44", labLayout.footerHeight!==44],
|
||||
["labLayout.labTitleSize!==26", labLayout.labTitleSize!==26],
|
||||
["labLayout.sectionHeadingSize!==20", labLayout.sectionHeadingSize!==20],
|
||||
["Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1", Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1],
|
||||
["Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1", Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1],
|
||||
["!reviewFlow.reviewSaveEnabled", !reviewFlow.reviewSaveEnabled],
|
||||
["!reviewFlow.commentStartsCollapsed", !reviewFlow.commentStartsCollapsed],
|
||||
["!reviewFlow.commentExpanded", !reviewFlow.commentExpanded],
|
||||
["reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'", reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'],
|
||||
["reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client", reviewFlow.reviewScroll.scroll<=reviewFlow.reviewScroll.client],
|
||||
["reviewFlow.reviewScroll.internalScroll", !reviewFlow.reviewScroll.internalScroll],
|
||||
["reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69", reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69],
|
||||
["!reviewFlow.saveViewport.visible", !reviewFlow.saveViewport.visible],
|
||||
["!reviewFlow.saveViewport.footerVisible", !reviewFlow.saveViewport.footerVisible],
|
||||
["!reviewFlow.reviewScroll.saveAfter", !reviewFlow.reviewScroll.saveAfter],
|
||||
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
|
||||
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
|
||||
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,errors})); if(!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар') process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); await usersPage.waitForFunction(()=>[...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.children[1]?.textContent.trim()==='Колдонуучу')); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); const localizedRoleCells=await usersPage.locator('#user-list .review__user-row').evaluateAll(rows=>rows.map(row=>row.children[1].textContent)); const usersNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,usersNavContract,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар'||!localizedRoleCells.includes('Колдонуучу')||!usersNavContract.headerUsers||usersNavContract.dialogUsers||usersNavContract.dialogLab||!usersNavContract.headerLab) process.exit(1);
|
||||
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
|
||||
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
|
||||
await usersPage.keyboard.press('Escape');
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};});
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};}); const reviewNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); if(!reviewNavContract.headerUsers||reviewNavContract.dialogUsers||reviewNavContract.dialogLab||!reviewNavContract.headerLab) throw new Error(`Review account actions duplicate header navigation: ${JSON.stringify(reviewNavContract)}`);
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
|
||||
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
|
||||
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
|
||||
if(search640.documentWidth>reflowWidth||!search640.accountVisible||!search640.accountWithinViewport||!accountDialog640.open||!accountDialog640.withinViewport||!accountDialog640.actionsVisible||review640.documentWidth>reflowWidth||!review640.accountWithinViewport||!review640.logoutVisible||!review640.logoutWithinViewport||users640.documentWidth>reflowWidth||!users640.accountWithinViewport||!users640.logoutVisible||!users640.logoutWithinViewport||!users640.createVisible||!users640.createWithinViewport) process.exit(1);
|
||||
await usersPage.locator('#create-user-open').click(); await usersPage.locator('#new-username').fill('fixture-created'); await usersPage.locator('#temporary-password').fill('Fixture-only-temporary-password-2026'); await usersPage.locator('#create-user-form button[type="submit"]').click(); await usersPage.waitForFunction(()=>!document.querySelector('#create-user-dialog').open); let createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor();
|
||||
await createdRow.locator('.review__user-reset-disclosure summary').click(); await createdRow.locator('.review__user-reset input[type="password"]').fill('Fixture-only-reset-password-2026'); const resetResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/admin/users/fixture-created/reset-password'); await createdRow.locator('.review__user-reset button[type="submit"]').click(); await resetResponse;
|
||||
await usersPage.locator('#review-account-open').click(); await usersPage.locator('#security-tab').click(); await usersPage.locator('#profile-current-password').fill('Fixture-only-current-password-2026'); await usersPage.locator('#profile-new-password').fill('Fixture-only-new-password-2026'); await usersPage.locator('#profile-confirm-password').fill('Fixture-only-new-password-2026'); const passwordResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/auth/change-password'); await usersPage.locator('#profile-password-form button[type="submit"]').click(); await passwordResponse; await usersPage.waitForFunction(()=>!document.querySelector('#account-dialog').open);
|
||||
await usersPage.locator('#review-account-open').click(); await usersPage.locator('#security-tab').click(); const passwordToggle=usersPage.locator('#profile-password-form .review__password-toggle').first(); const securityCloseCount=await usersPage.locator('#security-panel [data-close="account-dialog"]').count(); await passwordToggle.click(); const passwordShown=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='text'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='true'); await passwordToggle.click(); const passwordHidden=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='password'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='false'); await usersPage.locator('#profile-current-password').fill('Fixture-only-current-password-2026'); await usersPage.locator('#profile-new-password').fill('Fixture-only-new-password-2026'); await usersPage.locator('#profile-confirm-password').fill('Fixture-only-new-password-2026'); const passwordResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/auth/change-password'); await usersPage.locator('#profile-password-form button[type="submit"]').click(); await passwordResponse; await usersPage.waitForFunction(()=>!document.querySelector('#account-dialog').open); if(securityCloseCount!==0||!passwordShown||!passwordHidden) process.exit(1); console.log('accountSecurity',JSON.stringify({securityCloseCount,passwordShown,passwordHidden}));
|
||||
const passwordAccountAfterChange=await usersPage.locator('#profile-username').textContent(); if(passwordAccountAfterChange!==passwordChangeAccount.username||!await usersPage.locator('#users-page').isVisible()) throw new Error('Fixture password change must return the account payload and restore the admin workspace.');
|
||||
createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor(); await createdRow.locator('.review__user-actions > button').last().click(); await usersPage.locator('#delete-user-confirm').click(); await usersPage.waitForFunction(()=>![...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.textContent.includes('fixture-created')));
|
||||
const safeFixtureOperations={passwordChanges:fixtureState.passwordChanges,userCreates:fixtureState.userCreates,passwordResets:fixtureState.passwordResets,userDeletes:fixtureState.userDeletes,reviewSaves:fixtureState.reviewSaves,createdUserRemoved:!fixtureState.users.some((user)=>user.username==='fixture-created')}; console.log('safeFixtureOperations',JSON.stringify(safeFixtureOperations));
|
||||
if(safeFixtureOperations.passwordChanges!==1||safeFixtureOperations.userCreates!==1||safeFixtureOperations.passwordResets!==1||safeFixtureOperations.userDeletes!==1||safeFixtureOperations.reviewSaves<2||!safeFixtureOperations.createdUserRemoved) process.exit(1);
|
||||
|
||||
const layoutRegressions={brand:{},mobile:{}};
|
||||
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
|
||||
for (const language of ['ru','ky']) {
|
||||
await usersPage.locator(`#ui-${language}`).click();
|
||||
await usersPage.waitForFunction(expected=>document.documentElement.lang===expected,language);
|
||||
for (const width of [1024,1280,1440]) {
|
||||
await usersPage.setViewportSize({width,height:900});
|
||||
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
layoutRegressions.brand[`${language}-${width}`]=await usersPage.evaluate(()=>{
|
||||
const brand=document.querySelector('.search__brand'), name=document.querySelector('.search__brand-name'), form=document.querySelector('.search__query');
|
||||
const range=document.createRange(); range.selectNodeContents(name);
|
||||
const rects=[...range.getClientRects()];
|
||||
return {brandRight:brand.getBoundingClientRect().right,textLeft:Math.min(...rects.map(rect=>rect.left)),textRight:Math.max(...rects.map(rect=>rect.right)),formLeft:form.getBoundingClientRect().left,accessibleName:brand.getAttribute('aria-label')};
|
||||
});
|
||||
}
|
||||
}
|
||||
await usersPage.locator('#ui-ru').click();
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
|
||||
layoutRegressions.reviewNav=await usersPage.evaluate(()=>{
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('review-search-nav'),lab:read('review-review-nav')};
|
||||
});
|
||||
await usersPage.keyboard.press('Tab'); await usersPage.keyboard.press('Tab');
|
||||
layoutRegressions.reviewNav.focused=await usersPage.evaluate(()=>({id:document.activeElement.id,outline:getComputedStyle(document.activeElement).outlineWidth}));
|
||||
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
|
||||
layoutRegressions.publicNav=await usersPage.evaluate(()=>{
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('search-nav'),lab:read('search-review-nav')};
|
||||
});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const publicAccountNav=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#search-review-nav').hidden}));
|
||||
await usersPage.keyboard.press('Escape');
|
||||
if(publicAccountNav.headerUsers||!publicAccountNav.dialogUsers||publicAccountNav.dialogLab||!publicAccountNav.headerLab) throw new Error(`Public account actions do not match visible header links: ${JSON.stringify(publicAccountNav)}`);
|
||||
await usersPage.setViewportSize({width:390,height:844});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C');
|
||||
await usersPage.locator('.search__result-title').first().waitFor(); await usersPage.locator('.search__result-title').first().click(); await usersPage.locator('#public-document-heading').waitFor();
|
||||
for (const width of [390,320]) {
|
||||
await usersPage.setViewportSize({width,height:844});
|
||||
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
layoutRegressions.mobile[width]=await usersPage.evaluate(()=>{
|
||||
const breadcrumb=document.querySelector('.search__breadcrumbs'),text=document.querySelector('#document-breadcrumb'),back=document.querySelector('#document-back'),range=document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const textRects=[...range.getClientRects()].map(({left,right,top,bottom})=>({left,right,top,bottom}));
|
||||
const backRect=back.getBoundingClientRect(),crumbRect=breadcrumb.getBoundingClientRect();
|
||||
const overlaps=textRects.some(rect=>rect.left<backRect.right&&rect.right>backRect.left&&rect.top<backRect.bottom&&rect.bottom>backRect.top);
|
||||
return {textRects,back:{left:backRect.left,right:backRect.right,top:backRect.top,bottom:backRect.bottom,height:backRect.height,tag:back.tagName,name:back.textContent.trim()},breadcrumb:{left:crumbRect.left,right:crumbRect.right,scrollWidth:breadcrumb.scrollWidth,clientWidth:breadcrumb.clientWidth},overlaps};
|
||||
});
|
||||
}
|
||||
console.log('layoutRegressions',JSON.stringify(layoutRegressions));
|
||||
const brandFailures=Object.entries(layoutRegressions.brand).filter(([,value])=>value.textRight>value.brandRight+0.5||value.formLeft-value.textRight<8||value.accessibleName!=='Акылдаш — поиск по правовой базе').map(([viewport])=>viewport);
|
||||
const navQuiet=value=>value.tag!=='A'||!value.href||value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(157, 38, 51)'||value.background!=='rgb(255, 255, 255)'||value.borderColor!=='rgb(222, 211, 206)';
|
||||
const navPrimary=value=>value.tag!=='A'||!value.href||!value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(255, 255, 255)'||value.background!=='rgb(157, 38, 51)'||value.borderColor!=='rgb(157, 38, 51)';
|
||||
const navFailures=navQuiet(layoutRegressions.reviewNav.search)||navPrimary(layoutRegressions.reviewNav.lab)||navQuiet(layoutRegressions.publicNav.lab)||navPrimary(layoutRegressions.publicNav.search)||layoutRegressions.reviewNav.focused.id!=='review-search-nav'||layoutRegressions.reviewNav.focused.outline!=='3px';
|
||||
const mobileFailures=Object.entries(layoutRegressions.mobile).filter(([,value])=>value.overlaps||value.back.tag!=='BUTTON'||value.back.name!=='К результатам'||value.back.height<44||value.textRects.some(rect=>rect.left<value.breadcrumb.left-1||rect.right>value.breadcrumb.right+1)).map(([viewport])=>viewport);
|
||||
if(brandFailures.length||navFailures||mobileFailures.length) throw new Error(JSON.stringify({brandFailures,navFailures,mobileFailures,layoutRegressions}));
|
||||
fixtureAccount=ordinaryAccount;
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#review').waitFor();
|
||||
const ordinaryNav=await usersPage.evaluate(()=>({role:document.querySelector('#profile-role').textContent,headerUsers:!document.querySelector('#review-users-open').hidden,accountUsers:!document.querySelector('#search-users-open').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.waitForURL('https://fixture.invalid/'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#search-results-heading').waitFor();
|
||||
const ordinaryRoute=await usersPage.evaluate(()=>({path:location.pathname,usersHidden:document.querySelector('#users-page').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
console.log('ordinaryAccountCapability',JSON.stringify({ordinaryNav,ordinaryRoute}));
|
||||
if(ordinaryNav.role!=='Пользователь'||ordinaryNav.headerUsers||ordinaryNav.accountUsers||ordinaryNav.adminPage||ordinaryRoute.path!=='/'||!ordinaryRoute.usersHidden||ordinaryRoute.adminPage) throw new Error('Ordinary account must not receive user-management navigation or the users screen.');
|
||||
await browser.close();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
// Synthetic legal-interface examples. Shared by Playwright and the loopback preview.
|
||||
const docs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
|
||||
];
|
||||
const reviewDocs = [
|
||||
{code:'review-a',name:'Fixture document A',type:'fixture',status:'active',edition:'2026'},
|
||||
{code:'review-b',name:'Fixture document B',type:'fixture',status:'active',edition:'2025'}
|
||||
];
|
||||
const visualDocs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики · демонстрационная редакция',type:'Кодекс',status:'Действует',snippet:'Синтетический пример: общие положения о налоге на прибыль и порядке его расчёта.',edition:'2024'},
|
||||
{code:'doc-2',name:'Порядок расчёта налога на прибыль · учебный пример',type:'Методический материал',status:'Демонстрационный',snippet:'Иллюстративный материал для проверки отображения поисковой выдачи.',edition:'2023'}
|
||||
];
|
||||
const visualReviewDocs = [
|
||||
{code:'review-a',name:'Налог на прибыль: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2026'},
|
||||
{code:'review-b',name:'Расчёт условной налоговой базы: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2025'}
|
||||
];
|
||||
const visualArticle = '<h2>Налог на прибыль</h2><p>Синтетический текст для визуальной проверки интерфейса. Он не содержит ссылок на внешние адреса или загружаемые изображения.</p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Условный расчёт</span></b></p><p><b><span>Статья 1. Демонстрационный пример</span></b></p><p>Условная организация использует примерные данные, чтобы показать чтение текста и навигацию по структурным заголовкам.</p><h2>Порядок расчёта</h2><p>Все значения и формулировки в этом документе созданы только для интерфейсного preview.</p>';
|
||||
const visualReviewArticles = {
|
||||
'review-a':'<h2>Налог на прибыль: синтетический пример</h2><p>Демонстрационная редакция для проверки совместной работы результатов и панели документа.</p><p><b><span>Раздел I. Условные положения</span></b></p><p><b><span>Статья 1. Примерная расчётная база</span></b></p><p>Текст полностью вымышлен и используется только для локального fixture-preview.</p>',
|
||||
'review-b':'<h2>Расчёт условной налоговой базы</h2><p>Вымышленный текст для проверки панели чтения и элементов оценки.</p>'
|
||||
};
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const passwordChangeAccount = {username:'admin',role:'Администратор',can_manage_users:true,created_at:1780000000,must_change_password:false};
|
||||
const ordinaryAccount = {username:'fixture-user',role:'Пользователь',can_manage_users:false,created_at:1780000000,must_change_password:false};
|
||||
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Основные положения</span></b></p><p><b><span>Статья 1. Основные понятия</span></b></p><p>Обычное упоминание статьи 9 не является заголовком.</p><p id="duplicate-heading"><b><span>Статья 1. Повторный номер</span></b></p><p id="duplicate-heading"><b><span>Раздел II. Налоговая отчётность</span></b></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
const articleKy = '<h2>Киреше салыгы</h2><p><b><span>БӨЛҮМ 1. Жалпы жоболор</span></b></p><p><b><span>Глава 1. Негизги жоболор</span></b></p><p><b><span>Берене 1. Негизги түшүнүктөр</span></b></p><p>Текстте берене 9 жөнүндө кадимки эскерүү бар.</p>';
|
||||
|
||||
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy};
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node check.cjs"
|
||||
"test": "node check.cjs",
|
||||
"preview": "node preview-server.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {visualDocs, visualReviewDocs, visualReviewArticles, visualArticle, articleKy, catalogs, passwordChangeAccount} = require('./fixtures.cjs');
|
||||
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const htmlPath = path.join(repo, 'backend/search/review.html');
|
||||
const staticRoot = path.join(repo, 'backend/search/static');
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
const port = Number(process.argv[2] || 4173);
|
||||
let users = [{username:'admin',role:'Администратор',can_manage_users:true,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null},{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null}];
|
||||
|
||||
const warning = `<div style="position:relative;z-index:1000;padding:5px 12px;background:#38262a;color:#fff;font:600 12px/1.4 sans-serif;text-align:center;letter-spacing:.02em">ЛОКАЛЬНЫЙ PREVIEW · только синтетические данные · внешние ссылки отключены</div><script>document.addEventListener('click',event=>{const link=event.target.closest('a[href]');if(!link)return;const target=new URL(link.href,location.href);if(target.origin!==location.origin){event.preventDefault();event.stopImmediatePropagation();}},true);</script>`;
|
||||
const previewHtml = html.replace('<body>', `<body>${warning}`);
|
||||
const json = (response, status, value) => {
|
||||
response.writeHead(status, {'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
const readBody = (request) => new Promise((resolve) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => { body += chunk; if (body.length > 32768) request.destroy(); });
|
||||
request.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
|
||||
});
|
||||
const sendHtml = (response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type':'text/html; charset=utf-8',
|
||||
'Cache-Control':'no-store',
|
||||
'X-Content-Type-Options':'nosniff',
|
||||
'Referrer-Policy':'no-referrer',
|
||||
'Content-Security-Policy':"default-src 'self' data:; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; navigate-to 'self'"
|
||||
});
|
||||
response.end(previewHtml);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
const method = request.method || 'GET';
|
||||
if (method === 'GET' && ['/', '/review', '/users'].includes(url.pathname)) return sendHtml(response);
|
||||
if (method === 'GET' && url.pathname === '/auth/me') return json(response, 200, {account:passwordChangeAccount});
|
||||
if (method === 'POST' && url.pathname === '/auth/logout') return json(response, 200, {ok:true});
|
||||
if (method === 'POST' && url.pathname === '/auth/change-password') return json(response, 200, {account:passwordChangeAccount});
|
||||
if (method === 'GET' && url.pathname === '/search/filters') return json(response, 200, catalogs);
|
||||
if (method === 'GET' && url.pathname === '/search') {
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const lab = url.searchParams.has('page_size') && !url.searchParams.has('sort');
|
||||
return json(response, 200, {results:query === 'not-found' ? [] : (lab ? visualReviewDocs : visualDocs),has_next:false,review_token:'fixture-only-token'});
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-2/editions') return json(response, 200, {available_languages:[],editions:[]});
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-3/editions') return json(response, 503, {error:'Fixture editions error'});
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-1/editions') return json(response, 200, {available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года',ky:'2024-жылдын 12-январындагы редакция'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]});
|
||||
const visualReviewEdition = url.pathname.match(/^\/documents\/(review-a|review-b)\/editions\/(2026|2025)$/);
|
||||
if (method === 'GET' && visualReviewEdition) return json(response, 200, {content:{ru:{html:visualReviewArticles[visualReviewEdition[1]]}}});
|
||||
const edition = url.pathname.match(/^\/documents\/doc-1\/editions\/(2024|2023)$/);
|
||||
if (method === 'GET' && edition) {
|
||||
const language = url.searchParams.get('language') || 'ru';
|
||||
const content = edition[1] === '2023' ? '<h2>Russian-only edition</h2><p>Regression fixture.</p>' : language === 'ky' ? articleKy : visualArticle;
|
||||
return json(response, 200, {content:{[language]:{html:content}}});
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/admin/users') return json(response, 200, {users});
|
||||
if (method === 'POST' && url.pathname === '/admin/users') {
|
||||
const body = await readBody(request); const username = String(body.username || 'fixture-created');
|
||||
users.push({username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null});
|
||||
return json(response, 201, {username});
|
||||
}
|
||||
if (method === 'POST' && /^\/admin\/users\/[^/]+\/reset-password$/.test(url.pathname)) return json(response, 200, {ok:true});
|
||||
if (method === 'DELETE' && /^\/admin\/users\/[^/]+$/.test(url.pathname)) return json(response, 200, {ok:true});
|
||||
if (method === 'POST' && url.pathname === '/search-reviews') return json(response, 200, {id:42});
|
||||
if (method === 'GET' && url.pathname.startsWith('/static/')) {
|
||||
const name = path.basename(url.pathname);
|
||||
const file = path.join(staticRoot, url.pathname.endsWith('.woff2') ? 'fonts' : '', name);
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return json(response, 404, {error:'Not found'});
|
||||
const type = name.endsWith('.woff2') ? 'font/woff2' : name.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||
response.writeHead(200, {'Content-Type':type,'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
|
||||
return fs.createReadStream(file).pipe(response);
|
||||
}
|
||||
return json(response, 404, {error:'Fixture route not found'});
|
||||
});
|
||||
|
||||
server.on('error', (error) => { process.stderr.write(`${error.code || error.name}\n`); process.exitCode = 1; });
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
process.stdout.write(`fixture preview listening at http://127.0.0.1:${port}/ (pid ${process.pid})\n`);
|
||||
});
|
||||
Reference in new issue
Block a user