Compare commits

..
Author SHA1 Message Date
agent 871bde1abd Merge pull request 'fix: enumerate Windows ACL entries by SID' (#63) from fix/windows-acl-sid-enumeration into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 09:11:40 +00:00
codex 5c26bbe8b5 docs: record SID-native Windows ACL checks
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 12:05:19 +03:00
codex 3d7df25d00 fix Windows ACL checks to enumerate SIDs directly
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 11:52:10 +03:00
agent 4d589eee95 Merge pull request 'Harden external release preparation and runtime compatibility' (#62) from fix/cd-release-runtime-readiness into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 3s
2026-11-03 07:48:07 +00:00
codex 3c17e2eb3b Document external Windows release handoff
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:41:53 +03:00
codex 54e30d4228 Make Windows harness cleanup safe for Git objects
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:35:49 +03:00
codex 00b0af69c2 Fix Windows ZIP mode fixture encoding
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 10:24:45 +03:00
codex 81ab7aac39 Harden Windows release mode harness
CI / tests-and-images (pull_request) Successful in 2m11s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:19:45 +03:00
codex 8748760594 Align Windows release bundle mode validation
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:12:52 +03:00
codex 9d553744a1 Harden Windows release bundle trust boundary
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:02:27 +03:00
codex 710c483f34 Harden external release preparation workflow
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 09:38:37 +03:00
agent f19faf628e Merge pull request 'Consolidate product, UI, and deployment plans' (#60) from feature/documentation-consolidation into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 06:08:33 +00:00
codex 9fc269283b docs: align documentation footer versions
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-11-03 09:02:33 +03:00
codex 826c1d8ca5 Merge current main into documentation consolidation
CI / tests-and-images (pull_request) Successful in 2m47s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 08:54:25 +03:00
agent 9a98fafefb Merge pull request 'Improve search ranking and support empty reviews' (#61) from fix/search-relevance-and-empty-reviews into main
CI / tests-and-images (push) Successful in 2m14s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 23:44:12 +00:00
codex 849c19e2ab docs: record final search benchmark findings
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 02:38:36 +03:00
codex 1c7ddf2fcb Use DFS scoring for explicit multi-index search
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 02:20:23 +03:00
codex e653f3b50e Preserve multi-index scope in review snapshots
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:41:58 +03:00
codex 726b1ee745 Harden empty review validation and lease ranking
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:09:01 +03:00
codex aa6fabe970 Fix search lease terminology and empty reviews
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:47:33 +03:00
codex 7437aef346 Record documentation gates in changelog
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:22:21 +03:00
codex 849c7537ed Document atomic corpus publication gate
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:18:06 +03:00
codex ee03dc6cc7 Clarify data pipeline versions and corpus handoff gate
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:11:52 +03:00
codex 4cf96e3fa9 Address documentation roadmap review findings
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:58:47 +03:00
codex ba9564fca2 Narrow scratch ignores and clarify shared mockup
CI / tests-and-images (pull_request) Successful in 2m31s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:44:57 +03:00
codex 3eba203009 Complete transferred UI and operational criteria
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:43:11 +03:00
codex 3fd5695bb2 Clarify roadmap source history
CI / tests-and-images (pull_request) Successful in 2m23s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:41:46 +03:00
codex 42b3aa09a7 Consolidate product and deployment roadmap docs
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:40:45 +03:00
agent 7ece39f212 Merge pull request 'План развития платформы: сравнение с Токтом Онлайн' (#55) from feature/toktom-platform-development-plan into main
CI / tests-and-images (push) Successful in 3m4s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 19:21:24 +00:00
codex bb3de5ee0f Merge branch 'main' of https://git.vakanaut.ru/admin/akyldash into feature/toktom-platform-development-plan
CI / tests-and-images (pull_request) Successful in 2m47s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
# Conflicts:
#	CHANGELOG.md
2026-11-02 22:04:25 +03:00
codex 1ca41f162e Add platform plan to changelog
CI / tests-and-images (pull_request) Successful in 2m21s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 21:53:26 +03:00
codex afb3c9aa03 Update platform plan against current main
CI / tests-and-images (pull_request) Successful in 3m18s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 19:04:48 +03:00
admin c8d0086a9f Merge pull request 'Подготовить безопасный процесс production-релизов' (#59) from feature/production-deployment-readiness into main
CI / tests-and-images (push) Successful in 2m51s
Runner smoke test / runner-smoke (push) Successful in 3s
Reviewed-on: #59
Reviewed-by: agent <agent@vakanaut.ru>
2026-11-01 19:43:47 +00:00
codex 6cab6fcc95 Document bounded external release trigger
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 12:03:08 +03:00
codex 8ede9f2123 Quote Windows task script argument
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:57:32 +03:00
codex ca5036abfe Do not interrupt active deployment on battery change
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:51:07 +03:00
codex 1c2a468516 Terminate timed-out release API helper groups
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:50:34 +03:00
codex b50cfa818d Add bounded external release runtime binding 2026-11-01 11:50:13 +03:00
codex 0630a774cf Document external deployment readiness limits
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-01 11:31:12 +03:00
codex 4a7e907be7 Clarify Windows validation evidence
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-31 01:02:08 +03:00
codex 96ed0949ca Clarify production handoff is not deployed 2026-10-31 00:58:56 +03:00
codex 3d267afaee Clean up failed backup publication safely 2026-10-31 00:53:12 +03:00
codex 4c4b51270c Harden Windows backup ACL preflight 2026-10-31 00:49:26 +03:00
codex 940b6c93f9 Fix external deployment recovery preflight 2026-10-31 00:45:52 +03:00
codex a54fef6a0c Make Windows backup receiver portable 2026-10-31 00:41:34 +03:00
codex d65b857c2a Add external backup release and rollback sessions 2026-10-31 00:35:18 +03:00
agent 6be28418ab docs: update deployment readiness changelog
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 23:00:50 +03:00
agent f8483badcf fix: validate Gitea workflow source identity
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-26 22:55:29 +03:00
agent 74aa167d16 ci: chain release and dry-run handoff
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 22:43:11 +03:00
admin f1573edfa7 Merge branch 'main' into feature/production-deployment-readiness
CI / tests-and-images (pull_request) Successful in 2m28s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-26 19:10:55 +00:00
admin f90161f380 Merge pull request 'Ввести риск-ориентированную политику Delivery First' (#58) from feature/delivery-first-risk-verification into main
CI / tests-and-images (push) Successful in 2m19s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #58
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-26 19:05:11 +00:00
agent 9a5a1a1a69 Merge current main into Delivery First PR
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 16:56:00 +03:00
agent 05fc47d7c3 docs: record Delivery First policy
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 16:49:59 +03:00
agent e40a32971d Fix release artifact checksum paths
CI / tests-and-images (pull_request) Successful in 2m48s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 16:41:35 +03:00
agent eff81c1504 docs: record production release workflow
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 13:00:33 +03:00
agent 779743fdb0 fix: validate release tags by Gitea run ID
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-26 10:56:59 +03:00
agent 8f331a002f docs: record artifact download verification
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 10:41:39 +03:00
agent d428919b78 fix: harden release artifact verification
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-26 10:40:53 +03:00
agent 9d8ce6b67f fix: address production release review findings
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-25 13:44:56 +03:00
agent 852d639c45 feat: prepare production deployment readiness
CI / tests-and-images (pull_request) Failing after 26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-25 13:21:40 +03:00
agent b5adc09203 Merge pull request 'Исправить навигацию и capability администратора' (#57) from fix/admin-navigation-capabilities into main
CI / tests-and-images (push) Successful in 2m22s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-24 20:33:30 +00:00
codex a153dc8765 docs: record admin navigation release changes
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 23:29:57 +03:00
codex 3620bb734c test: wait for localized user role
CI / tests-and-images (pull_request) Successful in 2m24s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:26:06 +03:00
codex 830e1d0f0d fix: localize account roles and align lab brand
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:17:43 +03:00
codex fd28ed2201 Добавить risk-based Delivery First policy
CI / tests-and-images (pull_request) Successful in 2m34s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 22:53:07 +03:00
codex a1bb3eab98 fix: align admin navigation with backend capability
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 22:32:30 +03:00
admin 8bd2b53469 Merge pull request 'Согласовать поиск, кабинет и лабораторию с макетом' (#56) from fix/uiux-visual-feedback into main
CI / tests-and-images (push) Successful in 2m43s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #56
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-24 19:02:01 +00:00
Codex 21325d4e5c Fix three UI navigation collisions
CI / tests-and-images (pull_request) Successful in 2m36s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 20:07:28 +03:00
Codex 48cb424afd Refine search, account, and lab layouts
CI / tests-and-images (pull_request) Successful in 2m32s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:49:55 +03:00
Codex e5a8326a5e Polish search, account, and lab screens
CI / tests-and-images (pull_request) Successful in 2m56s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 19:12:05 +03:00
agent 770e7930d9 docs: correct implementation versions in comparison baseline
CI / tests-and-images (pull_request) Successful in 2m30s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 18:56:33 +03:00
agent cb3ab76d2b docs: add platform development plan and Toktom gap analysis
CI / tests-and-images (pull_request) Successful in 2m52s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 18:53:53 +03:00
admin 01380d3c09 Merge pull request 'Исправления интерфейса Түндүк: поиск, лаборатория и кабинеты' (#54) from feature/uiux-tunduk-corrections into main
CI / tests-and-images (push) Successful in 2m50s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #54
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 19:44:49 +00:00
admin 6a3a6f8da5 fix: preserve search layout until panels fit
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:29:08 +03:00
admin 224646e9d7 fix: address UI review findings
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 17:17:01 +03:00
admin 1d5eda3d89 chore: normalize target screenshot permissions
CI / tests-and-images (pull_request) Successful in 2m21s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 16:46:54 +03:00
admin 8f58995fd0 fix: complete Tunduk interface corrections 2026-10-23 16:46:41 +03:00
admin 25c62d3fff Merge pull request 'Implement pixel-perfect legal search interface' (#53) from feature/frontend-pixel-perfect into main
CI / tests-and-images (push) Successful in 2m17s
Runner smoke test / runner-smoke (push) Successful in 2s
Reviewed-on: #53
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-23 08:59:29 +00:00
108 changed files with 10943 additions and 1079 deletions

No files matched your search

+4
View File
@@ -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"]
+23
View File
@@ -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
+18
View File
@@ -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
+64
View File
@@ -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.'
+90
View File
@@ -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.'
+5
View File
@@ -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/
+30 -1
View File
@@ -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
+88
View File
@@ -2,6 +2,94 @@
## Не выпущено
- 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, чтобы три панели не обрезались при
промежуточных ширинах окна.
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
навигации, старый текст больше не остаётся при ошибке загрузки другого
документа; очищаются дополнительные URL-атрибуты юридической разметки,
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
изоляции ссылок, reflow и безопасных форм управления пользователями.
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
чтение документа и лаборатории, доступные диалоги; управление пользователями
вынесено на отдельный защищённый экран. Документная разметка очищается от
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
правовой текст и API управления пользователями не менялись.
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
+16 -18
View File
@@ -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).
Точные токены и правила этой спецификации важнее случайных деталей
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
ранних концепций не используется.
Название продукта — **Акылдаш**; «УКУК» из ранних концепций не используется.
Изображение определяет целевую композицию, пропорции панелей и расположение
элементов поискового экрана. Эта спецификация определяет семантические токены,
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[плане точного воспроизведения поискового интерфейса](docs/product/frontend-pixel-perfect-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).
| Элемент | Размер | Межстрочный интервал |
|---|---|---|
+19 -9
View File
@@ -9,19 +9,29 @@ 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 и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -63,4 +73,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
View File
@@ -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
+34 -16
View File
@@ -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}
@@ -395,7 +413,7 @@ def handler(api: Api):
session = cookies.get("akyldash_session")
token = session.value if session else None
user = api.auth_store.user_for_token(token)
if method == "GET" and route in {"/", "/review"}:
if method == "GET" and route in {"/", "/review", "/users"}:
body = api.review_page().encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
@@ -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:
+7 -2
View File
@@ -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,))
+1 -1
View File
@@ -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
+103 -16
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.13.0"
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.1.2")
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
+30 -3
View File
@@ -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
+692 -123
View File
File diff suppressed because it is too large. Load diff
+210 -18
View File
@@ -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(
+91 -2
View File
@@ -3,10 +3,11 @@ import contextlib
import io
import tempfile
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import patch
from unittest.mock import Mock, patch
from search.api import Api, ApiError
from search.api import Api, ApiError, handler
class SearchApiTest(unittest.TestCase):
@@ -37,6 +38,56 @@ class SearchApiTest(unittest.TestCase):
with self.assertRaisesRegex(ApiError, "static asset not found"):
Api.static_asset("/static/fonts/../../DESIGN.md")
def test_users_screen_is_served_without_exposing_admin_api(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
request_handler = handler(api)
def get(path):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
users_page = get("/users")
users_page.send_response.assert_called_once_with(200)
self.assertIn(b'id="users-page"', users_page.wfile.getvalue())
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))
@@ -140,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()
+24
View File
@@ -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()
+31 -1
View File
@@ -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"]
+130 -2
View File
@@ -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()
+52
View File
@@ -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).
+11 -1
View File
@@ -6,10 +6,15 @@
типографика, компоненты и правила адаптивности портала и лаборатории.
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
правила работы с данными.
- [Стратегическое сравнение с Токтом](product/platform-development-plan-toktom-2026-10-24.md) —
продуктовая гипотеза и конкурентный анализ; это не план исполнения задач.
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend.
- [План реализации и приёмки](product/implementation-roadmap.md) — единая
очередность UI и deployment gates, со статусом доказательств; не дублирует
продуктовую стратегию или эксплуатационные процедуры.
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
@@ -30,6 +35,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 +55,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+177
View File
@@ -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
View File
@@ -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.
+44 -8
View File
@@ -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
+38 -11
View File
@@ -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.
+226 -22
View File
@@ -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
View File
@@ -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.
+125
View File
@@ -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
View File
@@ -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).
-355
View File
@@ -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.
+462
View File
@@ -0,0 +1,462 @@
# План реализации и приёмки Акылдаш
Дата актуализации: **2 ноября 2026 года**. Основа сверки: `main` на
`7ece39f212b67e497a8dfb360d5d271ab088ef57`. Это единый план работ по продукту,
интерфейсу и переходу к production. Он не заменяет эксплуатационные runbooks
и не подтверждает состояние живого сервиса.
## 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 Продукт и качество правовых данных
1. Поддерживать воспроизводимое получение и нормализацию официальных
правовых материалов с сохранением источника, редакции и контрольных сумм.
2. Подтвердить полноту и актуальность RU/KY корпуса, правила для отсутствующих
текстов и одноязычных редакций; не выдумывать перевод или реквизиты.
3. Развивать замороженный relevance set и измерять качество на одной и той же
выборке до и после изменений ранжирования.
4. Развивать версионируемые API и справочники, сохраняя совместимость
существующих клиентов.
5. Публичный frontend, персональные функции и RAG остаются отдельными
продуктовыми решениями и readiness gates. Сверяться с [обзором проекта](project-overview.md),
[планом frontend](frontend-search-sps-plan.md) и
[готовностью публичного frontend](frontend-design-readiness-plan.md). Конкурентная
гипотеза и сегмент рынка отдельно описаны в
[стратегическом сравнении с Токтом](platform-development-plan-toktom-2026-10-24.md).
### 2.2 Интерфейс: датированный реестр 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.
#### Общие критерии окон и форм
- 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.3 Инфраструктура и переход к 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.
@@ -0,0 +1,307 @@
# План развития «Акылдаш»: конкурентный анализ «Токтом Онлайн»
Дата: 24 октября 2026 года. Статус: предложение для продуктового решения;
не утверждённый бюджет или срок выпуска. Изменения приложения этим документом
не выполняются.
## 1. Вывод и оценка шансов
**На текущем этапе «Акылдаш» не готов заменить профессиональную СПС целиком.
Шанс построить востребованный продукт есть, если начать с ограниченного
практического сегмента и доказать качество данных и экономию времени.**
Копирование всей широты конкурента потребует не только разработки, но и
постоянной юридической редакции, получения материалов, сопровождения клиентов
и продаж. Рекомендуемая стартовая гипотеза — поиск и контроль норм для
небольших юридических и бухгалтерских команд, работающих с ОсОО/ЖЧК,
налогообложением и трудовыми вопросами. Наличие отдельных поисковых улучшений
по ОсОО и налогам делает этот сегмент удобным для проверки, но само по себе
не подтверждает спрос.
| Сценарий | Оценка | Что должно измениться |
| --- | --- | --- |
| Быстрая полная замена привычной СПС | Низкие шансы сейчас | Нужны надёжный выпуск, полнота материалов и профессиональные инструменты |
| Дополнительный инструмент для одной практической задачи | Наиболее реалистичная гипотеза | Пилот должен показать измеримую пользу без обязательного отказа от прежней системы |
| Основное рабочее место небольшой команды | Условно достижимо после пилота | Нужны удержание, обновления, персональная работа и подтверждённая готовность платить |
| Массовая платформа с ИИ-консультантом | Долгосрочная гипотеза | Нужны проверяемые ответы, экономика обслуживания и контроль юридических ошибок |
Проценты вероятности успеха не рассчитываются: нет данных о числе клиентов
конкурента, продажах, удержании, нашей команде, бюджете и стоимости привлечения.
Цены, скорость и качество поиска систем в одинаковых условиях не сравнивались.
Техническая возможность создать функцию не доказывает коммерческое преимущество.
## 2. Основание и ограничения исследования
Изучено открытое [руководство «Токтом Онлайн»](https://online.toktom.kg/cms/Topic/273)
целиком: портал, учётные записи, продукты, поиск и работа с документами.
В таблице ниже номера означают разделы этого руководства. Описание функции
подтверждает её документирование, но не результат испытания платной системы.
Дата последнего обновления руководства не установлена; старый год в подвале
не позволяет определить актуальность отдельных инструкций.
Отдельная [страница продукта](https://online.toktom.kg/cms/Topic/292)
заявляет ежедневное обновление и работу через интернет с синхронизацией
персональных данных. Это заявления поставщика; их фактическое выполнение
не проверялось. Актуальный тариф на этой странице предлагается запрашивать.
База сравнения «Акылдаш»: актуальная на повторной сверке `origin/main`, commit
`c8d0086a9f20485c5efe6f9d3ccd476dd9f07065` (2 ноября 2026 года). Матрица
G01–G25, описание текущих возможностей в разделе 3 и этапы A–F в разделе 6
повторно сверены с этой базой. Проверены:
- [README](../../README.md), [обзор продукта](project-overview.md);
- [API](../../backend/search/api.py), [поисковый интерфейс](../../backend/search/review.html),
[учётные записи](../../backend/search/auth.py), [запросы](../../backend/search/query.py);
- [каталоги](search-catalog-v1.md), [план frontend](frontend-search-sps-plan.md),
[readiness gate](frontend-design-readiness-plan.md);
- [состояние инфраструктуры и корпуса](../operations/CURRENT_STATE.md),
[доступ](../operations/ACCESS.md), а также актуальные реализации Search API,
учётных записей и поискового интерфейса.
Матрица отражает возможности реализации на указанном commit, а не только
состояние прежней базы исследования. В частности, в текущем внутреннем контуре
администратор может создавать и удалять учётные записи и сбрасывать пароль;
публичная регистрация и восстановление по email по-прежнему отсутствуют.
Технические версии кода: Backend 0.13.3 (`APP_VERSION` в
`backend/search/minjust_opensearch.py`), Frontend 0.3.3 (подвал
`backend/search/review.html`), Telegram-бот 0.2.2 (`APP_VERSION` в
`tools/telegram-bot/bot.py`). Корневой README указывает Backend 0.13.2;
`backend/README.md` содержит более старые Backend 0.13.0 / Frontend 0.2.3.
При расхождении использованы версия backend и подвал интерфейса в указанном
commit. Эти версии не подтверждают развёртывание соответствующего кода.
Живой production, полнота локального корпуса и пользовательская доступность
не подтверждены текущими проверками. Нельзя считать функцию доступной клиентам
только на основании наличия кода.
## 3. Что уже есть у нас
В реализации есть полнотекстовый поиск RU/KY, фильтры по виду, статусу, группе
органа, юридической силе и дате; пагинация и сортировка по релевантности/дате.
Есть API карточки и редакций, чтение выбранной редакции и языка, оглавление,
поиск внутри текста, печать и переход на официальный источник.
Это **не нужно разрабатывать заново**.
Учётные записи SQLite, проверка полномочий на сервере,
смена пароля и административные создание, удаление и сброс пароля доступны
во внутреннем контуре. Лаборатория собирает оценки выдачи и комментарии
проверяющих. Это не публичная регистрация, не восстановление пароля по email,
не пользовательские заметки к нормам и не история исследований между устройствами.
По состоянию, записанному в `CURRENT_STATE.md`, публичный запуск и готовность
всего корпуса не подтверждены. В документе есть исторические проверки архива
и ограниченного staging; успешная обработка одного документа не доказывает
полноту и актуальность базы. RAG в README отмечен как ещё не созданный.
## 4. Функциональные пробелы
Обозначения: **нет** — в проверенных API, UI и профильных планах отсутствует
готовая возможность; **частично** — есть основа, но сценарий неполон;
**не подтверждено** — без аудита корпуса или рабочего сервиса вывод невозможен.
«Нет» относится к реализации указанного commit, а не ко всем внешним наработкам.
P0 — до публичного пилота; P1 — основное рабочее место; P2 — после спроса;
P3 — только при отдельном обосновании.
| ID | Возможность в руководстве «Токтом» | Состояние «Акылдаш» и необходимое дополнение | Приоритет |
| --- | --- | --- | --- |
| G01 | Регистрация, восстановление, соцвход (§1.1–1.2) | Частично: внутренние аккаунты есть; публичный onboarding и восстановление по email отсутствуют. Соцвход отложить | P1 / P3 |
| G02 | Организация и профиль (§3.1.1) | Частично: есть кабинет, но нет полноценной модели организаций | P2 |
| G03 | Счёт, платежи (§3.1.2) | Нет: коммерческий учёт и сверка оплат | P2 |
| G04 | Продукты, сроки, промокоды (§3.2.1–3) | Нет: тарифные права и жизненный цикл подписки | P2 |
| G05 | Подключение коллег (§3.2.4–5) | Нет: приглашения и права внутри организации; внутренние роли не заменяют это | P2 |
| G06 | Настройки выдачи (§3.2.6) | Частично: параметры доступны, серверного сохранения предпочтений нет | P1 |
| G07 | Тематические разделы (§3.3.1) | Частично: черновая таксономия есть, публичный тематический фильтр отсутствует | P1 |
| G08 | Судебные, справочные, зарубежные материалы (§3.3.1) | Не подтверждено: специализированные полные коллекции и их поиск не показаны; наличие отдельных актов в ЦБД не исключается | P2 / P3 |
| G09 | Новые документы (§3.3.3) | Нет отдельной пользовательской ленты поступлений | P1 |
| G10 | Избранное, изменения (§3.3.4) | Нет серверного списка и пользовательского контроля изменений | P1 |
| G11 | История документов/запросов (§3.3.5) | Нет синхронизированной истории исследования; URL браузера и история входов — другие данные | P1 |
| G12 | Обзор обновлений (§3.3.6) | Нет журнала новых, изменённых и отменённых актов для пользователя | P0 / P1 |
| G13 | Открытие по коду (§3.4.2) | Частично: API принимает код, отдельного пользовательского сценария нет | P1 |
| G14 | Расширенные реквизиты (§3.4.3) | Частично: нужны номер, раздельные поля названия/текста, тематический отбор, множественные значения и поиск без обязательного q | P1 |
| G15 | Детальные органы, публикация (§3.4.3.1) | Частично: API ограничен группами органов; нет полного реестра и фильтров по регистрации/источнику публикации | P2 |
| G16 | Шаблоны запросов (§3.4.3.2) | Нет именованных сохранённых поисков | P1 |
| G17 | Сортировка по органу/виду (§3.5.3) | Нет таких режимов и выбора направления; текущая дата имеет дополнительный приоритет статуса | P2 |
| G18 | Word: выдача/документ (§3.5.4; 3.6.6) | Нет DOCX-экспорта; браузерная печать уже есть | P1 |
| G19 | Справка и реквизиты (§3.6.1) | Частично: метаданные есть, полная читаемая паспортная карточка требует доработки | P1 |
| G20 | Связи, обратные ссылки (§3.6.7–8) | Нет собственного проверенного графа связей и обратного индекса; исходные HTML-ссылки не равнозначны | P1 |
| G21 | Предпросмотр, «Связной» (§3.6.9; 13) | Нет контекстного просмотра и связей на уровне конкретной нормы | P2 |
| G22 | Графические копии (§3.6.10) | Частично на уровне архива: готовая выдача оригиналов не подтверждена; вложения staging не верифицированы | P2 |
| G23 | Сравнение редакций (§3.6.12) | Нет сопоставления двух текстов и выделения изменений; переключатель редакций уже есть | P1 |
| G24 | Языковое сравнение (§3.6.14) | Нет параллельного просмотра; переключение RU/KY уже есть | P1 |
| G25 | Форум, сообщения (§2; 3.2.7–8) | Нет пользовательского сообщества; рабочий Telegram-бот команды его не заменяет | P3 |
Редакции, языки, оглавление и печать (§3.6.2–5) в перечень отсутствующих
функций не включены. Для сравнительного списка использовано
[руководство поставщика](https://online.toktom.kg/cms/Topic/273).
## 5. Стратегия отличия
Это гипотезы развития, а не установленные преимущества над конкурентом.
Отсутствие описания в его руководстве не доказывает отсутствия функции.
1. **Проверяемая актуальность.** На каждой норме показывать источник, редакцию,
время последней успешной проверки и ограничения данных. Разделять дату
получения, принятия, вступления в силу и период применимости.
2. **Практический двуязычный поиск.** Измерять RU и KY отдельно на реальных
задачах, сокращениях и опечатках. Не обещать кыргызскую морфологию только
из-за наличия ICU. Не подменять отсутствующий официальный текст переводом.
3. **Рабочая задача вместо набора документов.** Проверить сценарий
«найти применимую норму → проверить редакцию → сохранить доказательство →
получить сигнал об изменении». Он должен занимать меньше времени у пилота.
4. **Объяснение с проверяемыми ссылками.** После надёжного поиска добавить
ответы с точными цитатами и открытием фрагмента. При недостатке источников
система сообщает об этом; юридические выводы требуют проверки специалистом.
Удобный интерфейс и низкая цена сами по себе не обеспечат переход клиентов.
Открытые источники также не дают автоматического разрешения на любой способ
их использования. Права на каждый новый источник проверяются отдельно;
копирование базы и редакционных материалов конкурента в план не входит.
## 6. Последовательность развития и приёмка
Сроки ниже — предварительные диапазоны для команды из двух разработчиков
с регулярным участием юриста и частичным участием дизайнера/эксплуатации.
Это предположение для обсуждения, не оценка существующего штата. При иной
команде и после обследования данных сроки нужно пересчитать. Переходы
определяются результатами, а не истечением недель.
### Этап A. Данные и воспроизводимый запуск — P0, ориентир 3–6 недель
Зависимости: состояние официального API, доступный корпус и участие владельца
инфраструктуры. Назначить ответственных за данные, эксплуатацию и юридическую
проверку. Согласовать правила текущей редакции и обработки неизвестного статуса.
- Завершить квалификацию источника и полный аудит корпуса; отдельно проверить
языки, порядок редакций, контрольные суммы, ошибки, вложения и пропуски.
- Запустить воспроизводимый цикл проверки обновлений с журналом изменений
и безопасным переключением индекса. Из этого журнала строить G12 и G09.
- Выполнить существующий readiness gate, зафиксировать baseline и ограничения.
- Подготовить пилотное развёртывание, наблюдение за ошибками, проверенный backup
и восстановление в изолированной среде по существующим процедурам.
Приёмка: завершённый отчёт о полноте с объяснёнными исключениями; повторный
запуск не создаёт дубликатов; неудачное обновление сохраняет рабочий индекс;
сквозные сценарии RU/KY проходят; восстановление проверено. Рекомендуемая цель
актуализации — не более суток от доступности изменения у источника; сначала
измерить достижимость и только затем обещать SLA пользователям.
### Этап B. Поисковое рабочее место — P1, ориентир 4–6 недель
Зависимость: этап A. Закрыть G07, G13–14, G18–19 и уточнить G06.
Использовать готовые просмотр, печать и оглавление; не переписывать их.
- Утвердить темы и устойчивые идентификаторы; не превращать черновые хеши
классификатора в публичный контракт без проверки.
- Добавить точный номер и независимые поля поиска; разрешить отбор только
по реквизитам; определить семантику AND/OR и множественного выбора.
- Сделать паспорт документа, пользовательский сценарий открытия по коду и
DOCX с редакцией, языком, источником и временем выгрузки. Не обещать PDF-
экспорт сверх печати до подтверждения отдельной потребности.
Приёмка: 20 задач пилота успешно проходят путь от запроса до нужного фрагмента;
документы без языка/текста имеют понятное состояние; файл экспорта воспроизводит
выбранную редакцию; новые правила не ухудшают замороженный relevance baseline.
### Этап C. Возвращение к работе и контроль — P1, ориентир 3–5 недель
Зависимости: A–B; для внешних пользователей — выбранная модель доступа и
проверенное восстановление аккаунта G01. Для закрытого пилота допустимы
создаваемые администратором внутренние аккаунты без публичной регистрации.
Закрыть G06, G10–11, G16: серверные избранное, история и шаблоны; G09/G12
показать как пользовательскую ленту. Добавить настройки контроля и доставки
уведомлений только после появления надёжного журнала обновлений. Выбор канала
и согласие пользователя — отдельное решение; наличие бота команды не означает
готовность продуктовых уведомлений.
Приёмка: сохранённое доступно со второго устройства; данные разных аккаунтов
изолированы; удаление/отключение контроля работает; повторная обработка события
не дублирует сигнал. Правила хранения истории и удаления аккаунта документированы.
### Этап D. Анализ нормы — P1/P2, ориентир 5–8 недель
Зависимости: проверенные редакции, структура фрагментов и метаданные A–B.
Закрыть G23–24 и базовую часть G20; затем G21, G22 и G15 по результатам пилота.
Для связей сохранять источник, целевую редакцию и уверенность сопоставления.
Приёмка: юрист проверяет не менее 30 разнообразных пар редакций, включая
перенумерацию и перемещение статей; интерфейс отличает техническую правку от
содержательной, а неопределённость показывает явно. Параллельный RU/KY-просмотр
использует одну редакцию и не выдумывает соответствия. Неуверенные ссылки
не выдаются как подтверждённые связи. Текстовый diff не считается юридическим
заключением о дате применимости нормы.
### Этап E. Платный командный продукт — P2, ориентир 4–8 недель
Зависимость: подтверждённые повторное использование и готовность платить.
Закрыть G02–05 и недостающую часть G01. Сначала выбрать одну понятную модель
подписки и необходимый канал оплаты; не воспроизводить весь биллинг конкурента.
Приёмка: организация приглашает и отзывает участника; сервер проверяет права
и срок доступа; повторное платёжное событие не создаёт повторное начисление;
просроченная подписка не уничтожает сохранённые данные. Стоимость эксплуатации,
юридической редакции и поддержки учитывается в экономике тарифа.
### Этап F. Новые источники и ответы — P2/P3, отдельная оценка
G08, G17, G25 и оставшиеся расширения включать по измеренному спросу.
Для судебной практики сначала исследовать законность получения, полноту,
обезличивание и обновляемость; для экспертных материалов — лицензирование
и редакционную ответственность. Международные акты проверять в существующем
корпусе прежде, чем создавать отдельный загрузчик. Зарубежную базу и форум
не ставить на критический путь первого продукта.
RAG — отдельная разработка после A–D. Начать с поиска доказательств и цитат,
затем краткого ответа. Приёмка: замороженный экспертный набор, проверка каждой
ссылки и редакции, отказ при недостатке данных и ноль критических неподтверждённых
юридических утверждений на приёмочном наборе. Это критерий допуска, а не гарантия
безошибочности вне набора. Сравнение качества ИИ конкурентов пока не выполнено.
## 7. Проверка рыночной гипотезы и условия остановки
Параллельно этапу A провести 12–15 интервью с юристами и бухгалтерами, включая
пользователей действующих СПС. Собирать обезличенные задачи, частоту,
потери времени, критичные источники и причины отказа от нового сервиса.
Цены сравнивать после получения актуальных коммерческих предложений.
Предлагаемый пилот: 10–15 специалистов, 4 недели, 20 одинаковых задач с RU/KY
и простыми/сложными запросами. С согласия участников сравнить время и долю
правильных решений в текущем инструменте и «Акылдаш»; менять порядок систем,
чтобы уменьшить эффект обучения. Результат оценивает юрист по первоисточнику.
Цели ниже — предлагаемые пороги решения, не уже достигнутые показатели:
| Измерение | Предлагаемый порог | Решение |
| --- | --- | --- |
| Качество поиска | Recall@10 ≥ 0,90 и MRR@10 ≥ 0,75 на независимо проверенном наборе, отдельно RU и KY | При недостижении улучшать поиск до расширения функций |
| Практическая польза | Медианное время правильного выполнения задач меньше минимум на 25% | При отсутствии выигрыша пересмотреть сценарий и сегмент |
| Возвращаемость | Не менее 60% активированных участников пользуются продуктом на четвёртой неделе | При провале сначала исследовать причины, затем решать о биллинге |
| Готовность платить | Не менее трёх независимых организаций готовы на платный пилот по названной цене | Переходить к этапу E после проверки экономики |
| Доверие | Нет критических ошибок редакции/источника в приёмочных задачах | Любая такая ошибка блокирует публичные обещания надёжности |
Малый пилот помогает выбрать направление, но не даёт оценки доли рынка.
Если пользователям необходимы источники, которые мы не можем законно получить,
либо стоимость сопровождения превышает приемлемый тариф, сузить сегмент или
остановить выбранную модель до дальнейших вложений.
## 8. Ближайший рабочий цикл
1. Назначить владельца продукта и юридического редактора; согласовать сегмент.
2. Зафиксировать состояние корпуса и readiness gate на актуальной main.
3. Провести интервью, собрать задачи и заморозить критерии пилота.
4. Превратить этап A и минимальную часть B в отдельные задачи с зависимостями
и критериями приёмки. Согласовать связь с существующим планом frontend,
не отменяя его gate этим документом.
5. После пилота пересчитать сроки, стоимость и порядок C–F; выбрать, какие
пробелы действительно мешают продаже, а какие можно отложить.
Для реализации сохранять существующую архитектуру Python/SQLite/OpenSearch,
использовать текущие API и компоненты, добавлять зависимости только по
обоснованной необходимости. Изменения проводить отдельными PR с проверками,
версией при изменении кода, независимым review и changelog. Production-действия
и изменение инфраструктуры требуют действующих процедур доступа и backup.
---
Повторная сверка реализации: `main` `c8d0086a9f20485c5efe6f9d3ccd476dd9f07065`.
Backend v0.13.3 · Frontend v0.3.3 · Telegram-бот v0.2.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.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+97 -32
View File
@@ -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")
+338
View File
@@ -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,
}
+642
View File
@@ -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())
+35 -1
View File
@@ -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")
+139
View File
@@ -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()
+388
View File
@@ -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()
+51
View File
@@ -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
+20
View File
@@ -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
+69
View File
@@ -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())
+264
View File
@@ -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())
+32
View File
@@ -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)
+160
View File
@@ -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())
+126
View File
@@ -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())
+83
View File
@@ -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())
+67
View File
@@ -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}\"")"
+53 -2
View File
@@ -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())
+7
View File
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"repositories": {
"backend": ["git.vakanaut.ru/admin/akyldash/backend"],
"opensearch": ["git.vakanaut.ru/admin/akyldash/opensearch"]
}
}
+129
View File
@@ -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())
+79
View File
@@ -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)
+315
View File
@@ -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
+109
View File
@@ -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
View File
@@ -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__":
+145
View File
@@ -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()
+103
View File
@@ -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()
+117
View File
@@ -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()
+110
View File
@@ -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()
+155
View File
@@ -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()
+49 -11
View File
@@ -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()
+64
View File
@@ -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()
+355
View File
@@ -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()
+372
View File
@@ -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()
+39 -43
View File
@@ -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()
+15 -3
View File
@@ -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
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more