Compare commits

..
Author SHA1 Message Date
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
agent 2e77f6c3df Document frontend search release in changelog
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:53:01 +03:00
agent 28e42e8b55 Clear stale source link while loading editions
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:46:16 +03:00
agent 2d3f0a35c6 Fix public document edition state
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:27:37 +03:00
agent aae9657b9e fix(review): position tutorial hints beside targets
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 11:08:57 +03:00
agent a2ef8e0bf0 fix(frontend): refine visual match and modal focus
CI / tests-and-images (pull_request) Successful in 2m18s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-23 10:31:22 +03:00
agent 5a80c021d3 feat(frontend): complete public search visual review
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:34:39 +03:00
agent 133d847608 clarify baseline status in frontend plan
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:17:26 +03:00
agent 043d9d632d refine search screen visual alignment
CI / tests-and-images (pull_request) Successful in 3m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-23 00:16:18 +03:00
agent d01fdabd68 feat(frontend): implement legal search interface
Runner smoke test / runner-smoke (pull_request) Failing after 13m50s
CI / tests-and-images (pull_request) Failing after 13m55s
2026-10-23 00:00:41 +03:00
agent 84ae32b8a3 Merge pull request 'Keep rootless Docker accessible under runner hardening' (#52) from fix/runner-protecthome-rootless-socket into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-21 06:04:16 +00:00
agent 1f6129e009 Document runner ProtectHome fix
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 09:00:43 +03:00
agent d6799ec59b Preserve runner runtime path carve-out
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:57:00 +03:00
agent fcab9e1239 Keep rootless Docker socket visible to runner
CI / tests-and-images (pull_request) Successful in 2m8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:54:10 +03:00
agent d2fb4b81c0 Merge pull request 'Add bounded current MinJust API staging' (#51) from fix/current-minjust-api-ingestion into main
CI / tests-and-images (push) Successful in 2m7s
Runner smoke test / runner-smoke (push) Successful in 5s
2026-10-21 05:44:35 +00:00
agent f922f6e8c7 Document current MinJust staging release
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-21 08:38:34 +03:00
agent 06e350fb1e Fix MinJust overlap test under umask 000
CI / tests-and-images (pull_request) Successful in 2m9s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-21 08:34:07 +03:00
agent e522b2eee3 Test rejection of unknown corpus normalizer versions
CI / tests-and-images (pull_request) Failing after 8s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 02:00:07 +03:00
agent 228dfdaca0 Harden MinJust staging and legacy corpus verification
CI / tests-and-images (pull_request) Failing after 9s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-20 01:59:32 +03:00
agent 6357648c9c Add exact current MinJust API staging adapter
CI / tests-and-images (pull_request) Successful in 2m7s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 01:39:36 +03:00
agent b845316ee7 Merge pull request 'fix: validate digest-pinned rollback images safely' (#50) from fix/rollback-image-digest-reference into main
CI / tests-and-images (push) Successful in 2m6s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-19 21:41:47 +00:00
agent 2d3b34f34b Merge remote-tracking branch 'origin/main' into fix/rollback-image-digest-reference
CI / tests-and-images (pull_request) Successful in 2m10s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
# Conflicts:
#	CHANGELOG.md
#	docs/operations/CURRENT_STATE.md
2026-10-20 00:35:46 +03:00
agent cc8cc1bce6 Merge pull request 'Add offline release manifest contract' (#49) from feature/release-manifest-contract into main
CI / tests-and-images (push) Successful in 2m8s
Runner smoke test / runner-smoke (push) Successful in 1s
2026-10-19 21:29:33 +00:00
agent a68d3c315e docs: record rollback preflight limitation
CI / tests-and-images (pull_request) Successful in 2m5s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 00:27:44 +03:00
agent ba669fad34 Merge remote-tracking branch 'origin/main' into feature/release-manifest-contract
CI / tests-and-images (pull_request) Successful in 2m5s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
# Conflicts:
#	CHANGELOG.md
2026-10-20 00:18:34 +03:00
codex 8f64131131 Merge remote-tracking branch 'origin/main' into fix/rollback-image-digest-reference
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-20 00:15:33 +03:00
codex 802bfd117e test: assert digest rollback fails before side effects
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-10-20 00:13:55 +03:00
agent 2281705c95 Merge pull request 'Make corpus inventory scalable and resumable' (#48) from feature/resumable-corpus-inventory into main
CI / tests-and-images (push) Successful in 2m9s
Runner smoke test / runner-smoke (push) Successful in 1s
2026-10-19 21:10:48 +00:00
codex fc0473e7e2 fix: validate digest-pinned rollback images safely 2026-10-20 00:10:25 +03:00
agent e12a8a919c Document release manifest preparation scope
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 22:07:45 +03:00
agent 37050b1458 Add validated release manifest contract
CI / tests-and-images (pull_request) Successful in 2m7s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 22:02:09 +03:00
agent 6bd4b05af2 Document corpus inventory safety gates
CI / tests-and-images (pull_request) Successful in 2m30s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:39:34 +03:00
agent 0da2bcd82d Lock corpus artifacts during comparison
CI / tests-and-images (pull_request) Successful in 2m31s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:33:52 +03:00
agent 0979b8f544 Reject empty corpus staging comparisons
CI / tests-and-images (pull_request) Successful in 2m33s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:28:27 +03:00
agent 4d106903e1 Fix corpus inventory indexing and provenance
CI / tests-and-images (pull_request) Successful in 2m25s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 19:22:00 +03:00
agent 4b2e5578cd Add resumable corpus inventory verification 2026-10-18 15:29:14 +03:00
agent 0e276ae652 Merge pull request 'ci: isolate image builds on rootless runner' (#47) from feature/autonomous-review-workflow into main
CI / tests-and-images (push) Successful in 2m5s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-10-18 05:58:19 +00:00
codex 554a7ab5ff docs: qualify runner resource verification
CI / tests-and-images (pull_request) Successful in 2m6s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-18 08:55:16 +03:00
codex cf99f4532f docs: record successful isolated CI build
CI / tests-and-images (pull_request) Successful in 2m4s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:51:21 +03:00
codex 5d72bb092d fix: pass runner workspace to container actions
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:46:05 +03:00
codex d96fc4d36b ci: set container action entrypoints
CI / tests-and-images (pull_request) Failing after 9s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:37:44 +03:00
codex 436806a834 ci: exercise root guards under unprivileged uid
CI / tests-and-images (pull_request) Failing after 16s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-18 08:32:48 +03:00
codex 3a89dc2196 ci: build production images with isolated Kaniko
CI / tests-and-images (pull_request) Failing after 15s
Runner smoke test / runner-smoke (pull_request) Successful in 8s
2026-10-18 08:17:57 +03:00
agent 69c62466ca docs: complete autonomous review gates
Runner smoke test / runner-smoke (pull_request) Successful in 2s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-17 23:13:07 +03:00
agent 36892aef77 docs: define autonomous review workflow
Runner smoke test / runner-smoke (pull_request) Successful in 1s
CI / tests-and-images (pull_request) Has been cancelled
2026-10-17 23:07:12 +03:00
admin ac611cc878 Merge pull request 'Fix rootless Docker HOME for runner' (#45) from fix/rootless-docker-home into main
Runner smoke test / runner-smoke (push) Successful in 3s
CI / tests-and-images (push) Has been cancelled
Reviewed-on: #45
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-14 14:45:57 +00:00
131 changed files with 13404 additions and 598 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
FROM caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028
+16
View File
@@ -0,0 +1,16 @@
name: Validate Caddyfile
description: Run the pinned Caddy validator against the checked-out Caddyfile
inputs:
workspace:
description: Repository workspace path supplied by the workflow
required: true
runs:
using: docker
image: Dockerfile
entrypoint: /usr/bin/caddy
args:
- validate
- --config
- ${{ inputs.workspace }}/deploy/production/Caddyfile
- --adapter
- caddyfile
+1
View File
@@ -0,0 +1 @@
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
+22
View File
@@ -0,0 +1,22 @@
name: Build image with Kaniko in a rootless container
description: Validate a Dockerfile by building it without publishing the image or cache
inputs:
workspace:
description: Repository workspace path supplied by the workflow
required: true
dockerfile:
description: Dockerfile path relative to the checked-out repository
required: true
destination:
description: Image reference required by Kaniko; the image is not pushed
required: true
runs:
using: docker
image: Dockerfile
entrypoint: /kaniko/executor
args:
- --context=dir://${{ inputs.workspace }}
- --dockerfile=${{ inputs.workspace }}/${{ inputs.dockerfile }}
- --destination=${{ inputs.destination }}
- --no-push
- --no-push-cache
+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
+43 -7
View File
@@ -7,7 +7,7 @@ on:
jobs:
tests-and-images:
runs-on: ubuntu-latest
runs-on: akyldash-ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -19,6 +19,8 @@ jobs:
run: python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v
- name: Run infrastructure backup tests
run: python3 -m unittest discover -s infra/backup -p 'test_*.py' -v
- name: Check root guard UID decisions
run: bash infra/bootstrap/test-root-guard.sh
- name: Check recovery host guard
run: bash infra/bootstrap/test-host-guard.sh
- name: Check verifier helper argument guard
@@ -34,26 +36,60 @@ jobs:
bash infra/runner/test-runner-config.sh
bash infra/runner/test-install-runner-idempotency.sh
bash infra/runner/test-install-runner-guard.sh
- name: Check CI container operation isolation
run: bash infra/runner/test-ci-container-builds.sh
- name: Check infrastructure shell syntax
run: |
bash -n infra/bootstrap/bootstrap-ubuntu-24.04.sh infra/bootstrap/host-guard.sh \
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 \
infra/bootstrap/test-audit-host.sh infra/bootstrap/test-verify-helper.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 \
infra/runner/act-runner-healthcheck.sh infra/runner/test-runner-config.sh \
infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh
infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh \
infra/runner/test-ci-container-builds.sh
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
- name: Run rollback helper tests
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
run: |
secret_file=$(mktemp)
trap 'rm -f "$secret_file"' EXIT
REVIEW_SECRET_FILE="$secret_file" docker compose --env-file deploy/production/.env.example -f deploy/production/compose.yaml config --quiet
docker run --rm --entrypoint caddy -v "$PWD/deploy/production/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028 validate --config /etc/caddy/Caddyfile --adapter caddyfile
- name: Validate Caddyfile in isolated container action
uses: ./.gitea/actions/caddy-validate
with:
workspace: ${{ github.workspace }}
- name: Build production images
uses: ./.gitea/actions/kaniko-build
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.backend
destination: akyldash-backend:ci
- name: Build OpenSearch production image
uses: ./.gitea/actions/kaniko-build
with:
workspace: ${{ github.workspace }}
dockerfile: deploy/production/Dockerfile.opensearch
destination: akyldash-opensearch:ci
- name: Prepare a synthetic artifact compatibility marker
run: |
docker build -f deploy/production/Dockerfile.backend -t akyldash-backend:ci .
docker build -f deploy/production/Dockerfile.opensearch -t akyldash-opensearch:ci .
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.'
+1
View File
@@ -5,6 +5,7 @@ credentials.json
__pycache__/
.venv/
*.py[cod]
tools/frontend-visual-review/node_modules/
# Local environment files
.env
+90 -2
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). Не запрашивай доступы повторно.
@@ -9,7 +13,9 @@ Python, SQLite и OpenSearch. Production пока не развернут.
[деплой](docs/operations/DEPLOYMENT.md), [backup](docs/operations/BACKUP.md),
[rollback](docs/operations/ROLLBACK.md), [восстановление](docs/operations/RECOVERY.md).
- Работай в feature/fix/refactor-ветке. `main` защищена; изменения вноси через
PR, не сливай его автоматически. Сохраняй незакоммиченные изменения.
PR. DEV не одобряет и не сливает собственный PR. Условный merge выполняет
только REVIEWER по правилам раздела «Автономный workflow PR» ниже. Сохраняй
незакоммиченные изменения.
- SSH alias — `akyldash_vps`; используй только read-only диагностику. Не давай
`codex` общий sudo или доступ к Docker socket. Root-действия требует владелец.
Для Gitea используй `gitea-api`, не читай файлы с учётными данными.
@@ -34,3 +40,85 @@ 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
`01a144a5-8b95-77c3-b311-9a9afd062ca8`, host `local`; DEV — thread
`01a138f1-3b34-7c91-a9ed-4b43c3c85f1f`, host
`remote-ssh-codex-managed:712b8d4e-24e1-4dd5-92af-85d1fbd26449`, Gitea
`codex` (ID 6); REVIEWER — thread `01a144f9-5b51-7923-8398-61d4a6a98306`,
host `remote-ssh-codex-managed:b61555d7-8d1a-4cfa-988b-c8da4791d426`, Gitea
`agent` (ID 7). Если этот же чат отображает REVIEWER как `local` внутри его
собственного app-server, это self-label и не является маршрутом из контекста
оркестратора.
- DEV принимает только явно зарегистрированную оркестратором человеческую
задачу с полями `task`, `repo`, `branch`, `pr`, `head_sha`, `phase`. Репозиторий
должен быть `admin/akyldash`; работай только в назначенной ветке и PR. Веди
одну задачу/PR за раз и не подхватывай старые PR самостоятельно.
- Допустимые фазы: `initial_review` — реализация и устранение review findings;
`final_review_and_merge` — только после первоначального успешного review,
changelog-коммита и отдельного назначения оркестратора. Не переходи к следующей
фазе без назначения. Если задача приостановлена, останови любые действия;
после resume перепроверь назначение, PR, head/base, checks и review с нуля.
- Повторная доставка одного назначения идемпотентна по ключу
`task`+`pr`+`head_sha`+`stage`: не дублируй commit, push, review-запрос или
merge; сообщи оркестратору о уже выполненном результате. Новый head или stage
образует новое назначение и требует повторной проверки.
- Межчатовое чтение недоступно: оркестратор через central relay сам читает
результаты и выдаёт назначения. Завершай каждый этап отчётом с полями `task`,
`repo`, `branch`, `pr`, `head_sha`, `stage`, `checks`, `findings`,
`next_action`. Не настраивай отдельные webhook, daemon или транспорт.
- DEV исследует, изменяет, проверяет, коммитит и отправляет изменения; REVIEWER
не меняет код и независимо проверяет полный diff от base после каждого push,
включая предыдущие findings. DEV не одобряет и не сливает свой PR.
- Только после первоначального успешного формального review DEV добавляет
значимые изменения в существующий changelog, коммитит и отправляет их в тот же
PR. Затем REVIEWER полностью проверяет итоговый diff. Для docs-only задачи не
придумывай изменения кода; правила версии применяй только если они явно
относятся к этой документационной правке.
- Слияние может выполнить только REVIEWER для зарегистрированной человеческой
задачи, в фазе `final_review_and_merge`, после явного назначения оркестратора.
Непосредственно перед ним REVIEWER подтверждает `admin/akyldash`, PR, base
`main`, ожидаемую ветку, автора PR `codex` (ID 6), неизменность base и head,
причём head должен совпадать с зарегистрированным `head_sha`, включая
`head_commit_id` для merge-запроса. Требуются все успешные required CI/test
checks и gates, отсутствие pending/failed/unknown checks, открытых
findings и активных `REQUEST_CHANGES`, плюс актуальный формальный `APPROVED`
от REVIEWER для точного текущего commit с параметрами `commit_id == head_sha`,
`stale == false`, `dismissed == false`. При изменении head или base review и
проверки выполняются заново. Комментарий с approval не является формальным
review.
Используй только обычный server-side merge с `head_commit_id`; не используй
`force_merge` и не обходи branch protection. Если любой существенный gate,
статус или защита неизвестны либо не проверяются — не сливай PR и сообщи
оркестратору, что именно осталось неизвестным.
- Все Gitea API-вызовы выполняй только через `gitea-api` с личным окружением;
Git используй с обычным credential helper. Не читай credentials из файлов
репозитория. Запускай Git- и API-команды отдельно, без объединения с другими
командами.
+89
View File
@@ -2,6 +2,95 @@
## Не выпущено
- Успешный 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`: добавлен публичный поиск по правовой базе с фильтрами,
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
Serif.
- Исправлены переключение на доступный язык редакции, очистка содержимого и
ссылки на официальный источник при смене документа или ошибке загрузки;
подписи фильтров локализуются на кыргызский язык.
- Infrastructure: systemd unit Gitea Actions runner теперь использует
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
writable carve-out для rootless runtime; VPS не изменялся.
- Backend `0.12.1`: добавлен ограниченный возобновляемый staging редакций из
текущего API Минюста с проверкой точных ID и метаданных, изоляцией от
legacy-корпуса и сохранением исходного порядка редакций при нормализации и
экспорте. Тест overlap guard задаёт безопасные права fixture независимо от
`umask` CI; сам guard не ослаблен.
- Rollback preflight проверяет digest-pinned image references по сохранённому
image ID и останавливается до изменений deployment; переключение production
на digest и полноценный digest rollback пока не реализованы.
- Infrastructure: добавлен офлайн-контракт release manifest с точной схемой,
digest-ссылками и repository allowlist; проверен Compose override с
`--no-build` как требованием будущего deploy. Публикация, provenance и
production-деплой не включены.
- Добавлен read-only возобновляемый inventory корпуса с полным хешированием,
потоковой сверкой, индексируемыми диапазонами путей и provenance SHA-256
проверяющего; значения `stat` хранятся без ограничения SQLite INTEGER.
Сравнение source/staging сверяет путь, размер и SHA-256, удерживает shared
locks и отклоняет пустые корпуса без документов или фрагментов. Runbook и
`CURRENT_STATE.md` отражают, что полный аудит корпуса ещё выполняется; это
изменение не означает готовность корпуса или production.
- CI actions теперь используют workspace, переданный Gitea, для Caddyfile и Kaniko build context; CI run 69 и smoke run 70 прошли на self-hosted runner.
- Runner installer сохраняет `HOME` и XDG-пути в user-unit `docker.service`, перезапускает активный daemon и проверяет окружение его процесса.
- Runner installer защищает image и mountpoint root-owned home-каталогом, оставляет регистрацию и workspace в runner-owned каталоге, безопасно форматирует образ по открытому FD и проверяет `nosuid,nodev`.
- Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, Caddy HTTPS proxy с разделёнными Compose-сетями и CI-проверка конфигурации; публикация образов и production-деплой остаются выключены до проверки runner, registry, backup/restore и ограниченного deploy-helper.
+47 -2
View File
@@ -86,6 +86,15 @@ spacing:
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
ранних концепций не используется.
Изображение определяет целевую композицию, пропорции панелей и расположение
элементов поискового экрана. Эта спецификация определяет семантические токены,
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
План не является второй дизайн-системой и не означает завершение реализации.
**Key Characteristics:**
- Спокойное чтение и компактные, понятные элементы управления.
@@ -162,6 +171,34 @@ spacing:
Разворачивание документа и поиск внутри него реализуются только вместе
с работающим поведением, а не как декоративные кнопки.
### Композиция поискового экрана
- Первая строка desktop: знак и название, разделитель и подпись продукта
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
переключатель языка интерфейса справа. Служебные действия авторизованного
пользователя не вытесняют поиск и не меняют эту иерархию.
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
фильтры. Сведения о демонстрационном режиме показываются только в нём.
- Ниже расположены три смежные панели: фильтры, результаты, документ.
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
промежутков между самостоятельными карточками панелей.
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
и пару полей даты в одной строке при достаточной ширине. Вид элемента
выбора соответствует одиночному или множественному контракту API.
- Заголовок результатов и счётчик находятся слева, сортировка справа.
Высота строк зависит от содержимого; едины отступы и типографика.
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
и текстовый признак открытия.
- Панель документа начинается с контекстной навигации и разворачивания,
затем идут название, компактные метаданные, действия, поиск внутри
документа и единое поле чтения. Название документа имеет отдельную
типографическую роль, отличную от заголовков интерфейсных панелей.
- При коротком содержимом footer расположен у нижней границы окна;
длинное содержимое доступно прокруткой без перекрытия текста и управления.
Бренд находится слева, фактические версии frontend и backend справа.
- Поисковая страница не показывает инструменты оценивания лаборатории.
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
порядок результатов и оценивание слева, текст выбранной редакции справа.
Она не получает фильтры, аналитику или публичную навигацию исключительно
@@ -176,8 +213,9 @@ spacing:
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
область прокрутки.
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
проверки, а не заранее навязанные CSS breakpoints.
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
1280×800 — основной размер сравнения поискового экрана с референсом.
Это размеры проверки, а не заранее навязанные CSS breakpoints.
## Elevation & Depth
@@ -204,6 +242,13 @@ spacing:
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
На телефоне орнамент сокращается либо убирается.
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
части панели фильтров. Он не перекрывает длинные списки и не резервирует
место ценой недоступности управления. Знак бренда и орнамент поставляются
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
согласованные толщину линий, оптический размер и выравнивание.
## Components
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
+6 -4
View File
@@ -10,7 +10,9 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.11.3` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
согласованным отображением доступных действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
@@ -18,8 +20,8 @@ SQLite; первый пользователь `admin` создаётся инт
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.11.3` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -62,4 +64,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
+63 -5
View File
@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.11.3`
Версия: `0.13.0`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -54,6 +54,49 @@ result = sync_archive(output_path)
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
выбора будущего backend-фреймворка.
## Текущий API Минюста и отдельный staging
Старый OpenData endpoint может возвращать `404`; для отдельной ограниченной
выгрузки официальный сайт предоставляет `/api/v1/GetDocument` и
`/api/v1/GetEdition`. Адаптер `ingestion.minjust_current` получает один
указанный документ и сохраняет его в отдельное поколение staging:
```bash
PYTHONPATH=backend python3 -m ingestion.minjust_current \
--output /tmp/minjust-current-code6 \
--document-code 6
```
Продолжайте прерванную выгрузку только с тем же каталогом и `--resume`. По
умолчанию лимит скачанного текста одного документа — 4 GiB, лимит запросов —
один в секунду; каталог не должен совпадать или пересекаться с
`data/minjust-cbd` и
`data/minjust-normalized`. Адаптер сохраняет исходные ответы и checkpoints,
публикует папку документа атомарно и не заменяет существующий код в staging.
Каталог staging должен быть отдельной директорией режима 0700, принадлежащей
текущему пользователю; его родитель должен быть существующим доверенным
каталогом. Символические ссылки в generation, checkpoints и каталогах
документов отклоняются. Legacy-корни вычисляются от корня репозитория, а не
от текущего рабочего каталога.
Для `/GetEdition?lang=kg` официальный ответ помещает кыргызский HTML в
`contentRu` при `contentKg=null`; архивный язык определяется запрошенным
`lang`, а не именем поля ответа.
Исторические редакции запрашиваются только с `exact=true`; без этого параметра
официальный endpoint возвращал ID последней редакции даже для другого
запрошенного ID. Адаптер требует совпадения response ID и запрошенного ID,
сверяет названия RU/KY с metadata `GetDocument` и сохраняет request contract в
checkpoint. Старые staging generations без этого contract нельзя возобновить.
`exact=true` и совпадение ID проверены на двух контрольных редакциях; это не
подтверждает полноту всего источника.
Порядок редакций сохраняется по ответу `/GetDocument` и используется как
официальный порядок при нормализации и выборе текущей редакции в поисковом
индексе. Вложения и встроенные изображения этим адаптером не подтверждаются:
staging остаётся неполным до отдельной проверки медиа и не заменяет корпус.
Получение полного списка документов не является частью этой ограниченной
процедуры.
## Нормализация архива
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
@@ -128,16 +171,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 и
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
@@ -253,4 +311,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
+720
View File
@@ -0,0 +1,720 @@
"""Read-only staging client for the current MinJust CBD API."""
from __future__ import annotations
import argparse
import hashlib
import http.client
import json
import logging
import os
import sqlite3
import stat
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Callable
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
APP_VERSION = "0.13.0"
REQUEST_CONTRACT = {
"version": 2,
"endpoint": "/GetEdition",
"edition_id_parameter": "editionId",
"exact_parameter": "true",
"language_parameter": "lang",
"response_id_must_match": True,
}
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
DEFAULT_DOCUMENT_BUDGET = 4 * 1024 * 1024 * 1024
LANGUAGES = (("ru", "ru", "textRusType"), ("kg", "ky", "textKyrType"))
LOGGER = logging.getLogger(__name__)
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class SourceError(RuntimeError):
pass
class SourceChanged(SourceError):
pass
@dataclass(frozen=True)
class ApiResponse:
value: dict
body: bytes
sha256: str
class CurrentCbdClient:
def __init__(
self,
requests_per_second: float = 1,
timeout: int = 60,
retries: int = 5,
max_response_bytes: int = MAX_RESPONSE_BYTES,
opener: Callable = urllib.request.urlopen,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> None:
if requests_per_second <= 0 or timeout <= 0 or retries <= 0 or max_response_bytes <= 0:
raise ValueError("rate, timeout, retries, and response limit must be positive")
self.interval = 1 / requests_per_second
self.timeout = timeout
self.retries = retries
self.max_response_bytes = max_response_bytes
self.opener = opener
self.sleep = sleep
self.clock = clock
self.last_request = 0.0
def request_json(
self,
path: str,
*,
method: str = "GET",
parameters: tuple[tuple[str, object], ...] = (),
payload: dict | None = None,
) -> ApiResponse:
if not path.startswith("/") or ".." in path.split("/"):
raise ValueError("API path must be an absolute endpoint path")
query = urllib.parse.urlencode(parameters, doseq=True)
url = API_ROOT + path + (f"?{query}" if query else "")
body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode() if method == "POST" else None
for attempt in range(self.retries):
wait = self.interval - (self.clock() - self.last_request)
if wait > 0:
self.sleep(wait)
request = urllib.request.Request(
url,
data=body,
method=method,
headers={
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
},
)
try:
self.last_request = self.clock()
with self.opener(request, timeout=self.timeout) as response:
raw = response.read(self.max_response_bytes + 1)
if len(raw) > self.max_response_bytes:
raise SourceError(f"MinJust response exceeds {self.max_response_bytes} bytes")
value = json.loads(raw)
if not isinstance(value, dict):
raise SourceError("MinJust response must be a JSON object")
return ApiResponse(value, raw, hashlib.sha256(raw).hexdigest())
except urllib.error.HTTPError as error:
if error.code != 429 and error.code < 500:
raise SourceError(f"MinJust returned HTTP {error.code} for {path}") from error
delay = 2**attempt
if error.code == 429:
delay = max(delay, retry_after_seconds(error.headers.get("Retry-After")))
except (TimeoutError, urllib.error.URLError, http.client.IncompleteRead, json.JSONDecodeError) as error:
delay = 2**attempt
LOGGER.warning("MinJust request failed for %s (attempt %s/%s)", path, attempt + 1, self.retries)
last_error = error
if attempt + 1 < self.retries:
self.sleep(delay)
raise SourceError(f"MinJust request failed after {self.retries} attempts: {path}") from locals().get("last_error")
def document(self, alias: str) -> ApiResponse:
response = self.request_json("/GetDocument", parameters=(("DocumentCode", alias),))
value = response.value
canonical = value.get("documentCode")
editions = value.get("editions")
if not isinstance(canonical, int) or isinstance(canonical, bool) or canonical < 1:
raise SourceError("MinJust document has an invalid canonical documentCode")
if not isinstance(editions, list):
raise SourceError("MinJust document is missing editions")
ids = [edition_id(item) for item in editions]
if len(ids) != len(set(ids)):
raise SourceError(f"MinJust document {canonical} contains duplicate edition IDs")
return response
def edition(self, identifier: int, language: str) -> ApiResponse:
if identifier < 1 or language not in {"ru", "kg"}:
raise ValueError("edition ID or requested language is invalid")
response = self.request_json(
"/GetEdition",
parameters=(("editionId", identifier), ("lang", language), ("exact", "true")),
)
returned_id = response.value.get("id")
if not isinstance(returned_id, int) or isinstance(returned_id, bool) or returned_id < 1:
raise SourceError(f"MinJust returned an invalid edition response ID for {identifier}")
if returned_id != identifier:
raise SourceChanged(f"MinJust exact edition ID {returned_id} does not match requested ID {identifier}")
return response
def retry_after_seconds(value: str | None) -> float:
if not value:
return 0
try:
return max(0, float(value))
except ValueError:
try:
return max(0, (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError, OverflowError):
return 0
def edition_id(value: object) -> int:
if not isinstance(value, dict):
raise SourceError("MinJust document contains an invalid edition")
identifier = value.get("id")
if not isinstance(identifier, int) or isinstance(identifier, bool) or identifier < 1:
raise SourceError("MinJust edition has an invalid id")
return identifier
def open_manifest(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.execute("PRAGMA journal_mode=DELETE")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS documents (
alias TEXT PRIMARY KEY,
canonical_id INTEGER,
response_sha256 TEXT,
expected_editions INTEGER,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS editions (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
source_order INTEGER NOT NULL,
response_sha256 TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id)
);
CREATE TABLE IF NOT EXISTS languages (
canonical_id INTEGER NOT NULL,
edition_id INTEGER NOT NULL,
request_language TEXT NOT NULL,
archive_language TEXT NOT NULL,
response_sha256 TEXT,
content_sha256 TEXT,
byte_count INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (canonical_id, edition_id, request_language)
);
CREATE TABLE IF NOT EXISTS generation (
contract_sha256 TEXT NOT NULL
);
"""
)
return connection
def reject_symlink_components(path: Path) -> None:
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {current}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {current}")
def ensure_stage_path(root: Path, path: Path) -> Path:
root = root.absolute()
path = path.absolute()
try:
relative = path.relative_to(root)
except ValueError as error:
raise ValueError("staging file escaped its private generation") from error
current = root
if current.is_symlink():
raise ValueError("staging root must not be a symbolic link")
for part in relative.parts:
current /= part
try:
mode = current.lstat().st_mode
except FileNotFoundError:
continue
if stat.S_ISLNK(mode):
raise ValueError(f"staging path must not contain a symbolic link: {relative}")
if current != path and not stat.S_ISDIR(mode):
raise ValueError(f"staging parent must be a directory: {relative}")
return path
def stage_write(root: Path, path: Path, content: bytes) -> None:
path = ensure_stage_path(root, path)
atomic_write(path, content)
ensure_stage_path(root, path)
def stage_mkdir(root: Path, path: Path) -> None:
path = ensure_stage_path(root, path)
path.mkdir(parents=True, exist_ok=True)
ensure_stage_path(root, path)
if not path.is_dir():
raise ValueError("staging path must be a directory")
def validate_stage_tree(output: Path) -> None:
for relative in (
Path("current-api.sqlite3"), Path("current-api.sqlite3-journal"),
Path("current-api.sqlite3-wal"), Path("current-api.sqlite3-shm"),
Path("manifest.sqlite3"), Path("manifest.sqlite3-journal"),
Path("manifest.sqlite3-wal"), Path("manifest.sqlite3-shm"),
Path("request-contract.json"),
):
path = ensure_stage_path(output, output / relative)
if path.exists() and not path.is_file():
raise ValueError(f"staging checkpoint must be a regular file: {relative}")
for relative in (Path(".work"), Path("documents")):
root = ensure_stage_path(output, output / relative)
if root.exists() and not root.is_dir():
raise ValueError(f"staging path must be a directory: {relative}")
if root.is_dir():
for directory, directories, files in os.walk(root, followlinks=False):
for name in directories + files:
ensure_stage_path(output, Path(directory) / name)
def validate_stage_root(output: Path, resume: bool) -> Path:
requested = Path(output)
if ".." in requested.parts:
raise ValueError("staging output path must not contain '..'")
destination = Path(os.path.abspath(requested))
reject_symlink_components(destination)
parent = destination.parent
if not parent.is_dir():
raise ValueError("staging output parent must already exist")
for ancestor in (parent, *parent.parents):
info = ancestor.lstat()
mode = stat.S_IMODE(info.st_mode)
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise ValueError("staging output ancestors must be real directories")
sticky_parent = bool(mode & stat.S_ISVTX)
if mode & 0o022 and not sticky_parent:
raise ValueError("staging output parent is not trusted")
for relative in (Path("data/minjust-cbd"), Path("data/minjust-normalized")):
legacy = (REPOSITORY_ROOT / relative).resolve()
if destination == legacy or destination.is_relative_to(legacy) or legacy.is_relative_to(destination):
raise ValueError("current API staging must not overlap the existing corpus")
if destination.exists():
if destination.is_symlink() or not destination.is_dir():
raise ValueError("staging output must be a real directory")
else:
destination.mkdir(mode=0o700)
root_info = destination.lstat()
root_mode = stat.S_IMODE(root_info.st_mode)
if (
stat.S_ISLNK(root_info.st_mode)
or not stat.S_ISDIR(root_info.st_mode)
or root_info.st_uid != os.geteuid()
or root_mode & 0o077
or (root_mode & 0o700) != 0o700
):
raise ValueError("staging output must be a private directory owned by the current user")
validate_stage_tree(destination)
if any(destination.iterdir()) and not resume:
raise ValueError("staging output is not empty; use --resume for this generation")
contract_path = destination / "request-contract.json"
if contract_path.exists():
try:
contract = json.loads(contract_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise SourceChanged("staging generation request contract is invalid") from error
if contract != REQUEST_CONTRACT:
raise SourceChanged("staging generation uses a different request contract")
else:
if any(destination.iterdir()):
raise SourceChanged("staging generation has no exact-edition request contract; start a new generation")
stage_write(destination, contract_path, json_bytes(REQUEST_CONTRACT))
return destination
def stage_document(
output: Path,
alias: str,
client: CurrentCbdClient,
*,
resume: bool = False,
max_document_bytes: int = DEFAULT_DOCUMENT_BUDGET,
) -> dict:
"""Stage one document and its official editions without touching legacy roots."""
if not alias or len(alias) > 64 or "/" in alias or "\\" in alias:
raise ValueError("document code is invalid")
if max_document_bytes <= 0:
raise ValueError("document byte budget must be positive")
output = validate_stage_root(output, resume)
work_root = output / ".work" / "documents"
target_root = output / "documents"
connection = open_manifest(output / "current-api.sqlite3")
raw_manifest = connect_manifest(output / "manifest.sqlite3")
try:
contract_sha = hashlib.sha256(json.dumps(REQUEST_CONTRACT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
saved_contract = connection.execute("SELECT contract_sha256 FROM generation").fetchone()
if saved_contract and saved_contract[0] != contract_sha:
raise SourceChanged("checkpoint request contract changed; start a new staging generation")
if not saved_contract:
if connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0]:
raise SourceChanged("existing checkpoints have no request contract; start a new staging generation")
with connection:
connection.execute("INSERT INTO generation (contract_sha256) VALUES (?)", (contract_sha,))
response = client.document(alias)
source = response.value
canonical = source["documentCode"]
record_sha = response.sha256
editions = source["editions"]
previous = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias = ?",
(alias,),
).fetchone()
if previous and previous[1] is not None and (
previous[0] != canonical or previous[1] != record_sha or previous[2] != len(editions)
):
raise SourceChanged("document metadata changed; start a new staging generation")
working_document = work_root / str(canonical)
final_document = target_root / str(canonical)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.is_symlink():
raise ValueError("staging target must not be a symbolic link")
if final_document.exists() and not previous:
raise ValueError("staging target already exists without a matching checkpoint")
if final_document.exists():
final_metadata = ensure_stage_path(output, final_document / "metadata.json")
source_document = ensure_stage_path(output, final_document / "source-document.json")
metadata = json.loads(final_metadata.read_text(encoding="utf-8"))
if (
metadata.get("SourceApi", {}).get("response_sha256") != record_sha
or hashlib.sha256(source_document.read_bytes()).hexdigest() != record_sha
):
raise SourceChanged("published staging document does not match the current source record")
for edition in editions:
identifier = edition_id(edition)
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(
output,
final_document / "editions" / str(identifier) / f"{archive_language}.html",
)
checkpoint = connection.execute(
"SELECT state, content_sha256 FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if not edition.get(type_field):
if not checkpoint or checkpoint[0] != "unavailable":
raise SourceError(f"published staging document has no checkpoint for edition {identifier} {request_language}")
elif (
not checkpoint
or checkpoint[0] != "complete"
or not html_path.is_file()
or sha256_file(html_path) != checkpoint[1]
):
raise SourceError(f"published staging document failed verification for edition {identifier} {request_language}")
document_sha = hash_tree(final_document)
fetched_at = utc_now()
manifest_row = raw_manifest.execute(
"SELECT source_sha256 FROM documents WHERE code = ?", (canonical,)
).fetchone()
if manifest_row and manifest_row[0] != document_sha:
raise SourceError("published staging document does not match its raw manifest")
with raw_manifest:
raw_manifest.execute(
"INSERT OR IGNORE INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?) "
"ON CONFLICT(code) DO UPDATE SET message=excluded.message, failed_at=excluded.failed_at",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', error=NULL, updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
"resumed": True,
}
stage_mkdir(output, working_document)
document_metadata = map_document_metadata(source, alias, response.sha256)
stage_write(output, working_document / "metadata.json", json_bytes(document_metadata))
stage_write(output, working_document / "source-document.json", response.body)
with connection:
connection.execute(
"INSERT INTO documents (alias, canonical_id, response_sha256, expected_editions, state, updated_at) VALUES (?, ?, ?, ?, 'fetching', ?) "
"ON CONFLICT(alias) DO UPDATE SET canonical_id=excluded.canonical_id, response_sha256=excluded.response_sha256, "
"expected_editions=excluded.expected_editions, state='fetching', error=NULL, updated_at=excluded.updated_at",
(alias, canonical, record_sha, len(editions), utc_now()),
)
for order, edition in enumerate(editions):
identifier = edition_id(edition)
existing = connection.execute(
"SELECT source_order, response_sha256 FROM editions WHERE canonical_id = ? AND edition_id = ?",
(canonical, identifier),
).fetchone()
if existing and existing != (order, hashlib.sha256(json_bytes(edition)).hexdigest()):
raise SourceChanged(f"edition metadata changed for {identifier}")
connection.execute(
"INSERT OR IGNORE INTO editions (canonical_id, edition_id, source_order, response_sha256) VALUES (?, ?, ?, ?)",
(canonical, identifier, order, hashlib.sha256(json_bytes(edition)).hexdigest()),
)
incomplete = []
for order, edition in enumerate(editions):
identifier = edition_id(edition)
directory = working_document / "editions" / str(identifier)
stage_mkdir(output, directory)
stage_write(output, directory / "metadata.json", json_bytes(map_edition_metadata(edition, order)))
for request_language, archive_language, type_field in LANGUAGES:
html_path = ensure_stage_path(output, directory / f"{archive_language}.html")
checkpoint = connection.execute(
"SELECT state, content_sha256, byte_count FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
(canonical, identifier, request_language),
).fetchone()
if checkpoint and checkpoint[0] == "complete":
if not html_path.is_file() or sha256_file(html_path) != checkpoint[1]:
raise SourceError(f"staged content hash mismatch for edition {identifier} {request_language}")
continue
if not edition.get(type_field):
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "unavailable", "source metadata reports no language file",
)
continue
try:
language_response = client.edition(identifier, request_language)
response_id = language_response.value.get("id")
if isinstance(response_id, bool) or not isinstance(response_id, int) or response_id != identifier:
raise SourceChanged(f"MinJust exact edition ID mismatch for {identifier}")
for field in ("nameRus", "nameKyr"):
if edition.get(field) is not None and language_response.value.get(field) != edition[field]:
raise SourceChanged(f"MinJust exact edition metadata mismatch for {identifier}: {field}")
content = language_response.value.get("contentRu")
if not isinstance(content, str) or not content:
raise SourceError(f"MinJust returned no content for edition {identifier} language {request_language}")
encoded = content.encode("utf-8")
completed_bytes = connection.execute(
"SELECT COALESCE(SUM(byte_count), 0) FROM languages WHERE canonical_id = ? AND state = 'complete'",
(canonical,),
).fetchone()[0]
if completed_bytes + len(encoded) > max_document_bytes:
raise SourceError(f"document {canonical} exceeds its {max_document_bytes}-byte staging budget")
response_metadata = {
key: value
for key, value in language_response.value.items()
if key not in {"contentRu", "contentKg"}
}
response_metadata["requested_language"] = request_language
response_metadata["archive_language"] = archive_language
response_metadata["requested_edition_id"] = identifier
response_metadata["exact"] = True
response_metadata["response_sha256"] = language_response.sha256
stage_write(
output,
directory / "source" / f"{request_language}.json",
json_bytes(response_metadata),
)
stage_write(output, html_path, encoded)
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
language_response.sha256, hashlib.sha256(encoded).hexdigest(), len(encoded), "complete", None,
)
except Exception as error:
message = str(error)[:500]
save_language_checkpoint(
connection, canonical, identifier, request_language, archive_language,
None, None, 0, "error", message,
)
incomplete.append({"edition_id": identifier, "language": request_language, "error": message})
if isinstance(error, SourceChanged) or (isinstance(error, SourceError) and "budget" in message):
raise
if incomplete:
with connection:
connection.execute(
"UPDATE documents SET state='incomplete', error=?, updated_at=? WHERE alias=?",
(json.dumps(incomplete, ensure_ascii=False), utc_now(), alias),
)
return {"alias": alias, "canonical_id": canonical, "editions": len(editions), "incomplete": incomplete}
working_metadata = ensure_stage_path(output, working_document / "metadata.json")
metadata = json.loads(working_metadata.read_text(encoding="utf-8"))
metadata["SourceCompleteness"] = {
"text": "complete",
"attachments": "not independently verified",
}
stage_write(output, working_metadata, json_bytes(metadata))
stage_mkdir(output, target_root)
ensure_stage_path(output, working_document)
ensure_stage_path(output, final_document)
if final_document.exists():
raise ValueError("refusing to replace a document in this staging generation")
os.replace(working_document, final_document)
validate_stage_tree(output)
document_sha = hash_tree(final_document)
fetched_at = utc_now()
with raw_manifest:
raw_manifest.execute(
"INSERT INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
(canonical, document_sha, fetched_at),
)
raw_manifest.execute(
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)",
(canonical, "attachments and embedded media are not independently verified", fetched_at),
)
with connection:
connection.execute(
"UPDATE documents SET state='text_complete_incomplete_attachments', updated_at=? WHERE alias=?",
(fetched_at, alias),
)
return {
"alias": alias,
"canonical_id": canonical,
"editions": len(editions),
"source_sha256": document_sha,
"state": "text_complete_incomplete_attachments",
}
except Exception as error:
with connection:
connection.execute(
"INSERT INTO documents (alias, state, error, updated_at) VALUES (?, 'error', ?, ?) "
"ON CONFLICT(alias) DO UPDATE SET state='error', error=excluded.error, updated_at=excluded.updated_at",
(alias, str(error)[:500], utc_now()),
)
raise
finally:
raw_manifest.close()
connection.close()
def map_document_metadata(source: dict, alias: str, response_sha256: str) -> dict:
canonical = source["documentCode"]
return {
"Code": canonical,
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"Title": {"Rus": source.get("headerRu"), "Kyr": source.get("headerKg")},
"Number": source.get("number"),
"DateAdopted": source.get("dateAdopted"),
"DateInclusion": source.get("dateInclusion"),
"DateOfEntry": source.get("dateOfEntry"),
"DatePublication": source.get("datePublication"),
"DateRegistration": source.get("dateRegistration"),
"IsPublicInRegister": source.get("isPublicInRegister"),
"SourceStatus": source.get("status"),
"SourceClassId": source.get("classId"),
"SourceRefTypeId": source.get("refTypeId"),
"SourceAuthorityIds": source.get("authoritiesId"),
"SourcePublicationIds": source.get("sourcePublicationsId"),
"SourceMetadata": {key: value for key, value in source.items() if key != "editions"},
"SourceDocumentCode": alias,
"SourceMediaStatus": "not independently verified",
"SourceApi": {"endpoint": "/GetDocument", "response_sha256": response_sha256},
}
def map_edition_metadata(source: dict, source_order: int) -> dict:
return {
"Code": edition_id(source),
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
"SourceOrder": source_order,
"SourceTextTypes": {
"ru": source.get("textRusType"),
"ky": source.get("textKyrType"),
},
"SourceMediaStatus": "not independently verified",
"SourceMetadata": source,
}
def save_language_checkpoint(
connection: sqlite3.Connection,
canonical: int,
edition: int,
request_language: str,
archive_language: str,
response_sha256: str | None,
content_sha256: str | None,
byte_count: int,
state: str,
error: str | None,
) -> None:
with connection:
connection.execute(
"INSERT INTO languages (canonical_id, edition_id, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(canonical_id, edition_id, request_language) DO UPDATE SET "
"archive_language=excluded.archive_language, response_sha256=excluded.response_sha256, content_sha256=excluded.content_sha256, "
"byte_count=excluded.byte_count, state=excluded.state, error=excluded.error, updated_at=excluded.updated_at",
(canonical, edition, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, utc_now()),
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def hash_tree(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(item for item in root.rglob("*") if item.is_file()):
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b"\0")
digest.update(sha256_file(path).encode("ascii"))
digest.update(b"\0")
return digest.hexdigest()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True, help="new, separate staging generation")
parser.add_argument("--document-code", required=True, help="one official document alias or canonical code")
parser.add_argument("--resume", action="store_true", help="resume only this staging generation")
parser.add_argument("--requests-per-second", type=float, default=1)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--retries", type=int, default=5)
parser.add_argument("--max-document-bytes", type=int, default=DEFAULT_DOCUMENT_BUDGET)
parser.add_argument("--version", action="version", version=APP_VERSION)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
result = stage_document(
arguments.output,
arguments.document_code,
CurrentCbdClient(arguments.requests_per_second, arguments.timeout, arguments.retries),
resume=arguments.resume,
max_document_bytes=arguments.max_document_bytes,
)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return int(result.get("state") != "text_complete_incomplete_attachments")
if __name__ == "__main__":
raise SystemExit(main())
+15 -3
View File
@@ -23,9 +23,9 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.9.3"
APP_VERSION = "0.10.0"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
NORMALIZER_VERSION = "1.1.0"
LANGUAGES = ("ru", "ky")
LOGGER = logging.getLogger(__name__)
@@ -376,13 +376,16 @@ def load_json(path: Path) -> dict:
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
metadata = load_json(edition_directory / "metadata.json")
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
return {
summary = {
"source_code": str(metadata.get("Code", edition_directory.name)),
"name": bilingual(metadata.get("Name")),
"source_type": clean_value(metadata.get("Type")),
"available_languages": languages,
"source_path": edition_directory.relative_to(input_root).as_posix(),
}
if "SourceOrder" in metadata:
summary["source_order"] = metadata["SourceOrder"]
return summary
def normalize_document(
@@ -406,6 +409,15 @@ def normalize_document(
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
) if edition_root.is_dir() else []
summaries = [edition_summary(path, input_root) for path in edition_directories]
source_orders = [item.get("source_order") for item in summaries]
if any(order is not None for order in source_orders):
if any(not isinstance(order, int) or isinstance(order, bool) or order < 0 for order in source_orders):
raise ValueError(f"Invalid source edition order in {document_directory}")
if len(set(source_orders)) != len(source_orders):
raise ValueError(f"Duplicate source edition order in {document_directory}")
ordered = sorted(zip(source_orders, edition_directories, summaries), key=lambda item: item[0])
edition_directories = [item[1] for item in ordered]
summaries = [item[2] for item in ordered]
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
normalized_metadata = clean_value(metadata)
document = {
+41 -8
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
@@ -26,6 +26,16 @@ LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
MAX_RESULT_WINDOW = 10_000
STATIC_ASSETS = {
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
}
class ApiError(Exception):
@@ -303,6 +313,17 @@ class Api:
except (OSError, UnicodeError) as error:
raise ApiError(500, "review interface is unavailable") from error
@staticmethod
def static_asset(route: str) -> tuple[str, bytes]:
asset = STATIC_ASSETS.get(route)
if asset is None:
raise ApiError(404, "static asset not found")
path = Path(__file__).with_name("static") / asset[0]
try:
return asset[1], path.read_bytes()
except OSError as error:
raise ApiError(500, "static asset is unavailable") from error
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
parsed = urllib.parse.urlsplit(path)
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
@@ -324,21 +345,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}
@@ -374,7 +395,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")
@@ -386,12 +407,24 @@ def handler(api: Api):
self.end_headers()
self.wfile.write(body)
return
if method == "GET" and route in STATIC_ASSETS:
content_type, body = api.static_asset(route)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "public, max-age=604800, immutable")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
if not user:
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"}:
@@ -470,7 +503,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,))
+109 -17
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.11.3"
APP_VERSION = "0.13.3"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -129,7 +129,12 @@ def bulk_pairs(
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
if not editions:
continue
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
official_editions = document.get("editions") or []
current_edition = (
str(official_editions[-1]["source_code"])
if official_editions
else max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
)
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
@@ -337,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)
@@ -359,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
@@ -393,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}")
@@ -425,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")
@@ -455,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.3")
return 0
+1277 -102
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
import json
import os
import sqlite3
import tempfile
import unittest
import urllib.error
from email.message import Message
from pathlib import Path
from unittest.mock import Mock, patch
from ingestion import minjust_current
from ingestion.minjust_current import (
ApiResponse,
CurrentCbdClient,
SourceChanged,
SourceError,
stage_document,
)
def api_response(value):
body = json.dumps(value, ensure_ascii=False).encode()
import hashlib
return ApiResponse(value, body, hashlib.sha256(body).hexdigest())
def document_record(alias="6", canonical=6, editions=None):
return api_response(
{
"documentCode": canonical,
"nameRus": "Закон",
"nameKyr": "Мыйзам",
"headerRu": "Заголовок",
"headerKg": "Баш сөз",
"number": 114,
"dateAdopted": "1998-08-04",
"classId": 1,
"refTypeId": 10,
"status": 1,
"editions": editions if editions is not None else [
{"id": 920492, "nameRus": "Редакция A", "nameKyr": "Редакция A", "textRusType": "docx", "textKyrType": "docx"},
{"id": 920292, "nameRus": "Редакция B", "nameKyr": "Редакция B", "textRusType": "docx", "textKyrType": "docx"},
],
}
)
class CurrentCbdClientTests(unittest.TestCase):
def test_rejects_legacy_roots_when_called_from_an_unrelated_working_directory(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
repository = base / "repository"
elsewhere = base / "elsewhere"
elsewhere.mkdir()
legacy_roots = [
repository / "data/minjust-cbd",
repository / "data/minjust-normalized",
]
for legacy in legacy_roots:
legacy.mkdir(parents=True)
# Root CI uses umask 000, so make the trusted fixture's parent permissions explicit.
for directory in (repository, repository / "data", *legacy_roots):
directory.chmod(0o755)
original_cwd = Path.cwd()
client = Mock()
try:
os.chdir(elsewhere)
with patch.object(minjust_current, "REPOSITORY_ROOT", repository):
for legacy in legacy_roots:
with self.subTest(legacy=legacy), self.assertRaisesRegex(ValueError, "must not overlap"):
stage_document(legacy / "staging", "6", client)
self.assertFalse((legacy / "staging").exists())
finally:
os.chdir(original_cwd)
client.document.assert_not_called()
def test_requires_private_owned_staging_root(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "shared"
output.mkdir(mode=0o755)
output.chmod(0o755)
with self.assertRaisesRegex(ValueError, "private directory"):
stage_document(output, "6", Mock())
def test_resume_rejects_symlinked_checkpoints_and_document_paths(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
targets = [
("request-contract.json", False),
("current-api.sqlite3", False),
("manifest.sqlite3", False),
(".work/documents/6", True),
(".work/documents/6/editions/920492/ru.html", False),
("documents/6", True),
]
client = Mock()
for index, (relative, directory) in enumerate(targets):
with self.subTest(relative=relative):
output = base / f"generation-{index}"
output.mkdir(mode=0o700)
contract = output / "request-contract.json"
if relative != "request-contract.json":
contract.write_text(json.dumps(minjust_current.REQUEST_CONTRACT), encoding="utf-8")
outside = base / f"outside-{index}"
if directory:
outside.mkdir()
else:
outside.write_text("preserve", encoding="utf-8")
link = output / relative
link.parent.mkdir(parents=True, exist_ok=True)
try:
link.symlink_to(outside, target_is_directory=directory)
except (NotImplementedError, OSError):
self.skipTest("symlinks are unavailable")
with self.assertRaisesRegex(ValueError, "symbolic link"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
if not directory:
self.assertEqual(outside.read_text(encoding="utf-8"), "preserve")
else:
self.assertEqual(list(outside.iterdir()), [])
def test_respects_retry_after_and_retries_server_errors(self):
headers = Message()
headers["Retry-After"] = "7"
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
opener = Mock(side_effect=[
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
urllib.error.HTTPError("https://example.test", 503, "", Message(), None),
response,
])
delays = []
CurrentCbdClient(opener=opener, sleep=delays.append, clock=lambda: 0, retries=3).request_json("/GetEdition")
self.assertIn(7.0, delays)
self.assertIn(2, delays)
self.assertEqual(opener.call_count, 3)
def test_enforces_request_interval(self):
now = [0.0]
starts = []
response = Mock()
response.read.return_value = b"{}"
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
def opener(_request, timeout):
starts.append(now[0])
return response
client = CurrentCbdClient(
opener=opener,
clock=lambda: now[0],
sleep=lambda delay: now.__setitem__(0, now[0] + delay),
)
client.request_json("/GetEdition")
client.request_json("/GetEdition")
self.assertGreaterEqual(starts[1] - starts[0], 1.0)
def test_rejects_oversized_response(self):
response = Mock()
response.read.return_value = b"x" * 11
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceError, "exceeds 10 bytes"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response, max_response_bytes=10).request_json("/GetEdition")
def test_get_edition_uses_exact_query_and_checks_requested_id(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920292, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
requests = []
def opener(request, timeout):
requests.append(request)
return response
result = CurrentCbdClient(opener=opener, sleep=lambda _delay: None).edition(920292, "kg")
self.assertEqual(requests[0].full_url, "https://cbd.minjust.gov.kg/api/v1/GetEdition?editionId=920292&lang=kg&exact=true")
self.assertEqual(requests[0].method, "GET")
self.assertEqual(result.value["id"], 920292)
def test_rejects_response_id_that_does_not_match_exact_request(self):
response = Mock()
response.read.return_value = json.dumps({"id": 920492, "contentRu": "text", "contentKg": None}).encode()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with self.assertRaisesRegex(SourceChanged, "does not match requested ID"):
CurrentCbdClient(opener=lambda *_args, **_kwargs: response).edition(920292, "ru")
def test_stages_alias_mapping_language_quirk_and_official_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
output = base / "generation"
old_raw = base / "legacy" / "documents" / "6" / "sentinel"
old_raw.parent.mkdir(parents=True)
old_raw.write_text("preserve", encoding="utf-8")
class Client:
def document(self, alias):
self.alias = alias
return document_record(alias=alias, canonical=6)
def edition(self, identifier, language):
text = "<p>Russian</p>" if language == "ru" else "<p>Кыргызча</p>"
name = "Редакция A" if identifier == 920492 else "Редакция B"
return api_response({
"id": identifier, "nameRus": name, "nameKyr": name,
"contentRu": text, "contentKg": None,
})
client = Client()
result = stage_document(output, "6", client)
raw = output / "documents" / "6"
self.assertEqual(result["state"], "text_complete_incomplete_attachments")
self.assertEqual((raw / "editions/920492/ru.html").read_text(), "<p>Russian</p>")
self.assertEqual((raw / "editions/920492/ky.html").read_text(), "<p>Кыргызча</p>")
first = json.loads((raw / "editions/920492/metadata.json").read_text())
second = json.loads((raw / "editions/920292/metadata.json").read_text())
self.assertEqual((first["SourceOrder"], second["SourceOrder"]), (0, 1))
language_meta = json.loads((raw / "editions/920292/source/ru.json").read_text())
self.assertEqual((language_meta["requested_edition_id"], language_meta["exact"]), (920292, True))
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertTrue(connection.execute("SELECT contract_sha256 FROM generation").fetchone()[0])
self.assertEqual(old_raw.read_text(), "preserve")
with sqlite3.connect(output / "current-api.sqlite3") as connection:
rows = connection.execute(
"SELECT request_language, archive_language, state FROM languages ORDER BY request_language, edition_id"
).fetchall()
self.assertEqual(rows, [("kg", "ky", "complete")] * 2 + [("ru", "ru", "complete")] * 2)
def test_resolves_official_alias_to_canonical_staging_directory(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, alias):
return document_record(alias=alias, canonical=205064, editions=[])
result = stage_document(output, "1-4", Client())
self.assertEqual(result["canonical_id"], 205064)
self.assertTrue((output / "documents/205064/metadata.json").is_file())
self.assertFalse((output / "documents/1-4").exists())
def test_rejects_non_exact_edition_response_id_without_publishing(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920292, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, _identifier, language):
return api_response({"id": 920492, "contentRu": f"<{language} edition>", "contentKg": None})
with self.assertRaisesRegex(SourceChanged, "exact edition ID mismatch"):
stage_document(output, "6", Client())
self.assertFalse((output / "documents/6").exists())
with sqlite3.connect(output / "current-api.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT state FROM languages").fetchone()[0], "error")
def test_rejects_exact_edition_metadata_contradiction(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
client = CurrentCbdClient()
client.document = lambda _alias: document_record(editions=[{
"id": 920292, "nameRus": "04.08.1998", "nameKyr": "04.08.1998",
"textRusType": "docx", "textKyrType": "docx",
}])
client.request_json = lambda *_args, **_kwargs: api_response({
"id": 920292, "nameRus": "13.04.2017", "nameKyr": "13.04.2017",
"contentRu": "<wrong edition>", "contentKg": None,
})
with self.assertRaisesRegex(SourceChanged, "metadata mismatch"):
stage_document(output, "6", client)
self.assertFalse((output / "documents/6").exists())
def test_recovers_atomic_publish_before_manifest_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
connection.execute("UPDATE documents SET state='fetching' WHERE alias='6'")
with sqlite3.connect(output / "manifest.sqlite3") as connection:
connection.execute("DELETE FROM documents WHERE code=6")
connection.execute("DELETE FROM errors WHERE code=6")
resumed = stage_document(output, "6", client, resume=True)
self.assertEqual(resumed["source_sha256"], first["source_sha256"])
self.assertTrue(resumed["resumed"])
with sqlite3.connect(output / "manifest.sqlite3") as connection:
self.assertEqual(connection.execute("SELECT COUNT(*) FROM documents WHERE code=6").fetchone()[0], 1)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM errors WHERE code=6").fetchone()[0], 1)
def test_retries_after_document_metadata_request_failed_before_checkpoint(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
if not self.failed:
self.failed = True
raise SourceError("temporary DNS failure")
return document_record(editions=[])
client = Client()
with self.assertRaisesRegex(SourceError, "DNS"):
stage_document(output, "6", client)
result = stage_document(output, "6", client, resume=True)
self.assertEqual(result["canonical_id"], 6)
with sqlite3.connect(output / "current-api.sqlite3") as connection:
row = connection.execute(
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias='6'"
).fetchone()
self.assertEqual(row, (6, document_record(editions=[]).sha256, 0))
def test_does_not_resume_generation_without_exact_request_contract(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "legacy-generation"
output.mkdir(mode=0o700)
sentinel = output / "old-response.json"
sentinel.write_text("preserve", encoding="utf-8")
client = Mock()
with self.assertRaisesRegex(SourceChanged, "no exact-edition request contract"):
stage_document(output, "6", client, resume=True)
client.document.assert_not_called()
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_incomplete_translation_resumes_without_rewriting_successful_language(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
failed = False
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, language):
if language == "kg" and not self.failed:
self.failed = True
raise SourceError("simulated interrupted response")
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
client = Client()
first = stage_document(output, "6", client)
self.assertIn("incomplete", first)
self.assertFalse((output / "documents/6").exists())
second = stage_document(output, "6", client, resume=True)
self.assertEqual(second["state"], "text_complete_incomplete_attachments")
self.assertEqual((output / "documents/6/editions/920492/ru.html").read_text(), "<ru>")
self.assertEqual((output / "documents/6/editions/920492/ky.html").read_text(), "<kg>")
self.assertEqual(client.failed, True)
def test_refuses_budget_overrun_without_publishing_partial_document(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "generation"
class Client:
def document(self, _alias):
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
def edition(self, identifier, _language):
return api_response({"id": identifier, "contentRu": "123456", "contentKg": None})
with self.assertRaisesRegex(SourceError, "budget"):
stage_document(output, "6", Client(), max_document_bytes=5)
self.assertFalse((output / "documents/6").exists())
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -86,6 +86,47 @@ class MinjustNormalizationTest(unittest.TestCase):
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
self.assertFalse(empty["quality"]["has_html"])
def test_preserves_official_edition_order_instead_of_sorting_ids(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 920492, {"ru": "<p>Первая редакция</p>"})
self.edition(document, 920292, {"ru": "<p>Последняя редакция</p>"})
for code, order in ((920492, 0), (920292, 1)):
metadata_path = document / "editions" / str(code) / "metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = order
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
normalize_archive(source, output)
normalized = json.loads((output / "documents/6/document.json").read_text(encoding="utf-8"))
self.assertEqual([item["source_code"] for item in normalized["editions"]], ["920492", "920292"])
self.assertEqual([item["source_order"] for item in normalized["editions"]], [0, 1])
def test_rejects_partial_source_edition_order(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
source = base / "source"
output = base / "normalized"
document = self.document(source, 6)
self.edition(document, 10, {"ru": "<p>Один</p>"})
self.edition(document, 20, {"ru": "<p>Два</p>"})
metadata_path = document / "editions/10/metadata.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
metadata["SourceOrder"] = 0
metadata_path.write_text(json.dumps(metadata), encoding="utf-8")
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
result = normalize_archive(source, output)
self.assertEqual((result.failed, result.normalized), (1, 0))
with sqlite3.connect(output / "manifest.sqlite3") as connection:
error = connection.execute("SELECT error FROM documents WHERE code='6'").fetchone()[0]
self.assertIn("Invalid source edition order", error)
def test_continues_after_bad_document_and_clears_repaired_error(self):
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
+255 -19
View File
@@ -25,22 +25,223 @@ class MinjustOpenSearchTest(unittest.TestCase):
self.assertEqual(legal_force_code({"ru": "Указ", "ky": "Жарлыгы"}), "subordinate")
self.assertEqual(source_code("document_type", {"ru": "устав"}), "charter")
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")
def test_export_marks_last_official_edition_current_even_when_ids_are_out_of_order(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
input_root = root / "normalized"
document_root = input_root / "documents/6"
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 ('6', 'success')")
(document_root / "document.json").write_text(
json.dumps({"schema_version": "1", "source_code": "6", "editions": [
{"source_code": "920492", "source_order": 0},
{"source_code": "920292", "source_order": 1},
]}),
encoding="utf-8",
)
for edition in ("920292", "920492"):
text = f"Текст {edition}"
fragment_path = document_root / f"editions/{edition}/ru/fragments.json"
fragment_path.parent.mkdir(parents=True)
fragment_path.write_text(json.dumps([{
"id": f"document:6:edition:{edition}:lang:ru:fragment:1",
"document_code": "6",
"edition_code": edition,
"language": "ru",
"position": 1,
"type": "paragraph",
"text": text,
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
"source_path": f"documents/6/editions/{edition}/ru.html",
"source_sha256": "b" * 64,
}]), encoding="utf-8")
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}})
output = root / "bulk.ndjson"
self.assertEqual(export_bulk(input_root, output, "test-index"), (1, 2))
lines = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
flags = {
lines[index]["index"]["_id"].split(":")[3]: lines[index + 1]["is_current_edition"]
for index in range(0, len(lines), 2)
}
self.assertEqual(flags, {"920292": True, "920492": False})
def test_switches_alias_atomically_after_successful_load(self):
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_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:
@@ -116,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(
@@ -132,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"])
@@ -147,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"}}}},
@@ -182,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 = [
@@ -212,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(
@@ -228,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
@@ -251,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"
@@ -297,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(
+65 -2
View File
@@ -4,9 +4,9 @@ import io
import tempfile
import unittest
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):
@@ -24,6 +24,69 @@ class SearchApiTest(unittest.TestCase):
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
return Api("http://opensearch:9200", "current", root)
def test_static_font_assets_are_allowlisted(self):
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
self.assertEqual(content_type, "font/woff2")
self.assertTrue(body.startswith(b"wOF2"))
content_type, body = Api.static_asset("/static/favicon.svg")
self.assertEqual(content_type, "image/svg+xml")
self.assertIn(b"<svg", body)
content_type, body = Api.static_asset("/static/favicon-32.png")
self.assertEqual(content_type, "image/png")
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
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))
+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).
+1 -1
View File
@@ -45,4 +45,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
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.
+94 -16
View File
@@ -25,8 +25,13 @@ rootless Docker setup and user service use `.config/act-runner` as writable
`.config/act-runner/.local/share` as `XDG_DATA_HOME`. This keeps Docker config
and its systemd user unit in the existing `.config` tree without granting
write access to the protected home.
rootless Docker daemon
and runner user are separate from production Docker; `codex` is not added to
The runner systemd unit uses `ProtectHome=read-only` so `/run/user` remains
visible; `ReadWritePaths` retains the existing `/run/user/$uid` writable
carve-out for the runner's rootless runtime. `ProtectHome=yes` would hide
`/run/user` and break the configured runtime and Docker socket paths. See the
[systemd.exec documentation](https://www.freedesktop.org/software/systemd/man/255/systemd.exec.html#ProtectHome=).
The rootless Docker daemon and runner user are separate from production Docker;
`codex` is not added to
sudo or Docker groups. Jobs do not receive the rootless Docker socket, the
production Docker socket, production environment/secrets, or arbitrary host
bind mounts. The runner account has no access to `/etc/akyldash` or the
@@ -42,11 +47,65 @@ systemd timer checks the runner service and the rootless Docker API each minute;
on failure it restarts the fixed runner service. No UFW changes or public
metrics ports are needed. Runner cache is disabled.
The label `akyldash-ci` is used by `runner-smoke.yaml` on pull requests, pushes
to `main`, and manual dispatch. Existing CI image-build work remains on its current
`ubuntu-latest` target until it has been tested separately against the
isolated runner.
Production deployment is not automated.
The CI workflow and `runner-smoke.yaml` target the repository runner label
`akyldash-ci`. CI keeps the backend, Telegram, backup/restore, deployment,
bootstrap, runner, Compose, and Caddy checks. Caddy validation runs in a pinned
container action with explicit `/usr/bin/caddy` entrypoint. The workflow passes
Gitea's `${{ github.workspace }}` value into each container action; Caddy and
Kaniko use that supplied path for repository files instead of assuming a
container workspace location. Both production
Dockerfiles are built in a separate pinned Kaniko container action with explicit
`/kaniko/executor` entrypoint using the runner's rootless container engine; the
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 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 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`,
listed in the [official GitHub Packages entry](https://github.com/osscontainertools/kaniko/pkgs/container/kaniko/1271366984?tag=v1.28.5-debug).
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 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
@@ -136,9 +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. Only after the repository owner reviews this boundary should CI jobs be moved
to `akyldash-ci`. Do not add a production deploy workflow or secrets to this
runner until a separate restricted deploy mechanism is reviewed.
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
@@ -170,9 +231,26 @@ automatic recovery step.
## Status
The root installer and registration are incomplete. The owner reports that the
15 GiB ext4 loop-backed mount is active on `/dev/loop0` after manually running
`systemctl daemon-reload`, `enable`, and `start` when the first installer run
stopped at mount activation. This PR has not changed the VPS and does not claim
that remaining runner components are installed, a repository token is
available, the runner is Online, or a workflow has run.
The owner reports that the repository-scoped runner is registered, appears
Idle in Gitea, and its rootless Docker daemon works; the report date was not
recorded. Gitea status checked 2026-10-18 showed smoke run 62 successful and CI
run 61 pending on head `69c62466`. That workflow requested `ubuntu-latest`,
while the smoke workflow used `akyldash-ci`; the label difference is a
plausible explanation for the pending status, but the runner inventory API
returned 403 and the scheduler's reason was not independently confirmed.
On head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run
64 succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
with `exec: "validate": executable file not found`; Kaniko was skipped and smoke
run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the Caddy action
looked for `/github/workspace/deploy/production/Caddyfile`, which was not
mounted at that fixed path; Kaniko was skipped and smoke run 68 succeeded. On
head `5d72bb09`, CI run 69 and smoke run 70 succeeded. CI validated the Caddyfile
and built the backend and OpenSearch images using `${{ github.workspace }}`.
Actual per-action resource-limit enforcement and peak usage were not
independently measured or verified. Image publishing, host-wide resource
metrics, and production deployment remain unverified. Root-guard tests
use `setpriv` for negative cases when jobs start as UID 0 and separately test
the host-audit guard with simulated UID 0 and UID 65534; the real host audit,
production config setup, and runner installer are never executed as root by
those tests. No VPS changes are part of this PR.
+184
View File
@@ -0,0 +1,184 @@
# Corpus inventory and production handoff
The repository contains a read-only verifier for the ignored local corpus at
`data/minjust-cbd/` and `data/minjust-normalized/`. It does not download,
normalize, copy, index, or modify source files. Keep inventory artifacts outside
the repository and both source trees.
## Run the inventory
From the repository root, create a private artifact directory and run:
```bash
artifact_dir=$(mktemp -d /tmp/akyldash-corpus-audit.XXXXXXXX)
python3 infra/deploy/verify_corpus.py \
--raw-root data/minjust-cbd \
--normalized-root data/minjust-normalized \
--output-dir "$artifact_dir"
```
The tool writes `result.json`, `inventory.sqlite3`, and a persistent lock file
into that directory. Keep the directory private: the inventory contains source
paths and checksums. A second run against the same artifact directory is
rejected while a scan is active. Exit
status `0` means the scan and structural checks completed without errors; `1`
means findings were recorded or the run is incomplete; `2` means the arguments
or artifact path were invalid. Read `result.json` to distinguish those states.
Warnings, including documents with zero text fragments, are reported
separately and do not turn the scan into a failure.
For an interrupted run, use the same artifact directory and add `--resume`.
Resume rehashes every source file instead of trusting cached timestamps, then
reports added, changed, and removed paths relative to entries already stored
in the inventory. After resuming an interrupted first pass, those counters are
relative to the partial saved inventory; the full rehash and final checks still
cover every path. A successful result therefore describes the latest complete
pass. Run against a quiescent corpus; do not normalize, fetch, or copy files
into either source tree during the scan. Do not point `--output-dir` inside
either source tree or through a symlink. The verifier rejects such paths before
creating or changing them; it also flags symlinks, reparse points, hard links,
unsupported file types, and files that change while being read.
The inventory database schema is versioned. Resume requires the same root
metadata and verifier SHA-256 recorded in `result.json`; start a new artifact
directory after changing the verifier. `entries` counts files plus directories;
`files` and `directories` are separate counts. Document-prefix checks use
indexed path ranges, and stale-path reporting streams rows from SQLite.
The audit checks SQLite integrity, manifest/document directory agreement,
normalized schema and normalizer versions, source inventory checksums,
fragment identities and text hashes, required output files, and text assembled
from fragments. It does not establish the legal authority, completeness, or
semantic correctness of MinJust content.
## Compare source and staging before transfer
Run a complete inventory of the approved normalized source and the copied
staging tree into separate private artifact directories, using
`--normalized-root` for each. Require `status: complete` and
`verification_status: passed` in both results. The comparison command also
requires each manifest to contain at least one document and each normalized
tree to contain at least one fragment; an empty source or staging tree is a
failure.
```bash
python3 infra/deploy/compare_corpus_inventories.py \
"$source_artifacts/result.json" "$staging_artifacts/result.json"
```
The checker streams every relative path, size, and SHA-256 from both SQLite
inventories, and requires the same verifier/schema version and different
normalized roots. It opens both existing private `.inventory.lock` files
read-only and holds shared nonblocking locks through comparison; a missing,
unsafe, or busy lock fails closed. A zero exit status confirms a non-empty
staging tree matches the source. Keep both trees quiescent during their scans
and comparison. This is an equality check only; the owner still reviews the
code `6` disposition and approves the transfer destination.
## Current data disposition
As measured on 2026-10-18, both local manifests contain 209,958 documents and
pass SQLite `quick_check`. All normalized manifest entries are `success` at
schema `1` and normalizer `1.0.0`. The raw manifest records one fetch error for
code `6`, which is absent from its `documents` table; this does not prove that
the source document is absent. On 2026-10-18, bounded calls through the
repository's rate-limited MinJust client received HTTP `404` for
`GetDocumentListByQuery`, `GetDocument(6)`, control `GetDocument(1096)`, and
`CheckAvailable`. The listing call failed before returning a code list, and
both document calls failed identically. The repeated `404` is consistent with
the OpenData endpoint being unavailable or its route having changed, but does
not identify which. It is not evidence that code `6` is absent. The [public
document listing page](https://cbd.minjust.gov.kg/list-docs/kg) was reachable,
but it does not confirm this numeric API code. Do not describe the corpus as
fully verified or silently waive this error. Recheck the official listing and
API when the endpoint responds, then record an explicit owner disposition.
From the VPS, a metadata-only HTTPS check to `CheckAvailable` failed at the
HTTP/2 transport layer (`curl` exit 92, `INTERNAL_ERROR`, HTTP status `000`,
TLS verification `0`). An HTTP/1.1 retry was not completed.
## Current API and bounded code 6 recovery (2026-10-20)
The current official site uses `POST /api/v1/GetDocuments` (page size 12),
`GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded first-page probe
reported 210,639 records; this recovery does not enumerate the full list. The
official alias `1-4` resolves to canonical code `205064`. Code `6` is present
and has 201 editions. The edition order in `GetDocument` is authoritative;
numeric IDs do not define currentness.
For `lang=kg`, the API returns Kyrgyz HTML in `contentRu` and `contentKg=null`.
The bounded adapter maps that response by the requested language to archive
`ky`, preserves the response and checkpoints, limits requests to one per
second, caps JSON responses at 16 MiB, and caps one document's text at 4 GiB.
It writes only to a separate generation under `/tmp`; it neither resumes nor
overwrites the existing ignored corpus.
Without `exact=true`, a request for `920292` returned response field
`id=920492` despite different text length/hash. Two exact probes for `920292`
and `920492` returned matching IDs and names/dates (`04.08.1998` and
`13.04.2017`) matching `GetDocument`; their RU text lengths/hashes were
485,487 / `2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`
and 1,139,908 / `e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`.
The adapter now requires `exact=true`, returned-ID equality, and matching RU/KY
names before a language checkpoint can complete. Its versioned request
contract is part of both staging metadata and the SQLite resume identity;
pre-contract generations cannot be resumed.
The adapter does not independently validate attachments or embedded media.
Even after the text stage completes, the raw manifest records this as an
unresolved error. Do not merge this single document into the existing corpus,
claim corpus completeness, or transfer it to production without a separate
review and comparison. The prior no-exact pass at
`/tmp/akyldash-current-code6.ZAMC37u4ex` is marked quarantined in
`qualification.json`; its 240 text checkpoints and 304,493,520 bytes are
retained as unverified, with no document directory published. Its preserved
work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`. It must
not be reused or normalized.
The exact-only generation at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP`
contains 201 editions and 402 complete RU/KY text checkpoints (604,377,885
raw bytes). The raw manifest passes `quick_check`; its stored source SHA-256
matches the staged document tree at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`. Separate
normalization at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized`
passes `quick_check`, records normalizer `1.1.0`, and preserves first/last
edition order `920292` (`04.08.1998`) to `920492` (`13.04.2017`). The normalized
generation is about 2.05 GB. Neither temporary generation has been merged into
the existing corpus. Attachments and embedded media remain unverified.
The first full pass (run `b4a4556e30cc464d90bc8d75ef1673e9`) stopped before
validation with `Python int too large to convert to SQLite INTEGER` after
2,120,420 raw entries and 397,856 normalized entries. This was an inventory
metadata storage failure, not a corpus finding. The verifier now stores file
identity values as text and records its own SHA-256. A complete rehash with the
updated verifier is required; do not treat the failed pass or progress output
as a completed audit.
A fresh full rehash is now running in `/tmp/akyldash-corpus-audit-final.gtsD87vc`,
run `f4dd5e53020f442bbbecee2d0e4986a6`, verifier SHA-256
`83dac230099fc91972cd14e86e9f8ec72d13bca08f53d402f13a169e096d98f5`. Check
its private `result.json` for final counters, findings, and manifest comparison.
## Production transfer and indexing gates
Production deployment is not enabled. Before any transfer, the owner must
review the root-only host audit, confirm the target data location is empty or
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.
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.
+339 -56
View File
@@ -1,25 +1,167 @@
# Infrastructure current state
Last audit: 2026-10-12. Source: read-only SSH session as `codex`, owner-reported
UFW state, local repository inspection, and Gitea API. Root-only Docker state,
UFW rule details, and protected file metadata remain unverified.
## Proposed external deployment runtime binding — 2026-11-01
## VPS facts
- PR #59 now proposes a 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.
It fails closed for a stale host checkout, recovery journal, incomplete
listing, or the exact release already recorded as applied. The deploy
receiver still independently revalidates and uses the existing lock,
recovery journal, cold-backup ACK, cutover, and rollback protections.
- The Windows client proposes a disabled-by-default `--once` mode with a
fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
config and Task Scheduler XML are both disabled; the template uses an
interactive least-privilege owner, ignores overlapping invocations, and
does not wake the computer. No Windows task, SSH key, VPS command, helper,
or trigger config has been installed or exercised. Production CD remains
inactive.
- Remaining owner prerequisites are exact root forced-command/SSH host-key
setup for the dispatcher, installation of the reviewed Windows client under
a non-user-writable path, private ACLs for its config and D: backups, a
root-controlled checkout matching current `main`, and all previously listed
backup/restore and production readiness gates. The status endpoint does not
advance the host checkout and refuses to offer a stale release.
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
inventory audit.
- Resources: 4 vCPU, 7.7 GiB RAM, 3.8 GiB swap, 99 GiB disk with 92 GiB free
at this audit.
- Resources: 4 vCPU, 7.7 GiB RAM, 3.8 GiB swap, 99 GiB root filesystem with
84 GiB free on 2026-10-18. Runner storage is a separate 15 GiB ext4 loop
mount with 12 GiB free at that check.
- Network: interface `ens3`, IPv4 `206.223.249.118/32`.
- `codex` is UID/GID 1000, groups `codex` and `users`; `sudo -n true` requires
a password. Docker CLI cannot access `/var/run/docker.sock` as `codex`.
- SSH service is running. The inspected sshd configuration has public-key and
password authentication enabled and `PermitRootLogin yes`; no SSH changes
were made.
- Bootstrap completed. Docker CE/CLI `29.9.0`, Compose `5.6.0`, containerd
`2.4.1`, and runc `1.5.2` are installed; package versions and active Docker
service were independently checked. The Docker daemon API remains root-only.
- Bootstrap completed. Docker CLI `29.9.0` and Compose `5.6.0` were rechecked
over SSH on 2026-10-18; containerd `2.4.1` and runc `1.5.2` are from the
prior package audit. The Docker daemon API remains root-only.
- `/etc/docker/daemon.json` has the expected `local` log driver and `10m` / `3`
rotation limits. Bootstrap paths exist with root ownership: `/opt/akyldash`
mode `0755`, `/etc/akyldash` and `/var/backups/akyldash` mode `0700`, and
@@ -27,7 +169,10 @@ UFW rule details, and protected file metadata remain unverified.
- `/opt/akyldash/current`, verifier/rollback binaries, and the sudoers file are
absent. `codex` has not received sudo or Docker-group access. Metadata for
`/etc/akyldash/production.env` and `/etc/akyldash/review_secret` could not be
checked because `/etc/akyldash` is root-only.
checked because access is denied. The target
`/var/lib/akyldash/data/minjust-normalized` and its manifest metadata were
also denied; their presence, ownership, and contents are unknown. No sudo was
used.
- TCP listeners observed: SSH `22` and loopback DNS only; no listener on `1500`,
`80`, or `443`. No ISPmanager package or service unit was found through the
unprivileged checks; root can confirm with the audit script below.
@@ -38,24 +183,27 @@ UFW rule details, and 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,
HTTP, HTTPS, and `/review` still return Cloudflare `521`; 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
PRs enabled. `main` is protected and requires one approval; the current API
principal cannot push or merge directly to `main`.
- The repository runner-list API returns `403` because this API principal is
not the repository owner. The Actions task API currently reports no workflow
runs; this does not establish runner availability. Runner inventory and
package publishing permissions remain unverified.
not the repository owner. The latest verified CI run 75 and smoke run 76 both
succeeded on `main` head `0e276ae6`. The Actions run-list endpoint returned
`403` for this API principal during this recheck, so no newer run state was
independently retrieved. The owner reports the runner is registered and
Idle; repository runner inventory and package publishing permissions remain
unverified.
- No public application listener is observed on TCP `80`/`443`. Docker
container/volume state still requires the root audit. Repository Compose describes
Caddy `2.11.6` pinned by digest, Python Search API `0.11.3`, SQLite
account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
Caddy `2.11.6` pinned by digest, Python Search API `0.12.2` in this branch,
SQLite account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
- The target publishes only Caddy on TCP `80`/`443`. Backend and Caddy share a
proxy network; OpenSearch and backend share a separate internal network, and
backend has no host port. Caddy certificate storage, OpenSearch, and
@@ -78,41 +226,166 @@ UFW rule details, and 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.
- A repository-scoped official `act_runner` `0.2.13` installation is prepared
in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB ext4
loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU,
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 use the
`akyldash-ci` label and do not receive the rootless or production Docker
socket. The owner reports a partial runner installation: the 15 GiB ext4
loop-backed store is active on `/dev/loop0` after manually enabling and
starting its mount unit when the installer stopped. Remaining components,
repository registration, and Online state are unverified; this PR makes no
VPS changes. The reviewed installer locks the runner home to root ownership
(`0711`) so CI cannot replace the image or mountpoint, while writable config
and runtime state remain in runner-owned subdirectories. It reuses legacy
`0755` config directories, creates the image through a pinned file descriptor,
rejects path replacement, and requires mount options `nosuid,nodev`.
The reported installation blocker is that rootless setup receives this
protected directory as `HOME`; the installer assigns writable runner-owned
HOME/XDG paths, persists them in the `docker.service` user-unit drop-in, and
restarts an active daemon and checks the running process environment. Local
runner guards pass;
the change has not been run on the VPS.
- Gitea repository runner inventory could not be read by the current API
principal (403). The owner has not supplied runner state, and no runner
Online status or runner-hosted workflow is claimed. The manual
`runner-smoke.yaml` workflow is prepared but has not run.
tasks), rotated file logs, and a systemd health timer. Jobs target the
`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
12 GiB free. The separate `akyldash-runner-restart.service` remains failed
with exit status 1 since 2026-10-14; its journal had no entries, so the cause
is unknown. The latest verified main CI run 75 and smoke run 76 succeeded on
head `0e276ae6`. The Actions API returned 403 during this recheck, so no newer
run state was independently retrieved. A read-only SSH recheck on 2026-10-21
confirmed the active `akyldash-act-runner.service` runs as
`akyldash-runner` with `ProtectHome=read-only`; `ReadWritePaths` includes the
runner runtime/config/log paths and `/run/user/999`. Its `ExecStart` uses
`/etc/akyldash-runner/config.yaml`. The live unit retains a writable carveout
for `/run/user/999`; the installer keeps that existing carveout and changes
`ProtectHome` from `yes` to `read-only` so the runtime path remains visible.
No VPS unit was changed. Main commit
`d2fb4b81` passed CI run 115 and smoke run 116. On
head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run 64
succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
with `exec: "validate": executable file not found`; Kaniko was skipped and
smoke run 66 succeeded. On head `d96fc4d3`, CI run 67 failed because the
Caddy action's fixed `/github/workspace/...` path did not exist; Kaniko was
skipped and smoke run 68 succeeded. On head `5d72bb09`, CI run 69 and smoke
run 70 succeeded. CI validated the Caddyfile and built the backend and
OpenSearch images using `${{ github.workspace }}`. Actual per-action
resource-limit enforcement and peak usage were not independently measured or
verified. Image publishing, host-wide resource metrics, and production
deployment remain unverified. No VPS changes are made.
- An isolated local Compose smoke test built backend/OpenSearch images,
reached healthy status for OpenSearch, backend, and Caddy, and returned HTTP
200 for `/review` through Caddy. Its containers, networks, and volumes were
removed afterward. This does not verify VPS startup, DNS, or public TLS.
- The app has no migration framework or SMTP setup. Current app versions:
backend `0.11.3`, frontend `0.1.2`, Telegram bot `0.2.2` (the bot is not
- The app has no migration framework or SMTP setup. Repository versions on this
branch are:
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
deployed on this VPS).
## Corpus readiness (2026-10-18)
- The local raw archive and normalized corpus are ignored under `data/`; no
corpus files were changed. Their read-only SQLite manifests both pass
`quick_check` and each report 209,958 documents. The raw manifest is
28,295,168 bytes with SHA-256
`f8e3dd2fdec607583ca09cfdb5b76171bb7bfa1e41a0e07ee5aca8481e18e17e`; it has
one fetch error, code `6`, absent from `documents`. The normalized manifest
is 31,571,968 bytes with SHA-256
`1679c20e6d0f3e5a9b50737c006c4417a28ebcaf7467d194d3bf8243538f83bf`; all
entries are `success`, schema `1`, normalizer `1.0.0`.
- The read-only full-file inventory in `/tmp/akyldash-corpus-audit.Mjr6aa`
stopped before validation in run `b4a4556e30cc464d90bc8d75ef1673e9` with
`Python int too large to convert to SQLite INTEGER`. At stop it had processed
2,120,420 raw entries (1,081,428 files, 1,038,992 directories) and 397,856
normalized entries (246,321 files, 151,535 directories). This is a verifier
metadata storage failure, not a corpus finding. A full rehash with the
corrected, versioned verifier is required before readiness can be stated.
- A fresh full rehash is running in `/tmp/akyldash-corpus-audit-final.gtsD87vc`,
run `f4dd5e53020f442bbbecee2d0e4986a6`, with verifier SHA-256
`83dac230099fc91972cd14e86e9f8ec72d13bca08f53d402f13a169e096d98f5`.
`result.json` currently reports `scan_status: running`; its live counters
must be recorded after completion. Sources remain read-only.
- Through the repository client, bounded MinJust OpenData requests for
`GetDocumentListByQuery`, `GetDocument(6)`, control `GetDocument(1096)`, and
`CheckAvailable` each returned HTTP `404`. The list API returned no code list;
the direct requests do not establish whether code `6` exists. The public
listing page is reachable, but it does not map the numeric API code. A
metadata-only `CheckAvailable` request from the VPS failed in HTTP/2 with
`curl` exit 92 (`INTERNAL_ERROR`), HTTP status `000`, and TLS verification 0;
the HTTP/1.1 retry was not completed. Treat the code `6` disposition and
full corpus readiness as unresolved.
- On the VPS, read-only `stat` of `/var/lib/akyldash/data/minjust-normalized`
and its manifest was denied. Production corpus presence and metadata remain
unknown. No corpus was transferred and no production service, Docker socket,
runner policy, or VPS configuration was changed.
## Current MinJust API staging (2026-10-20)
- The public site's current API uses `POST /api/v1/GetDocuments` with page size
12, `GET /api/v1/GetDocument?DocumentCode=...`, and
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded page-1 probe
reported 210,639 filtered records; no full listing was enumerated for this
recovery. Official alias `1-4` resolves to canonical document code `205064`.
- Document code `6` has 201 official editions. The API's `lang=kg` response
places Kyrgyz HTML in `contentRu` while returning `contentKg=null`; staging
maps by requested language (`kg` to archive `ky`) and preserves the edition
order from the document response. Normalization and search export now retain
and use that order instead of sorting IDs.
- Without `exact=true`, one probe for edition `920292` returned response field
`id=920492` despite a different text length/hash. Two bounded `exact=true`
probes returned matching IDs and names/dates from `GetDocument`: edition
`920292` / `04.08.1998` (485,487 RU characters, SHA-256
`2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`) and
edition `920492` / `13.04.2017` (1,139,908 RU characters, SHA-256
`e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`).
Every edition request now uses `exact=true`, requires returned ID equality,
and checks RU/KY names against `GetDocument` metadata.
- `backend/ingestion/minjust_current.py` stages one document into a separate
generation, at no more than one request per second, with a 16 MiB response
limit, a 4 GiB text budget per document, contract-bound checkpoints, and
atomic publication. Staging requires a private 0700 directory owned by the
current user and rejects symlinks under checkpoints/document paths; legacy
corpus exclusions are anchored to the repository root. The corpus verifier
continues to validate unchanged normalized manifests from versions 1.0.0
and 1.1.0. The earlier non-exact pass is quarantined at
`/tmp/akyldash-current-code6.ZAMC37u4ex/qualification.json`: its 240 language
responses (304,493,520 bytes) remain unverified and are not reusable. The
retained work-tree SHA-256 is
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`.
- Exact-only code-6 staging is complete at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP`: 201 editions and 402
complete language checkpoints, 604,377,885 raw bytes. The raw manifest
passes `quick_check`; its document checksum matches the staged directory at
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`.
Separate normalization at
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized` contains 201
editions in official response order, passes `quick_check`, and records
normalizer `1.1.0` (2,048,797,024 bytes). The prior `data/minjust-cbd` and
`data/minjust-normalized` trees and production were not used as input or
modified. The raw staging manifest still records attachments and embedded
media as unverified; this single document does not establish corpus
completeness or production readiness.
## Checks not completed
- Docker Engine version from the server API, container and volume inventory,
@@ -122,8 +395,10 @@ UFW rule details, and protected file metadata remain unverified.
and restore of real production data remain unverified because production is
not deployed.
- Cloudflare edge response was checked; origin DNS target, origin certificate,
Full (strict), runner registration/Online state, registry publishing, and production
deployment have not been verified.
Full (strict), registry publishing, host-wide runner metrics, and production
deployment have not been verified. The
owner-reported Idle state has not been independently checked through runner
inventory because the API returned 403.
- This audit made no host changes. No package or service was installed by this
audit; the owner performed the initial bootstrap beforehand.
@@ -141,12 +416,20 @@ UFW rule details, and protected file metadata remain unverified.
3. Run `sudo bash /opt/akyldash/current/infra/deploy/install-helper.sh` after
reviewing the checkout; it grants only fixed verification, Compose
`config`/`status`, and guarded rollback commands.
4. After PR review and root audit, run
`sudo bash /opt/akyldash/current/infra/runner/install-runner.sh`. In the
repository Settings → Actions → Runners create a repository-level runner;
enter the token only at the interactive registration prompt as documented
in [CI_RUNNER.md](CI_RUNNER.md). Then enable the runner and health timer,
confirm **Online**, and dispatch the smoke workflow. Production deployment
remains manual and has no CI job.
4. After merging the reviewed PR, confirm a subsequent CI and smoke run on
`akyldash-ci`. Image publishing and production deployment remain manual and
are not part of CI.
5. Test backup/restore and rollback only with synthetic data in an isolated
environment before any production deployment.
## Change in this PR
Adds a read-only, resumable full-file corpus inventory and normalized-data
cross-check, a source/staging comparator requiring nonzero documents and
fragments plus held shared artifact locks, synthetic regression tests, and an
operator handoff runbook. CI's
existing `infra/deploy` unittest discovery includes the new tests. No workflow,
runner, VPS, production data, or Docker settings are changed. The first real
corpus scan stopped on an integer-storage bug; full validation with the
corrected verifier is still required. The source API's code `6` outcome remains
unconfirmed.
+158 -72
View File
@@ -1,83 +1,169 @@
# 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 implementation in PR #59 is not merged, installed, or exercised against
the VPS. 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 place the approved normalized corpus there
before deployment.
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:
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.
```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
```
## One-shot runtime binding proposed by PR #59
Application deployment remains owner-run. A repository-level Actions runner
installation is prepared in [CI_RUNNER.md](CI_RUNNER.md), but it is not yet
installed or registered; its Online state and smoke workflow are unverified.
The runner's job containers have no Docker socket or production secrets. CI has
no image publishing or production deployment step. The installed sudo helpers
support fixed verification, read-only Compose `config`/`status`, and guarded
rollback. They do not start or stop services.
PR #59 adds 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 fails closed on a stale root checkout, recovery journal,
incomplete Gitea run listing, failed source/workflow checks, invalid artifact
metadata/ZIP, or a release already applied. The candidate response contains
only a run ID, commit, and descriptor digest. The root receiver independently
repeats release verification and the existing backup/ACK/cutover guards.
## CI
The Windows client adds a one-shot `receive_external.py --once` mode. Its
configuration is fixed to `D:\Dev\akyldash` and the `akyldash_release` SSH
alias, and is disabled by default. It uses BatchMode, disables forwarding,
uses the existing host-key checking, bounds status output and time, then calls
the existing `run_release` path. Rollback remains a separate explicit
owner-run command. The example Task Scheduler XML is disabled, runs as the
interactive owner at least privilege, ignores overlapping instances, and
never wakes the computer. No task has been registered or enabled by this PR.
`.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.
Runner registration is incomplete, so neither workflow execution on that
runner nor current `ubuntu-latest` runner availability has been confirmed.
Before any activation, the owner must install the reviewed receiver and its
`archive.py` and `external_session.py` dependencies under a non-user-writable
`C:\Program Files\Akyldash` directory, set the config ACL so
only the owner, SYSTEM, and Administrators have full control, fill in the real
Python executable, owner SID, and local start time in the task template, and
confirm SSH host-key checking and the forced-command alias. Keep `enabled`
false and the task
disabled until the normal backup/restore and production readiness gates are
recorded. Do not bind a Gitea workflow or PR runner to this Windows host.
The root-side status endpoint requires `/opt/akyldash/current` to exactly
match current `main`; it does not update that checkout. An owner must advance
the root-controlled checkout through the approved installation process before
a release built from a newer commit can be considered ready. 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.
## Automation boundary: deployment is not active
The automatic chain currently ends at release publication:
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 an explicitly enabled
owner-controlled Windows trigger or by the manual release CLI. The proposed
`--once` path queries root's verified status endpoint and receives the
fixed tuple; no repository workflow invokes it. The example scheduler and
config remain disabled, and no Windows trigger has been installed or
exercised.
Production CD is still not active: no Windows task or credential is installed,
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 the sample JSON and Task Scheduler XML
reviewed and kept disabled until the owner explicitly accepts unattended
polling.
The installer intentionally does not create credentials, SSH entries, canary
values, registry permissions, or a new privilege rule. Existing files with
unexpected owner, mode, or content cause installation to stop for owner
review. Do not run the installer or receiver from CI. The owner's first use
must be a separate-target restore check and a reviewed dry run; this PR does
not authorize a production cutover.
The Windows client uses the existing OpenSSH host-key checking. Its configured
alias must reach a root-controlled endpoint capable of invoking the fixed
receiver; no new key or sudo permission is requested. Windows ACL and NTFS
checks, SSH host configuration, artifact redirect origin, registry pull
access, and every production health gate remain unverified until the owner
checks them.
@@ -0,0 +1,91 @@
# Deployment owner evidence packet
This packet is a list of owner-only checks. It does not authorize a production
change. Do not include token/password values, environment-file contents,
private keys, secret material, or raw process environments.
## Required before release use
- PR #59 proposes a disabled one-shot Windows trigger. No task or endpoint is
installed. If the owner chooses to activate it after merge and all other
gates, install the reviewed `receive_external.py`, `archive.py`, and
`external_session.py` client files in `C:\Program Files\Akyldash` with
write access limited to administrators/SYSTEM, copy
`infra/deploy/windows-release-trigger.example.json` to
`C:\ProgramData\Akyldash\release-trigger.json` and retain
`enabled:false`, then grant only the interactive owner, SYSTEM, and
Administrators full control on the config and its parent. Fill in the real
Python path, owner SID, and a future local start time in the XML template.
Review/import the task while
disabled; it runs as that logged-on owner at least privilege, ignores a
concurrent instance, does not wake the PC, and invokes only `--once`. Enable
the JSON and task only after the owner separately approves unattended
polling. Disable either one to stop future polls; an already running cold
backup/deploy must be allowed to finish or be explicitly recovered.
- The `akyldash_release` SSH key must be dedicated to the root forced-command
dispatcher, with OpenSSH `restrict` (or equivalent no-PTY/no-forwarding
options), verified host-key checking, and exact status/deploy command
matching. Do not put an unrestricted command or shell in root's
`authorized_keys`. The existing rollback key/path remains a separate manual
owner action. No key is generated, copied, or installed by this PR.
- Decide whether unattended production CD is acceptable. It currently is not
active: `deploy-handoff.yaml` remains status-only and reports `NOT DEPLOYED`;
the proposed local task and its config remain disabled. This avoids exposing
the Windows host to repository PR workflows. Verify that the root status
command is installed from a reviewed checkout, its forced-command key is
restricted, and the host checkout exactly matches current `main`. It does
not update that checkout automatically. The endpoint fails closed if the
filtered workflow-run API page is incomplete (more than ten candidates or
count mismatch), so status API behavior must be checked on the live Gitea
before activation.
- Confirm the registry package names and least-privilege writer/pull access for
`admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest
retention for the rollback window.
- Confirm Gitea artifact retention covers the rollback window. The workflow
requests 90 days for the small release descriptor and checksum.
- Provide a dated, sanitized root audit: installed checkout SHA, Compose
services and image IDs, health state, volume paths/modes, protected-file
owner/mode, listener summary, and current recovery-journal state.
- Confirm a root-controlled SSH path exists for the fixed deploy and rollback
commands, using existing owner SSH configuration and verified host-key
checking. The repository `codex` account must not receive general sudo,
Docker-group membership, or a Docker socket. No SSH key or root alias is
provisioned by this PR.
- If required, install a narrowly scoped, read-only Gitea API token at
`/etc/akyldash/gitea-api-token` with root ownership and mode `0600`. Record
only whether it is present and its scope; never send its value.
- Verify the Gitea artifact download origin. If redirected, provision only the
exact HTTPS origin in `/etc/akyldash/gitea-download-origins` as a root-owned
mode-`0600` file. The patched helper must not send its token to that origin.
- Provide owner-approved RU and KY search canary queries in
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`. Confirm they
return expected non-empty results without sending corpus documents.
- Confirm Cloudflare TLS mode, origin certificate renewal, and current public
`/review` and search health.
## Backup and recovery gates
- The complete archive must remain on the owner-controlled Windows destination
`D:\Dev\akyldash\.backups`; never stage a full archive on the VPS.
- Synthetic Windows protocol checks and a disposable private-NTFS owner-ACL
fixture passed. Before relying on the flow, run its checks against the actual
D: path and local `.git/info/exclude`, then verify an approved backup is
published only after hash and manifest verification.
- Confirm D: encryption at rest. Archives contain production secrets and
private keys; the protocol encrypts transport but does not encrypt files at
rest.
- Restore a verified archive on an isolated separate target and check data,
SQLite integrity, OpenSearch, and required secrets. Do not use production as
a restore test.
- Confirm current data/index counts, corpus generation, and migration
compatibility. Image rollback does not restore data or undo schema changes.
- Set the archive retention period and keep the previous verified archive
throughout the rollback window.
The installer does not create credentials, SSH configuration, canary values,
registry access, or new sudo rules. The PR has not been installed or run
against production. Windows validation passed 12 synthetic client checks, 8
protocol checks, and a disposable real-NTFS private owner-ACL fixture. The
actual D: path, SSH, full backup, and separate-target restore remain
unverified. Keep deployment disabled until the owner records evidence for
each gate above.
+28 -28
View File
@@ -1,34 +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. 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).
+2 -2
View File
@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.11.3`
- Backend: `0.12.2`
- Frontend: `0.1.2` — поиск, лаборатория релевантности, аккаунты
## Краткий итог
@@ -282,4 +282,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+355
View File
@@ -0,0 +1,355 @@
# План точного воспроизведения поискового интерфейса
Дата: 23 октября 2026 года.
Статус: основная реализация и локальная приёмка выполнены в
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
навигация, плотность результатов, заголовок документа, fixture-контент,
клавиатурный trap для диалогов и компактные позиционируемые подсказки
лаборатории. Скриншоты, покропленные сравнения и расширенный
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
сборка production images. Независимый review запускает владелец. Скринридер и
реальный корпус всё ещё требуют проверки.
## 1. Цель, границы и источники истины
Привести существующую авторизованную поисковую страницу `/` к композиции
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
Источники:
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
токены, типографика, доступность, постоянные правила компонентов.
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
этому сохранённому изображению более высокого разрешения.
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
готовности публичного продукта, которые этот план не отменяет.
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
Временные задачи и результаты измерений остаются здесь; новые постоянные
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
индекса, production, регистрация, аналитика, сравнение редакций, переход
на новый frontend framework. Содержимое демонстрационного документа
не становится юридическими данными продукта.
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
При необходимости совместного редактирования можно подготовить отдельный
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
это не заменяет проверку фактической вёрстки в браузере.
## 2. Исходное состояние до реализации
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
изображениям без браузера; строки не описывают текущее состояние. Последующие
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
состояние production или актуального OpenSearch.
Основные файлы:
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
сценариев, footer и авторизация.
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
фильтров и редакций.
- `backend/test_search_api.py` — существующие проверки API.
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
| Проблема | Подтверждение в реализации | Требуемый результат |
|---|---|---|
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
## 3. Этап 0 — воспроизводимый эталон
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; использовать установленный
Ponytail согласно правилам репозитория.
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
`feature/` ветку и не переносить посторонние изменения в PR.
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
три результата из изображения, выбран первый, открыта соответствующая
демонстрационная редакция, фильтр статуса и исходная прокрутка.
- [x] Изолировать fixture от реального режима и пометить демонстрационное
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
в production-путь.
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
пропорций и сохранить параметры подготовки эталона.
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
согласованные ограничения приведены в отчёте.
Критерий завершения: один и тот же тестовый сценарий воспроизводится
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
продолжает использовать API. Авторизация не ослабляется ради снимков.
## 4. Этап 1 — карта геометрии
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
около 1 CSS px.
| Область | Нормализованный эталон | Реализация |
|---|---:|---:|
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
| Footer | y ≈ 744–800 px | y = 744–800 px |
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
| Результаты | x ≈ 226–648 px | x = 226–648 px |
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
Внутренние контрольные координаты и ограничения см. в
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
разница по каждой крупной области.
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
документа по исходнику; фактические значения записаны в отчёте.
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
интервалы и разделители по всем областям.
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
реальные длинные данные ещё не проверены.
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
Не выравнивать принудительно высоту всех результатов или разделов.
- [ ] Определить владельца прокрутки каждой области, поведение footer
при коротком содержимом и доступность длинного документа.
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
а не независимыми приблизительными настройками каждого блока.
## 5. Этап 2 — разделение сценариев и базовых стилей
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
авторизацию, кабинет, управление пользователями, обучение и черновики.
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
- [ ] Использовать общий слой семантических токенов и базовых элементов;
стили поискового экрана изолировать BEM-классами.
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
static-файлов с MIME, защитой от обхода путей и сохранением защитных
заголовков. Убедиться, что ресурсы входят в production-образ.
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
Не вводить Next.js, React или новый сборщик только ради этой задачи.
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
их композицию случайным образом; существующие права доступа сохранены.
## 6. Этап 3 — шрифты, знак, орнамент и favicon
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
с лицензиями; исключить случайное синтетическое начертание.
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
Визуальные снимки выполнять после `document.fonts.ready`.
- [ ] Подготовить SVG-знак по визуальному направлению референса;
сверить размер, толщину линий и положение относительно названия.
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
`aria-hidden`, без перекрытия управления и текста.
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
Критерий завершения: шрифты реально загружены, знак и орнамент являются
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
## 7. Этап 4 — сборка композиции
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
демонстрационная подпись только для fixture. «Об источниках» открывает
реальные сведения о происхождении данных, а не пустую заглушку.
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
Сообщения для скринридера сохраняются.
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
орнамент в свободной нижней части без перекрытия длинного содержимого.
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
безопасная подсветка выдержки, пагинация.
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
компактные метаданные, язык, действия, поиск внутри, поле чтения.
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
не копировать устаревшую backend-версию и слово «макет» из изображения.
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
соответствуют эталону; нет лишних лабораторных блоков.
## 8. Этап 5 — рабочее поведение и интеграция
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
остальных; сохранение второго фильтра проверяется Playwright.
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
значения. Не имитировать множественный выбор checkbox без поддержки API.
Если нужен multiselect, отдельно описать и проверить расширение контракта.
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
- [ ] Не выдавать число на странице за общее: использовать явную подпись
либо отдельно реализовать достоверный total с проверкой его семантики.
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
при отсутствии структуры показать понятное состояние.
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
подсветка, активное совпадение, счётчик, предыдущий/следующий,
прокрутка и состояние нулевого результата. Не повреждать HTML,
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
прокрутки и корректного фокуса.
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей; служебные элементы проверены в print media.
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
через отмену запроса или проверку актуальности результата.
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
обработку ошибок и ограничения авторизации.
Критерий завершения: каждый видимый элемент эталона выполняет действие;
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
## 9. Этап 6 — адаптивность, типографика и состояния
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
Сверить переносы названия документа, результатов и плотность абзацев.
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
Высоты и параметры окружения записаны в отчёте.
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
breakpoints на реальных данных корпуса.
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
широкое изображение синтетическими данными.
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
длинный контент из реального корпуса.
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
диалогов, а также `prefers-reduced-motion`.
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
Критерий завершения: точность desktop не приводит к переполнению,
недоступности действий или потере контекста на узком экране.
## 10. Этап 7 — визуальная приёмка
Выполнить один пакет первичных снимков всех контрольных размеров,
собрать дефекты по областям, исправить одним пакетом и выполнить один
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
прокрутку; снимки делаются после `document.fonts.ready`.
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
login, ошибку или другой маршрут.
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
сравнения шапки, фильтров, результатов, документа и footer.
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
исправлениями и известными ограничениями.
- [ ] Передать независимому reviewer запрос пользователя, эталон,
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
Критерии для детерминированного fixture при 1280×800:
| Проверка | Условие приёмки |
|---|---|
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
Нулевая попиксельная разница не является критерием: сглаживание текста,
растровая природа концепта и нормативные токены дают объяснимые различия.
Один процент сходства всего экрана не заменяет проверки каждой области.
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
## 11. Этап 8 — проверки, версия и PR
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
API v1 не менялся.
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
config/runner guards, shell syntax и Playwright.
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
проверка Caddyfile и сборка backend/OpenSearch production images.
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
визуальное сравнение использует стабильный fixture.
- [x] Повысить версии для совместимого функционального изменения;
frontend/backend версии согласованы в footer.
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
изменения остаются в одном PR #53.
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
прежние исправления и оставляет краткий PR-комментарий с явным выводом
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
commit/push и независимый review.
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
существующий changelog, записав значимые изменения для пользователя.
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
и независимый review окончательного состояния.
Готовность к merge: проверки проходят, финальный review не содержит
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
доступны reviewer. Не сливать автоматически и не развёртывать production.
## 12. Порядок выполнения и итоговые материалы
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
Не начинать микроподгонку кнопок до исправления общей геометрии.
Ожидаемые материалы реализации:
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
- карта измерений, снимки, overlay/diff и отчёт приёмки;
- результаты обязательных проверок и независимого review;
- версии, changelog и один PR без автоматического merge.
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
которые реально выполнены, зафиксированы в отчёте visual-review.
Этот документ не подтверждает готовность API, публичного продукта или
production.
+1 -1
View File
@@ -262,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+334
View File
@@ -0,0 +1,334 @@
# Единый план корректировки интерфейса «Акылдаш»
Дата аудита: **23 октября 2026 года**.
Базовые пункты UI-01—UI-33 исправлены и вошли в `main` через PR #54.
На 24 октября 2026 года отдельный follow-up по замечаниям владельца для поиска,
личного кабинета и лаборатории реализован; локальный браузерный набор пройден.
Ожидаются CI и ручное PR review для pushed SHA.
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
Продуктовый roadmap и требования к юридической готовности остаются отдельными
документами: они не являются конкурирующими планами исправления интерфейса.
## 1. Источники истины и границы
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
ориентир, а не образец юридических данных или текущих функций.
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
документы не заменяют результаты этой браузерной проверки.
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
Backend-проверки ролей и операций управления пользователями не меняются.
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
корпус не меняются. Реальные учётные записи не используются в регрессионном
fixture.
## 2. Что проверено
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
с более широким desktop учитываются как дополнительные свидетельства,
но не выдаются за собственный браузерный прогон.
| Поверхность / сценарий | Выполнено | Ограничение |
|---|---|---|
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
Снимки: [каталог аудита](../design/audit-2026-10-23/).
Участки с реальными списками пользователей не переносятся в макеты Figma;
там используются явно условные примеры. Часть снимков лаборатории сделана
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
## 3. Реестр недостатков
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
улучшение понятности и последовательности. Предложения обозначены отдельно
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|---|---|---|---|
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
## 4. Целевая сетка, изменение ширины и фильтры
Это согласованное направление корректировки, а не уже существующие функции.
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
и лабораторный контент не вставляются друг в друга и не дублируют footer.
2. На широком desktop три колонки с двумя доступными разделителями.
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
в отдельную панель, затем документ становится отдельным представлением.
Пользовательское растягивание не может сломать соседнюю колонку.
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
видимый и клавиатурный способ сброса. Положение хранится локально;
при восстановлении ограничивается фактическим viewport.
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
колонки: краткие группы и раскрытие длинных справочников. Полный
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
Одиночный выбор API не выдаётся за множественный checkbox.
6. Длинные названия органов открываются в панели выбора с полными строками;
основное поле не становится бессмысленной обрезанной подписью.
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
ставятся друг под другом. Перенос текста внутри input недопустим.
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
документа допустима. Их заголовки/toolbar доступны при чтении.
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
и растущим main, а не `position: fixed` поверх содержимого. При длинном
контенте footer следует после него. Панель сохранения имеет собственное
место; sticky допускается только без перекрытия последней строки.
## 5. Единые правила всех окон и форм
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
Закрытие не спрятано в конце длинного содержимого.
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
Заголовок/закрытие остаются доступны при прокрутке содержимого.
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
возвращается на инициатор. Фон не интерактивен. Исключения для
обязательной смены пароля объясняются пользователю, не маскируются.
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
поверхности с понятным возвратом. Подтверждение удаления — короткое
целевое окно, а не второй длинный редактор за первым.
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
к администрированию определяется сервером, редизайн не меняет права.
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
пароль с доступным именем; подтверждение нового пароля как защита от
опечатки. Никакие реальные пароли не предзаполняются и не записываются
в материалы аудита. Изменение пароля выполняет человек.
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
Enter отправляет только активную форму. Ошибки рядом с полем и в
доступном сообщении, введённые допустимые данные сохраняются.
- При несохранённом редактировании — понятная защита от случайной потери;
обычное закрытие окна просмотра не требует лишнего подтверждения.
- Loading исключает повторную отправку; при успехе сообщается результат;
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
Основания для доступного поведения:
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
(паттерн APG находится в разработке; использовать как ориентир),
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
Крестик и структура кабинета — также прямое требование пользователя.
## 6. Реализация и проверяемое покрытие
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
сценарию. Playwright fixture использует только условные записи `admin`,
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
run завершился успешно.
| Пункты | Изменение | Регрессионное покрытие |
|---|---|---|
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
реальный zoom 200–400% остаётся непроверенным.
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
Снимки, overlay и pixel-difference с целевым изображением сохранены в
[каталоге visual review](visual-review/). Растровый diff является обзорной
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
Не мигрировать существующий Python frontend на React/Next.js ради этих
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
## 9. Follow-up замечания владельца: поиск, кабинет и лаборатория
Повторный просмотр трёх экранов выявил отдельные проблемы уже после PR #54.
Исправления остаются в этом плане, чтобы не создавать параллельный список:
| Поверхность | Наблюдение | Реализация и проверка |
|---|---|---|
| Поиск: выдача и чтение | Метка «Открыт» ставилась до ответа, могла оставаться после ошибки и дублироваться; оглавление пропускало структурные заголовки из жирных `p > b > span`; выбор языка был обрезан и расположен в навигации; в фильтрах оставался декоративный SVG с пустым резервом; активный пункт «Поиск» визуально смещался | Метка очищается при смене результата/редакции и появляется только после успешной загрузки; оглавление распознаёт самостоятельные заголовки раздела/главы/статьи на RU/KY, назначает уникальные якоря и строит вложенные списки; язык поиска находится в фильтрах с полной шириной; SVG и его место удалены; ссылки навигации используют палитру Акылдаш. Проверка: ошибки и гонки загрузки, заголовки RU/KY и обычные упоминания, уникальные якоря, диапазон и доступность splitter |
| Личный кабинет | Навигация выглядела ссылкой браузера; закрытие занимало строку текстом; подписи и значения профиля стояли в столбик; заголовок был крупным; появлялось горизонтальное переполнение; действие управления пользователями было неочевидным | Ссылки кабинета оформлены как действия с сохранением семантики `<a>`; крестик остаётся SVG-кнопкой 44×44 с доступной подписью; профиль выровнен в пары «поле — значение»; заголовок компактнее; длинные значения переносятся; администратор получает явную ссылку «Управление пользователями». Проверка: RU/KY, скрытие ссылки для не-администратора, фокус/закрытие диалога и ширина содержимого |
| Лаборатория | Дублировалась роль рядом с именем; ссылки навигации не имели вида кнопок; оболочка, форма, рабочая область, сохранение и footer имели разные границы; пустое сообщение резервировало высоту; панели выглядели разрозненными карточками; основной заголовок переносился; мобильный экран наследовал искусственную высоту | В шапке показывается только имя, роль остаётся в профиле; навигация сохраняет ссылки и получает единый вид кнопок; все участки выровнены по общему shell; пустой live status не добавляет зазор; результаты и документ образуют общую поверхность с внутренней прокруткой на desktop; заголовок использует доступную ширину и размеры по макету Figma; mobile возвращается к естественному потоку с прокруткой документа. Проверка: desktop/mobile, пустой status, сохранение после длинного документа и отсутствие page overflow |
Frontend повышен до **0.3.0** из-за добавления доступного изменения ширины
панелей. Backend и его API не менялись. Fixture данные искусственные; preview
слушает только loopback, запрещает запросы за пределы своего origin и не
использует авторизационные cookies или production API. Браузерный прогон прошёл
на 24 октября 2026 года; пять скриншотов сохранены в `current-progress` рядом
с отчётом assignment. CI и независимое ревью проверяются для конкретного pushed
SHA.
## 7. Приёмка: что значит «вылизанный интерфейс»
| Проверка | Условие |
|---|---|
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
Для визуального сравнения использовать изолированный детерминированный fixture
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
неработающий control или нечитаемый реальный контент.
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
процессу репозитория; автоматический reviewer не запускается. Production не
меняется автоматически.
## 8. Материалы и оставшаяся проверка
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
и не доказательство работоспособности функций. Учётные записи в ней
обезличены, текст документа помечен как пример оформления.
| Figma-экран | Ссылка |
|---|---|
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
обозначает намеренное продолжение содержимого. Drag-разделители,
закрытие и формы — спецификация будущего поведения, не работающий web-код.
Локальные экспорты целевых экранов для просмотра без Figma:
[поиск](../design/tunduk-search-target.png),
[лаборатория](../design/tunduk-review-target.png),
[кабинет](../design/tunduk-account-target.png).
Контрольные свидетельства live-сайта:
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
[кабинет](../design/audit-2026-10-23/account-desktop.png),
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
Browser evidence текущей ветки:
[desktop 1280×800](visual-review/desktop-1280x800.png),
[mobile 390 px](visual-review/mobile-390px.png),
[Figma overlay](visual-review/overlay-50.png) и
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
различия компоновки между live-target frame и deterministic fixture; pixel
diff не используется как pass/fail.
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
юридической корректности по законодательству не подтверждены этими тестами.
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
смена пароля, удаление/создание пользователей и отправка оценки в production.
Наличие макета не считается доказательством этих функций.
@@ -11,8 +11,13 @@
- загрузчик находится в `backend/ingestion/minjust_cbd.py`;
- сырой архив хранится в `data/minjust-cbd` и исключён из Git;
- в манифесте 209 811 успешно загруженных документов;
- 13 кодов остаются в таблице `errors` и отсутствуют в таблице `documents`;
- в read-only проверенном манифесте 209 958 документов;
- один код, `6`, остаётся в таблице `errors` и отсутствует в `documents`;
это не подтверждает отсутствие документа в источнике: при последней
проверке OpenData API возвращал HTTP `404` для списка, `GetDocument(6)`,
контрольного `GetDocument(1096)` и `CheckAvailable`;
- в нормализованном манифесте 209 958 успешных записей, schema `1`,
normalizer `1.0.0`; полная потоковая проверка всех файлов ещё выполняется.
- документ содержит `metadata.json` и каталог `editions`;
- редакция содержит `metadata.json`, `ru.html` и/или `ky.html`, иногда
изображения;
@@ -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. Это версии
исследованного кода; они не подтверждают развёртывание этих версий.
+1 -1
View File
@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
+100
View File
@@ -0,0 +1,100 @@
# Приёмка поискового экрана и лаборатории
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
## Снимки
- [1280×800 — основной экран](desktop-1280x800.png)
- [1440 px](desktop-1440px.png)
- [1024 px](desktop-1024px.png)
- [390 px — мобильный просмотр документа](mobile-390px.png)
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
- [Overlay 50% с приложенным референсом](overlay-50.png)
- [Карта абсолютной разницы пикселей](pixel-difference.png)
Покропленные сравнения областей для отдельного просмотра:
| Область | Overlay | Разница пикселей |
|---|---|---|
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
## Фактическая проверка
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
на y=744; footer — y=744–800.
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
Референсная подпись и элементы попадают в эти координаты с расхождением
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
- При 320 px также нет горизонтального переполнения.
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
на фильтрах, результатах, счётчике совпадений и открытом статусе.
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
кнопку открытия.
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
открытие редакции, поиск по документу
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
отображение поисковых подписей и выбранного статуса на кыргызском,
разворачивание документа, действия профиля/пользователей/выхода, мобильную
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
повтор после ошибки и отсутствие доступных редакций.
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
остальных URL-фильтров при удалении одного.
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
Полный скринридерный аудит этим не заменяется.
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
полупрозрачный фон и располагается у целевого элемента без пересечения.
Снимки первого шага сохранены для desktop и mobile.
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
странице перед снимком. Эти юридические тексты и результаты не входят в код
продукта. Для overlay и difference исходный растр 1586×992 вписывается
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
учётной записи и остаётся заметным дополнительным элементом справа во второй
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
доступный select; это функциональное расхождение пока оставлено явно видимым.
Содержимое документа, системный формат даты и сглаживание текста также не
совпадают попиксельно с концептом.
## Повторный запуск
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
`backend/search/review.html`, перехватывает API детерминированными fixture-
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
создаются, если найден исходный файл по стандартному пути или передан путь в
переменной `REFERENCE_IMAGE`.
## Оставшиеся визуальные отличия
Сглаживание текста и содержимое документа отличаются от растрового макета.
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
и поле языка поиска остаются разными состояниями; в макете язык показан как
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
показывают композицию, но не подтверждают юридическое содержимое или доступность
во всех браузерах.
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

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,
}
+507
View File
@@ -0,0 +1,507 @@
#!/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 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, 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")
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
MAX_STATUS_BYTES = 1024
STATUS_TIMEOUT_SECONDS = 180
NOT_READY_REASONS = {
"recovery_journal", "host_checkout_stale", "no_current_main_release", "main_advanced",
"already_applied",
}
MAX_ERROR_BYTES = 8192
_WINDOWS = os.name == "nt"
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
foreach ($rule in $acl.Access) {
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).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"] != "candidate" 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")
return {"version": 1, "action": "release", **release}
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 query_release_status(*, ssh: str = "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 = "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, ssh: str = "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 ready; deployment not started.")
return
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "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="query the fixed status endpoint and deploy at most one verified release")
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 any(
(args.run_id, args.commit, args.descriptor_sha256))
or args.ssh_alias != "akyldash_vps"):
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
run_release_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()
+314
View File
@@ -0,0 +1,314 @@
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("$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_candidate_tuple(self):
response = {"schema_version": 1, "state": "candidate", "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}'
))
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_disabled_is_noop_and_candidate_reuses_existing_release_flow(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:
receive_external.run_release_once(config_file=path)
query.assert_not_called()
deploy.assert_not_called()
config["enabled"] = True
path.write_text(json.dumps(config), encoding="utf-8")
candidate = {"version": 1, "action": "release", "run_id": "302",
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
query.return_value = candidate
receive_external.run_release_once(config_file=path)
deploy.assert_called_once_with(
candidate, repository=receive_external.DEFAULT_REPOSITORY,
ssh="ssh", ssh_alias="akyldash_release")
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),
'"C:\\Program Files\\Akyldash\\receive_external.py" --once',
)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(dirname "${BASH_SOURCE[0]}")
source "$script_dir/../test/run-as-unprivileged.sh"
output=$(mktemp)
trap 'rm -f "$output"' EXIT
if bash "$(dirname "$0")/audit-host.sh" >"$output" 2>&1; then
echo 'Host audit unexpectedly succeeded without root.' >&2
if run_as_unprivileged bash "$script_dir/audit-host.sh" >"$output" 2>&1; then
echo 'Host audit unexpectedly succeeded as an unprivileged UID.' >&2
exit 1
fi
grep -qx 'Run this read-only audit as root.' "$output"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(dirname "${BASH_SOURCE[0]}")
guard=$(mktemp)
output=$(mktemp)
trap 'rm -f "$guard" "$output"' EXIT
awk '
/^if \[\[ \$EUID -ne 0 \]\]; then$/ { capture = 1 }
capture { print }
capture && /^fi$/ { found = 1; exit }
END { if (!found) exit 1 }
' "$script_dir/audit-host.sh" | sed 's/\$EUID/\$TEST_EUID/g' >"$guard" || {
echo 'Could not extract the host audit root guard.' >&2
exit 1
}
if ! grep -Fq 'if [[ $TEST_EUID -ne 0 ]]; then' "$guard"; then
echo 'Extracted guard does not use the simulated effective UID.' >&2
exit 1
fi
if TEST_EUID=0 bash "$guard" >"$output" 2>&1; then
[[ ! -s $output ]] || { cat "$output" >&2; exit 1; }
else
echo 'Host audit root guard rejected simulated UID 0.' >&2
exit 1
fi
if TEST_EUID=65534 bash "$guard" >"$output" 2>&1; then
echo 'Host audit root guard accepted simulated non-root UID.' >&2
exit 1
fi
grep -qx 'Run this read-only audit as root.' "$output"
echo 'PASS host audit root guard accepts UID 0 and rejects non-root UIDs'
+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
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Require non-empty completed normalized audits and compare every file row."""
from __future__ import annotations
import argparse
import fcntl
import itertools
import json
import os
import sqlite3
import stat
import sys
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Any
class ComparisonError(ValueError):
pass
@contextmanager
def shared_inventory_lock(result_path: Path, label: str):
lock_path = result_path.absolute().parent / ".inventory.lock"
if not hasattr(os, "O_NOFOLLOW"):
raise ComparisonError(f"{label}: safe inventory lock opens are unsupported")
try:
descriptor = os.open(lock_path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise ComparisonError(f"{label}: cannot open inventory lock: {error}") from error
try:
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or metadata.st_nlink != 1
or stat.S_IMODE(metadata.st_mode) & 0o077
):
raise ComparisonError(f"{label}: inventory lock must be a private regular file")
try:
fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError as error:
raise ComparisonError(f"{label}: inventory is active (lock busy)") from error
yield
finally:
os.close(descriptor)
def read_audit(result_path: Path, label: str) -> tuple[sqlite3.Connection, dict[str, Any]]:
result_path = result_path.absolute()
try:
metadata = result_path.lstat()
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
raise ComparisonError(f"{label}: result.json must be a regular file")
result = json.loads(result_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise ComparisonError(f"{label}: cannot read result.json: {error}") from error
if not isinstance(result, dict):
raise ComparisonError(f"{label}: result.json must contain an object")
if result.get("status") != "complete" or result.get("verification_status") != "passed":
raise ComparisonError(f"{label}: inventory must be complete and passed")
verifier_sha256 = result.get("verifier_sha256")
if not isinstance(verifier_sha256, str) or len(verifier_sha256) != 64:
raise ComparisonError(f"{label}: verifier SHA-256 is missing")
normalized_root = result.get("roots", {}).get("normalized")
if not isinstance(normalized_root, str):
raise ComparisonError(f"{label}: normalized root is missing")
summary = result.get("manifest_summary", {}).get("normalized")
if not isinstance(summary, dict):
raise ComparisonError(f"{label}: normalized manifest summary is missing")
for key in ("documents", "fragments"):
if not isinstance(summary.get(key), int) or summary[key] <= 0:
raise ComparisonError(f"{label}: normalized {key} must be greater than zero")
database_path = result_path.with_name("inventory.sqlite3")
try:
db_metadata = database_path.lstat()
if (
stat.S_ISLNK(db_metadata.st_mode)
or not stat.S_ISREG(db_metadata.st_mode)
or db_metadata.st_nlink != 1
):
raise ComparisonError(f"{label}: inventory.sqlite3 must be a regular unlinked file")
for suffix in ("-wal", "-shm", "-journal"):
if Path(f"{database_path}{suffix}").exists():
raise ComparisonError(f"{label}: SQLite sidecar files are present")
connection = sqlite3.connect(database_path.resolve().as_uri() + "?mode=ro&immutable=1", uri=True)
connection.row_factory = sqlite3.Row
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ComparisonError(f"{label}: inventory database failed quick_check")
manifest_documents = connection.execute(
"SELECT count(*) FROM manifest_docs WHERE root_id='normalized'"
).fetchone()[0]
if manifest_documents != summary["documents"]:
raise ComparisonError(f"{label}: normalized document count does not match inventory")
files, byte_count, unfinished = connection.execute(
"SELECT count(*),coalesce(sum(size),0),"
"sum(CASE WHEN status!='hashed' OR size IS NULL OR sha256 IS NULL THEN 1 ELSE 0 END) "
"FROM files WHERE root_id='normalized'"
).fetchone()
progress = result.get("progress", {}).get("normalized", {})
if unfinished or files != progress.get("files") or byte_count != progress.get("bytes"):
raise ComparisonError(f"{label}: inventory rows do not match completed scan counters")
return connection, {
"root": normalized_root,
"documents": summary["documents"],
"fragments": summary["fragments"],
"files": files,
"bytes": byte_count,
"verifier_sha256": verifier_sha256,
"schema_version": result.get("schema_version"),
}
except sqlite3.Error as error:
raise ComparisonError(f"{label}: cannot validate inventory database: {error}") from error
def compare_inventories(source_result: Path, staging_result: Path) -> dict[str, int]:
with ExitStack() as locks:
locks.enter_context(shared_inventory_lock(source_result, "source"))
locks.enter_context(shared_inventory_lock(staging_result, "staging"))
source_db, source = read_audit(source_result, "source")
try:
staging_db, staging = read_audit(staging_result, "staging")
except Exception:
source_db.close()
raise
try:
if source["root"] == staging["root"]:
raise ComparisonError("source and staging must be different normalized roots")
if source["verifier_sha256"] != staging["verifier_sha256"] or source["schema_version"] != staging["schema_version"]:
raise ComparisonError("source and staging audits must use the same verifier and schema")
if (source["documents"], source["fragments"]) != (staging["documents"], staging["fragments"]):
raise ComparisonError("source and staging document or fragment counts differ")
query = (
"SELECT relative_path,size,sha256 FROM files "
"WHERE root_id='normalized' ORDER BY relative_path"
)
left = iter(source_db.execute(query))
right = iter(staging_db.execute(query))
mismatch_count = 0
for source_row, staging_row in itertools.zip_longest(left, right):
if source_row != staging_row:
mismatch_count += 1
if mismatch_count:
raise ComparisonError(f"source and staging differ in {mismatch_count} inventory rows")
return {"documents": source["documents"], "fragments": source["fragments"], "files": source["files"], "bytes": source["bytes"]}
finally:
source_db.close()
staging_db.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source_result", type=Path)
parser.add_argument("staging_result", type=Path)
arguments = parser.parse_args()
try:
summary = compare_inventories(arguments.source_result, arguments.staging_result)
except (ComparisonError, OSError) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
print(
"PASS: matched "
f"{summary['documents']} documents, {summary['fragments']} fragments, "
f"{summary['files']} files, {summary['bytes']} bytes"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+257
View File
@@ -0,0 +1,257 @@
#!/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
if latest_main_sha(ROOT) != manifest["commit"]:
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
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")
repository_revision = run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
if repository_revision != manifest["commit"]:
raise ValueError("host checkout does not match the verified release commit")
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)
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)
+154
View File
@@ -0,0 +1,154 @@
#!/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
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 {}),
)
with deployment_lock():
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()
if revision != manifest["commit"]:
raise ValueError("installed host checkout does not match the verified release commit")
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())
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Read-only selector for one fully verified current-main release candidate."""
from __future__ import annotations
import json
import os
import re
import subprocess
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
from release_state import read_json, validate_state
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
STATUS_MAX_BYTES = 1024
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
def checkout_sha(checkout: Path) -> str:
result = subprocess.run(
["git", "-C", str(checkout), "rev-parse", "HEAD"],
check=True, text=True, capture_output=True, timeout=10,
)
value = result.stdout.strip()
if not SHA_RE.fullmatch(value):
raise ValueError("production checkout revision is invalid")
return value
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,
checkout_lookup=checkout_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)
if checkout_lookup(checkout) != current_main:
return {"schema_version": 1, "state": "not_ready", "reason": "host_checkout_stale", "release": None}
api_args = {"api": api} if api is not None else {}
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": "candidate",
"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'
+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())
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more