Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
871bde1abd | ||
|
|
5c26bbe8b5 | ||
|
|
3d7df25d00 | ||
|
|
4d589eee95 | ||
|
|
3c17e2eb3b | ||
|
|
54e30d4228 | ||
|
|
00b0af69c2 | ||
|
|
81ab7aac39 | ||
|
|
8748760594 | ||
|
|
9d553744a1 | ||
|
|
710c483f34 | ||
|
|
f19faf628e | ||
|
|
9fc269283b | ||
|
|
826c1d8ca5 | ||
|
|
9a98fafefb | ||
|
|
849c19e2ab | ||
|
|
1c7ddf2fcb | ||
|
|
e653f3b50e | ||
|
|
726b1ee745 | ||
|
|
aa6fabe970 | ||
|
|
7437aef346 | ||
|
|
849c7537ed | ||
|
|
ee03dc6cc7 | ||
|
|
4cf96e3fa9 | ||
|
|
ba9564fca2 | ||
|
|
3eba203009 | ||
|
|
3fd5695bb2 | ||
|
|
42b3aa09a7 | ||
|
|
7ece39f212 | ||
|
|
bb3de5ee0f | ||
|
|
1ca41f162e | ||
|
|
afb3c9aa03 | ||
|
|
c8d0086a9f | ||
|
|
6cab6fcc95 | ||
|
|
8ede9f2123 | ||
|
|
ca5036abfe | ||
|
|
1c2a468516 | ||
|
|
b50cfa818d | ||
|
|
0630a774cf | ||
|
|
4a7e907be7 | ||
|
|
96ed0949ca | ||
|
|
3d267afaee | ||
|
|
4c4b51270c | ||
|
|
940b6c93f9 | ||
|
|
a54fef6a0c | ||
|
|
d65b857c2a | ||
|
|
6be28418ab | ||
|
|
f8483badcf | ||
|
|
74aa167d16 | ||
|
|
f1573edfa7 | ||
|
|
f90161f380 | ||
|
|
9a5a1a1a69 | ||
|
|
05fc47d7c3 | ||
|
|
e40a32971d | ||
|
|
eff81c1504 | ||
|
|
779743fdb0 | ||
|
|
8f331a002f | ||
|
|
d428919b78 | ||
|
|
9d8ce6b67f | ||
|
|
852d639c45 | ||
|
|
b5adc09203 | ||
|
|
a153dc8765 | ||
|
|
3620bb734c | ||
|
|
830e1d0f0d | ||
|
|
fd28ed2201 | ||
|
|
a1bb3eab98 | ||
|
|
8bd2b53469 | ||
|
|
21325d4e5c | ||
|
|
48cb424afd | ||
|
|
e5a8326a5e | ||
|
|
770e7930d9 | ||
|
|
cb3ab76d2b | ||
|
|
01380d3c09 | ||
|
|
6a3a6f8da5 | ||
|
|
224646e9d7 | ||
|
|
1d5eda3d89 | ||
|
|
8f58995fd0 | ||
|
|
25c62d3fff | ||
|
|
2e77f6c3df | ||
|
|
28e42e8b55 | ||
|
|
2d3f0a35c6 | ||
|
|
aae9657b9e | ||
|
|
a2ef8e0bf0 | ||
|
|
5a80c021d3 | ||
|
|
133d847608 | ||
|
|
043d9d632d | ||
|
|
d01fdabd68 | ||
|
|
84ae32b8a3 | ||
|
|
1f6129e009 | ||
|
|
d6799ec59b | ||
|
|
fcab9e1239 | ||
|
|
d2fb4b81c0 | ||
|
|
f922f6e8c7 | ||
|
|
06e350fb1e | ||
|
|
e522b2eee3 | ||
|
|
228dfdaca0 | ||
|
|
6357648c9c | ||
|
|
b845316ee7 | ||
|
|
2d3b34f34b | ||
|
|
cc8cc1bce6 | ||
|
|
a68d3c315e | ||
|
|
ba669fad34 | ||
|
|
8f64131131 | ||
|
|
802bfd117e | ||
|
|
2281705c95 | ||
|
|
fc0473e7e2 | ||
|
|
e12a8a919c | ||
|
|
37050b1458 | ||
|
|
6bd4b05af2 | ||
|
|
0da2bcd82d | ||
|
|
0979b8f544 | ||
|
|
4d106903e1 | ||
|
|
4b2e5578cd | ||
|
|
0e276ae652 | ||
|
|
554a7ab5ff | ||
|
|
cf99f4532f | ||
|
|
5d72bb092d | ||
|
|
d96fc4d36b | ||
|
|
436806a834 | ||
|
|
3a89dc2196 | ||
|
|
69c62466ca | ||
|
|
36892aef77 | ||
|
|
ac611cc878 | ||
|
|
9ec03200fb | ||
|
|
03c5809d69 | ||
|
|
00922e6c09 | ||
|
|
e6b099cf31 | ||
|
|
d8a0646829 | ||
|
|
433c3059b9 | ||
|
|
4110aa54c9 | ||
|
|
db6ded4075 | ||
|
|
b68421bb5b | ||
|
|
223e06ee77 |
No files matched your search
@@ -0,0 +1 @@
|
||||
FROM caddy:2.11.6-alpine@sha256:881bbc60f9986d5ab8e7cfd6cf7e4ef3c9c0439fef2429d035d065577882f028
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412
|
||||
COPY release-build.sh /usr/local/bin/release-build
|
||||
COPY validate-destination.sh /usr/local/bin/validate-destination.sh
|
||||
ENTRYPOINT ["/busybox/sh", "/usr/local/bin/release-build"]
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Publish a pinned Akyldash image
|
||||
description: Build and push one commit-tagged image with pinned rootless Kaniko
|
||||
inputs:
|
||||
workspace:
|
||||
description: Checked out source directory
|
||||
required: true
|
||||
dockerfile:
|
||||
description: Dockerfile path relative to workspace
|
||||
required: true
|
||||
destination:
|
||||
description: Commit-specific OCI tag
|
||||
required: true
|
||||
digest_file:
|
||||
description: Workspace path for Kaniko's resulting digest
|
||||
required: true
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
args:
|
||||
- ${{ inputs.workspace }}
|
||||
- ${{ inputs.dockerfile }}
|
||||
- ${{ inputs.destination }}
|
||||
- ${{ inputs.digest_file }}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/busybox/sh
|
||||
set -eu
|
||||
|
||||
workspace=$1
|
||||
dockerfile=$2
|
||||
destination=$3
|
||||
digest_file=$4
|
||||
: "${REGISTRY_USERNAME:?registry username secret is required}"
|
||||
: "${REGISTRY_PASSWORD:?registry password secret is required}"
|
||||
/busybox/sh /usr/local/bin/validate-destination.sh "$dockerfile" "$destination"
|
||||
|
||||
mkdir -p /kaniko/.docker "$(dirname "$digest_file")"
|
||||
auth=$(printf '%s:%s' "$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')
|
||||
umask 077
|
||||
printf '{"auths":{"git.vakanaut.ru":{"auth":"%s"}}}\n' "$auth" > /kaniko/.docker/config.json
|
||||
unset auth REGISTRY_USERNAME REGISTRY_PASSWORD
|
||||
/kaniko/executor \
|
||||
--context="dir://${workspace}" \
|
||||
--dockerfile="${workspace}/${dockerfile}" \
|
||||
--destination="$destination" \
|
||||
--digest-file="$digest_file" \
|
||||
--no-push-cache
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo 'ERROR: expected Dockerfile path and image destination' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
dockerfile=$1
|
||||
destination=$2
|
||||
case "$dockerfile" in
|
||||
deploy/production/Dockerfile.backend)
|
||||
repository=git.vakanaut.ru/admin/akyldash/backend
|
||||
;;
|
||||
deploy/production/Dockerfile.opensearch)
|
||||
repository=git.vakanaut.ru/admin/akyldash/opensearch
|
||||
;;
|
||||
*)
|
||||
echo 'ERROR: Dockerfile is outside the approved production image set' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$destination" in
|
||||
"$repository":*) tag=${destination#"$repository":} ;;
|
||||
*)
|
||||
echo 'ERROR: destination is outside the approved registry repository' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
commit=${tag%%-*}
|
||||
run_id=${tag#*-}
|
||||
case "$commit" in
|
||||
''|*[!0123456789abcdef]*)
|
||||
echo 'ERROR: destination commit must be a lowercase Git SHA-1' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ "${#commit}" -ne 40 ] || [ "$run_id" = "$tag" ]; then
|
||||
echo 'ERROR: destination tag must contain a 40-character commit and run ID' >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$run_id" in
|
||||
''|*[!0123456789]*)
|
||||
echo 'ERROR: destination run ID must be numeric' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
@@ -32,25 +34,62 @@ jobs:
|
||||
- name: Check runner config and isolation guards
|
||||
run: |
|
||||
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 \
|
||||
infra/runner/install-runner.sh infra/runner/act-runner-healthcheck.sh \
|
||||
infra/runner/test-runner-config.sh infra/runner/test-install-runner-guard.sh
|
||||
- name: Run rollback helper tests
|
||||
.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-ci-container-builds.sh
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deploy_release.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/create_release_manifest.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_handoff.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_refs.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/release_state.py').read_text())"
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/deployment_lock.py').read_text())"
|
||||
- name: Run deployment contract and rollback tests
|
||||
run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v
|
||||
- name: Validate production Compose and Caddyfile
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Production handoff status (not deployed)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release production images"]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deployment-status:
|
||||
name: NOT DEPLOYED - handoff status only
|
||||
if: >-
|
||||
gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'workflow_run' &&
|
||||
gitea.event.workflow_run.head_branch == 'main'
|
||||
runs-on: akyldash-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
- name: Verify source release identity before artifact lookup
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-handoff-source
|
||||
- name: Read artifact metadata with the installed Gitea helper when available
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: |
|
||||
if ! command -v gitea-api >/dev/null 2>&1; then
|
||||
echo 'DRY-RUN only: gitea-api is unavailable in this job; artifact metadata and host handoff are not verified.'
|
||||
python3 infra/deploy/release_handoff.py dry-run
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z ${GITEA_TOKEN:-} ]]; then
|
||||
echo 'DRY-RUN only: GITEA_TOKEN is unavailable; artifact metadata and host handoff are not verified.'
|
||||
python3 infra/deploy/release_handoff.py dry-run
|
||||
exit 0
|
||||
fi
|
||||
metadata=$(mktemp)
|
||||
trap 'rm -f "$metadata"' EXIT
|
||||
gitea-api GET "/repos/admin/akyldash/actions/runs/${SOURCE_RUN_ID}/artifacts" > "$metadata"
|
||||
python3 infra/deploy/release_handoff.py dry-run --artifact-metadata "$metadata"
|
||||
- name: State that no deployment was performed
|
||||
run: |
|
||||
echo 'DEPLOYMENT STATUS: NOT DEPLOYED'
|
||||
echo 'This workflow checks source provenance and may inspect artifact metadata only.'
|
||||
echo 'It does not download the descriptor ZIP, open SSH, or invoke the Windows receiver.'
|
||||
echo 'No Windows execution runner or automatic runtime binding has been verified.'
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Release production images
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
branches: [main]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
publish-images:
|
||||
if: >-
|
||||
gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'push' &&
|
||||
gitea.event.workflow_run.head_branch == 'main'
|
||||
runs-on: akyldash-ci
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||
- name: Verify successful CI source before using registry credentials
|
||||
env:
|
||||
SOURCE_WORKFLOW: ${{ gitea.event.workflow.name }}
|
||||
SOURCE_WORKFLOW_PATH: ${{ gitea.event.workflow_run.path }}
|
||||
SOURCE_EVENT: ${{ gitea.event.workflow_run.event }}
|
||||
SOURCE_CONCLUSION: ${{ gitea.event.workflow_run.conclusion }}
|
||||
SOURCE_BRANCH: ${{ gitea.event.workflow_run.head_branch }}
|
||||
SOURCE_RUN_ID: ${{ gitea.event.workflow_run.id }}
|
||||
SOURCE_SHA: ${{ gitea.event.workflow_run.head_sha }}
|
||||
SOURCE_REPOSITORY: ${{ gitea.event.repository.full_name }}
|
||||
run: python3 infra/deploy/release_handoff.py validate-ci-source
|
||||
- name: Require explicitly configured Gitea registry credentials
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
test -n "$REGISTRY_USERNAME" || { echo 'REGISTRY_USERNAME repository secret is required'; exit 1; }
|
||||
test -n "$REGISTRY_PASSWORD" || { echo 'REGISTRY_PASSWORD repository secret is required'; exit 1; }
|
||||
- name: Build and publish backend image
|
||||
uses: ./.gitea/actions/kaniko-push
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
with:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.backend
|
||||
destination: git.vakanaut.ru/admin/akyldash/backend:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
|
||||
digest_file: ${{ github.workspace }}/release/backend.digest
|
||||
- name: Build and publish OpenSearch image
|
||||
uses: ./.gitea/actions/kaniko-push
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
with:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
destination: git.vakanaut.ru/admin/akyldash/opensearch:${{ gitea.event.workflow_run.head_sha }}-${{ gitea.run_id }}
|
||||
digest_file: ${{ github.workspace }}/release/opensearch.digest
|
||||
- name: Create and validate release descriptor
|
||||
run: |
|
||||
python3 infra/deploy/create_release_manifest.py \
|
||||
--repository admin/akyldash \
|
||||
--commit '${{ gitea.event.workflow_run.head_sha }}' \
|
||||
--run-id '${{ gitea.run_id }}' \
|
||||
--source-run-id '${{ gitea.event.workflow_run.id }}' \
|
||||
--backend-digest release/backend.digest \
|
||||
--opensearch-digest release/opensearch.digest \
|
||||
--output release/release-manifest.json
|
||||
python3 infra/deploy/validate_release.py release/release-manifest.json \
|
||||
--policy infra/deploy/release-policy.json
|
||||
(cd release && sha256sum release-manifest.json > release-manifest.json.sha256)
|
||||
- id: descriptor
|
||||
name: Record release descriptor digest
|
||||
run: |
|
||||
(cd release && sha256sum -c release-manifest.json.sha256)
|
||||
digest=$(sha256sum release/release-manifest.json)
|
||||
digest=${digest%% *}
|
||||
printf 'descriptor_sha256=%s\n' "$digest" >> "$GITEA_OUTPUT"
|
||||
- name: Upload verified release descriptor
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
|
||||
with:
|
||||
name: release-descriptor-${{ gitea.run_id }}-${{ steps.descriptor.outputs.descriptor_sha256 }}
|
||||
path: |
|
||||
release/release-manifest.json
|
||||
release/release-manifest.json.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
- name: Confirm uploaded release descriptor
|
||||
run: |
|
||||
(cd release && sha256sum -c release-manifest.json.sha256)
|
||||
echo 'Release descriptor and checksum are available as this run artifact.'
|
||||
@@ -1,10 +1,12 @@
|
||||
# Local credentials and secrets
|
||||
credentials.json
|
||||
codex.json
|
||||
|
||||
# Python/runtime artifacts (for the upcoming bot implementation)
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.py[cod]
|
||||
tools/frontend-visual-review/node_modules/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
@@ -16,3 +18,7 @@ data/
|
||||
|
||||
# Local runtime logs
|
||||
logs/
|
||||
|
||||
# Local generated reports; deliberately publish reviewed/sanitized evidence with git add -f
|
||||
/reports/
|
||||
/tmp/
|
||||
@@ -1,7 +1,11 @@
|
||||
# Акылдаш — инструкции для агента
|
||||
|
||||
Проект: юридическая информационно-аналитическая платформа; Search API на
|
||||
Python, SQLite и OpenSearch. Production пока не развернут.
|
||||
Python, SQLite и OpenSearch. На 2026-10-25 публичный `/review` и прямой origin
|
||||
отвечают HTTP 200; состав production-контейнеров, готовность индекса и
|
||||
проверенность backup/restore не подтверждены. Сверяй актуальное состояние с
|
||||
[`CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md), не считай HTTP 200
|
||||
доказательством готовности поиска.
|
||||
|
||||
- Начинай с [инфраструктурного состояния](docs/operations/CURRENT_STATE.md) и
|
||||
[правил доступа](docs/operations/ACCESS.md). Не запрашивай доступы повторно.
|
||||
@@ -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-команды отдельно, без объединения с другими
|
||||
командами.
|
||||
@@ -2,6 +2,127 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
|
||||
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
|
||||
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
|
||||
может подготовить release B только при точном совпадении pinned runtime-файлов
|
||||
и release policy. Первый Windows task остаётся preparation-only; отдельные
|
||||
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
|
||||
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
|
||||
регистрация задач, SSH и production не выполнялись и не активировались.
|
||||
- Infrastructure: Windows release installer и external receiver читают владельцев
|
||||
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
|
||||
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
|
||||
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
|
||||
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
|
||||
менялись.
|
||||
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
|
||||
внешняя backup-retention политика и обязательный owner-approved maintenance
|
||||
window для cold backup. Передача корпуса требует атомарной публикации
|
||||
проверенного staging в `DATA_ROOT`; безопасное возобновление либо fail-closed
|
||||
при прерывании остаётся release gate. Механизм атомарной публикации ещё не
|
||||
реализован.
|
||||
- Backend `0.13.4` / Frontend `0.3.4`, алгоритм поиска `search-8`: для явно
|
||||
заданного multi-index поиска включена глобальная статистика DFS; signed review
|
||||
snapshots сохраняют полный index scope, а пустую выдачу можно сохранить с
|
||||
обязательным общим комментарием и точной проверкой рангов. Узкое расширение
|
||||
термина аренды сохранено без глобальных весов. На executable SHA
|
||||
`1c7ddf2` выполнен полный isolated прогон 7 500 запросов: изменились 537
|
||||
порядков относительно исходного baseline, 6 выдач остались пустыми; это
|
||||
наблюдения ранжирования, не экспертная оценка права. Три актуальных акта
|
||||
проверялись только в отдельном локальном supplement; production и исходный
|
||||
индекс не менялись.
|
||||
- Успешный CI на `push` в `main` запускает release workflow: при настроенных
|
||||
registry secrets он собирает образы rootless Kaniko, публикует digest
|
||||
references и сохраняет schema-v4 descriptor с SHA-256 в Gitea artifact;
|
||||
sidecar проверяется после извлечения из корня. Следующий workflow проверяет
|
||||
source-run identity и, при доступном `gitea-api`, метаданные артефакта только
|
||||
в dry-run и явно сообщает `NOT DEPLOYED`: ZIP не скачивается, SSH и host
|
||||
apply не выполняются. Добавлена отдельная root-verified status→fixed-tuple→
|
||||
Windows `--once` привязка через строгий SSH dispatcher; она не использует
|
||||
Gitea/PR runner и не требует Gitea token на Windows. Config и Task Scheduler
|
||||
template выключены, задача не зарегистрирована и production CD не активирован.
|
||||
Зелёный handoff не означает деплой. Подготовлены owner-run cutover и rollback с
|
||||
проверкой backup, image IDs, RU/KY canaries и recovery journal; CI проверяет
|
||||
synthetic artifact и завершается ошибкой при отсутствии файлов. Windows
|
||||
receiver прошёл 17 synthetic client checks, 8 protocol checks и проверку
|
||||
private owner ACL на disposable real-NTFS fixture. Inert Windows argv probe
|
||||
подтвердил передачу пути скрипта с пробелами; Task Scheduler COM проверил
|
||||
XML с `TASK_VALIDATE_ONLY` — задачу не регистрировали, не обновляли и не
|
||||
запускали. Production D: path, SSH, установка на VPS и полный backup/restore
|
||||
не проверены; cutover остаётся заблокирован до отдельного restore-теста и
|
||||
owner bootstrap. Принимаются только завершённые успешные Gitea runs; pending
|
||||
recovery journal записывается до остановки
|
||||
сервисов, повторно применённый release отклоняется до простоя, а проверка
|
||||
`main` использует явно заданный production checkout. Windows preflight
|
||||
проверяет owner ACL и требует свободное место под максимальный допустимый
|
||||
архив.
|
||||
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
|
||||
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
|
||||
кабинета убраны, а знак лаборатории приведён к основному бренду.
|
||||
- Добавлена политика Delivery First по уровням `LOW`/`MEDIUM`/`HIGH`: описаны
|
||||
обязательная глубина проверок, регрессионные тесты и доказательства угроз,
|
||||
совместимости и отката; обычные CI, одобрение и защита `main` остаются
|
||||
обязательными.
|
||||
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
|
||||
мобильной навигации документа; навигационные ссылки получили общие quiet и
|
||||
primary состояния.
|
||||
- Frontend `0.3.1`: уточнена компоновка поиска, лаборатории и кабинета;
|
||||
splitter сохраняет минимальную ширину панелей, а на средних экранах фильтры
|
||||
открываются доступной боковой панелью. Исправлено отображение футера на
|
||||
маршруте пользователей и browser fixture смены пароля.
|
||||
- Frontend `0.3.0` / Backend `0.13.1`: добавлен доступный splitter для
|
||||
изменения ширины результатов и документа. Язык поиска находится в фильтрах;
|
||||
состояния открытия, оглавление правовых заголовков, лаборатория и кабинет
|
||||
приведены к последовательной и компактной компоновке.
|
||||
- Добавлен план развития платформы со сравнением возможностей «Токтом Онлайн»,
|
||||
пробелов Акылдаша и этапов продуктовой проверки.
|
||||
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
|
||||
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
|
||||
промежуточных ширинах окна.
|
||||
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
|
||||
навигации, старый текст больше не остаётся при ошибке загрузки другого
|
||||
документа; очищаются дополнительные URL-атрибуты юридической разметки,
|
||||
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
|
||||
изоляции ссылок, reflow и безопасных форм управления пользователями.
|
||||
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
|
||||
чтение документа и лаборатории, доступные диалоги; управление пользователями
|
||||
вынесено на отдельный защищённый экран. Документная разметка очищается от
|
||||
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
|
||||
правовой текст и API управления пользователями не менялись.
|
||||
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
|
||||
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
|
||||
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
|
||||
к целевым элементам. Интерфейс использует локальные шрифты 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.
|
||||
- Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно.
|
||||
- Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
|
||||
|
||||
@@ -66,10 +66,11 @@ spacing:
|
||||
|
||||
## Overview
|
||||
|
||||
**Статус: утверждено пользователем 10 октября 2026 года.** Это целевая
|
||||
**Статус: утверждено пользователем 10 октября 2026 года.** Это нормативная
|
||||
дизайн-система портала и внутренней лаборатории проверки поисковой выдачи.
|
||||
Она ещё не внедрена в работающий интерфейс. Утверждение дизайна не означает
|
||||
готовность публичного сервиса или разрешение на deployment.
|
||||
Часть ресурсов и правил уже присутствует в исходном коде, но полное визуальное
|
||||
соответствие и доступность не подтверждаются одним наличием кода. Утверждение
|
||||
дизайна не означает готовность публичного сервиса или разрешение на deployment.
|
||||
|
||||
**Creative North Star: "Түндүк — спокойное пространство для работы с правом".**
|
||||
|
||||
@@ -80,11 +81,14 @@ spacing:
|
||||
|
||||
Это единственная нормативная спецификация дизайна проекта. Требования
|
||||
исходного brief перенесены сюда; отдельный brief и ранние варианты удалены.
|
||||
Визуальный ориентир — утверждённый пользователем
|
||||
[концептуальный экран](docs/design/tunduk-desktop-concept.png).
|
||||
Точные токены и правила этой спецификации важнее случайных деталей
|
||||
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
|
||||
ранних концепций не используется.
|
||||
Название продукта — **Акылдаш**; «УКУК» из ранних концепций не используется.
|
||||
|
||||
Целевые редактируемые состояния, композиция, измерения и критерии приёмки
|
||||
собраны в [плане реализации и приёмки](docs/product/implementation-roadmap.md).
|
||||
Макеты задают визуальное направление, но не источник юридических данных и не
|
||||
доказательство работающих функций. Утверждённые токены не заменяются цветами,
|
||||
взятыми пипеткой из растрового изображения. Изменения постоянных правил
|
||||
вносятся сюда явно.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
@@ -132,8 +136,11 @@ spacing:
|
||||
**Заголовки документа и разделов:** Noto Serif, веса 600 и 700.
|
||||
**Интерфейс и основной текст:** Noto Sans, веса 400, 500 и 600.
|
||||
Шрифты подключаются штатным способом, предпочтительно локальными WOFF2
|
||||
с `font-display: swap`. До поставки проверить лицензии и символы
|
||||
`Ө ө Ү ү Ң ң`. Файлы шрифтов пока не добавлены.
|
||||
с `font-display: swap`. В исходном коде на сверенном `main` есть локальные
|
||||
Noto Sans/Serif WOFF2 и OFL-лицензии в `backend/search/static/fonts/`;
|
||||
`review.html` объявляет `font-display: swap`. Это подтверждает наличие ресурсов
|
||||
и CSS, но не их загрузку на production и не полный визуальный аудит символов
|
||||
`Ө ө Ү ү Ң ң`. См. [план реализации и приёмки](docs/product/implementation-roadmap.md).
|
||||
|
||||
| Элемент | Размер | Межстрочный интервал |
|
||||
|---|---|---|
|
||||
@@ -162,6 +169,34 @@ spacing:
|
||||
Разворачивание документа и поиск внутри него реализуются только вместе
|
||||
с работающим поведением, а не как декоративные кнопки.
|
||||
|
||||
### Композиция поискового экрана
|
||||
|
||||
- Первая строка desktop: знак и название, разделитель и подпись продукта
|
||||
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
|
||||
переключатель языка интерфейса справа. Служебные действия авторизованного
|
||||
пользователя не вытесняют поиск и не меняют эту иерархию.
|
||||
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
|
||||
фильтры. Сведения о демонстрационном режиме показываются только в нём.
|
||||
- Ниже расположены три смежные панели: фильтры, результаты, документ.
|
||||
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
|
||||
промежутков между самостоятельными карточками панелей.
|
||||
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
|
||||
и пару полей даты в одной строке при достаточной ширине. Вид элемента
|
||||
выбора соответствует одиночному или множественному контракту API.
|
||||
- Заголовок результатов и счётчик находятся слева, сортировка справа.
|
||||
Высота строк зависит от содержимого; едины отступы и типографика.
|
||||
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
|
||||
и текстовый признак открытия.
|
||||
- Панель документа начинается с контекстной навигации и разворачивания,
|
||||
затем идут название, компактные метаданные, действия, поиск внутри
|
||||
документа и единое поле чтения. Название документа имеет отдельную
|
||||
типографическую роль, отличную от заголовков интерфейсных панелей.
|
||||
- При коротком содержимом footer расположен у нижней границы окна;
|
||||
длинное содержимое доступно прокруткой без перекрытия текста и управления.
|
||||
Бренд находится слева, фактические версии frontend и backend справа.
|
||||
- Поисковая страница не показывает инструменты оценивания лаборатории.
|
||||
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
|
||||
|
||||
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
|
||||
порядок результатов и оценивание слева, текст выбранной редакции справа.
|
||||
Она не получает фильтры, аналитику или публичную навигацию исключительно
|
||||
@@ -176,8 +211,9 @@ spacing:
|
||||
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
|
||||
область прокрутки.
|
||||
|
||||
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
|
||||
проверки, а не заранее навязанные CSS breakpoints.
|
||||
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
|
||||
1280×800 — основной размер сравнения поискового экрана с референсом.
|
||||
Это размеры проверки, а не заранее навязанные CSS breakpoints.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
@@ -204,6 +240,13 @@ spacing:
|
||||
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
|
||||
На телефоне орнамент сокращается либо убирается.
|
||||
|
||||
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
|
||||
части панели фильтров. Он не перекрывает длинные списки и не резервирует
|
||||
место ценой недоступности управления. Знак бренда и орнамент поставляются
|
||||
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
|
||||
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
|
||||
согласованные толщину линий, оптический размер и выравнивание.
|
||||
|
||||
## Components
|
||||
|
||||
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
|
||||
|
||||
@@ -9,18 +9,29 @@ Telegram-бот — только часть рабочего окружения
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.11.3` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
|
||||
В исходном коде находятся Telegram-бот-секретарь версии `0.2.2`, Search API
|
||||
версии `0.13.4` (алгоритм поиска `search-8`) и frontend `0.3.4` с поисковым
|
||||
экраном, внутренней лабораторией и согласованным отображением доступных
|
||||
действий учетной записи.
|
||||
Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм, применяет узкий
|
||||
юридический вариант для общего словосочетания «договор аренды» и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль. Ограничения
|
||||
текущего индекса и подтверждённые тестовые ошибки описаны в
|
||||
[отчёте о релевантности](docs/product/search-relevance-findings-2026-11-03.md).
|
||||
Версии описывают код, а не установленный production-релиз; актуальное
|
||||
подтверждённое состояние среды см. в
|
||||
[`docs/operations/CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md).
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.11.3` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| Telegram-бот | `0.2.2` | исходный код; последняя заметка о Synology указывает `0.2.1`, актуальность не перепроверена |
|
||||
| Search API | `0.13.4` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
|
||||
| Frontend | `0.3.4` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
|
||||
| Загрузчик архива Минюста (`backend/ingestion/minjust_cbd.py`) | `0.9.3` (`APP_VERSION`) | версия CLI и User-Agent в исходном коде |
|
||||
| Нормализатор корпуса (`backend/normalization/minjust_cbd.py`) | CLI `0.10.0` (`APP_VERSION`); формат/алгоритм `1.1.0` (`NORMALIZER_VERSION`) | версия инструмента и версия нормализатора в manifest — разные значения |
|
||||
| Оценка релевантности | формат набора `relevance-set-v1` | отдельная версия компонента не задана; CLI использует Search API `0.13.4` |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
## Структура репозитория
|
||||
@@ -62,4 +73,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.4 · Frontend v0.3.4
|
||||
@@ -1,11 +1,16 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.11.3`
|
||||
Версия загрузчика `minjust_cbd.py`: `0.9.3`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
|
||||
из будущего планировщика backend без запуска отдельного процесса.
|
||||
Для передачи корпуса в production обязательна отдельная процедура
|
||||
[CORPUS_HANDOFF.md](../docs/operations/CORPUS_HANDOFF.md); она проверяет
|
||||
source/staging, inventory и checksum, решение по code 6, публикацию и
|
||||
готовность индекса. Этот runbook — release gate; команды передачи здесь не
|
||||
дублируются.
|
||||
|
||||
## Пробная загрузка
|
||||
|
||||
@@ -54,6 +59,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 +176,31 @@ python3 backend/search/minjust_opensearch.py \
|
||||
После проверки production-индекса следует переключать alias, чтобы удалённые
|
||||
фрагменты не оставались в поиске.
|
||||
|
||||
Чтобы переключить alias атомарно только после успешной полной загрузки,
|
||||
передайте `--alias`:
|
||||
Alias переключается только после refresh, точного сравнения полного fragment
|
||||
count с inventory, обеих языковых canary-проверок и совпадения ожидаемого
|
||||
текущего индекса. Перед cutover обязательно укажите RU/KY запросы и точное имя
|
||||
старого target:
|
||||
|
||||
```bash
|
||||
python3 backend/search/minjust_opensearch.py \
|
||||
--url http://127.0.0.1:9200 \
|
||||
--index akyldash-fragments-v2 \
|
||||
--alias akyldash-fragments-current
|
||||
--alias akyldash-fragments-current \
|
||||
--expected-old-index akyldash-fragments-v1 \
|
||||
--canary-ru 'утверждённый русский запрос' \
|
||||
--canary-ky 'бекитилген кыргызча суроо'
|
||||
```
|
||||
|
||||
Wildcard removal, пустой индекс и alias с несколькими target запрещены. Если
|
||||
старый target успел измениться после проверки, OpenSearch отклонит атомарную
|
||||
операцию удаления точного старого alias. `--resume` повторно проверяет весь
|
||||
ожидаемый inventory, а не только количество документов текущего запуска.
|
||||
Если ответ на alias request потерян после успешного переключения или процесс
|
||||
остановился до записи завершённого checkpoint, повторный запуск проверит
|
||||
фактическую цель alias. Уже опубликованный candidate оставляется на месте, и
|
||||
checkpoint завершается; старый target позволяет безопасно повторить cutover,
|
||||
а любой другой target останавливает операцию.
|
||||
|
||||
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
|
||||
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
|
||||
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
|
||||
@@ -216,6 +279,9 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
Менять веса или анализаторы следует только после фиксации этого baseline и
|
||||
разбора ошибок выдачи.
|
||||
|
||||
Диагностический отчёт с измерениями, подтверждёнными ошибками и ограничениями
|
||||
текущего корпуса: [search relevance findings](../docs/product/search-relevance-findings-2026-11-03.md).
|
||||
|
||||
## Внутренняя лаборатория релевантности
|
||||
|
||||
Создайте первую учетную запись администратора:
|
||||
@@ -234,7 +300,8 @@ PYTHONPATH=backend python3 -m search.api \\
|
||||
|
||||
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
|
||||
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
|
||||
комментариями. Поиск и API доступны только после входа. Пользователь `admin`
|
||||
комментариями. Пустую выдачу можно сохранить как query-level оценку с
|
||||
обязательным общим комментарием. Поиск и API доступны только после входа. Пользователь `admin`
|
||||
управляет учетными записями; новые пользователи должны заменить временный
|
||||
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
|
||||
нужно резервировать отдельно от поискового индекса. Экспорт доступен
|
||||
@@ -253,4 +320,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Search API v0.13.4 · Frontend v0.3.4 · Telegram-бот v0.2.2
|
||||
@@ -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())
|
||||
@@ -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 = {
|
||||
|
||||
@@ -13,7 +13,7 @@ import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.auth import AuthError, AuthStore
|
||||
from search.auth import AuthError, AuthStore, can_manage_users
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.query import build_search_query
|
||||
@@ -21,11 +21,21 @@ from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-6"
|
||||
SEARCH_ALGORITHM_VERSION = "search-8"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
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):
|
||||
@@ -115,9 +125,12 @@ class Api:
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
|
||||
def query_opensearch(self, body: dict) -> dict:
|
||||
def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
|
||||
try:
|
||||
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
url = self.search_url("_search")
|
||||
if search_type is not None:
|
||||
url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
|
||||
return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
except RuntimeError as error:
|
||||
raise ApiError(502, "search backend is unavailable") from error
|
||||
|
||||
@@ -167,7 +180,8 @@ class Api:
|
||||
if sort == "date":
|
||||
body["sort"].append({"date_adopted": "desc"})
|
||||
body["sort"].append({"_score": "desc"})
|
||||
response = self.query_opensearch(body)
|
||||
search_type = "dfs_query_then_fetch" if "," in self.index else None
|
||||
response = self.query_opensearch(body, search_type=search_type)
|
||||
try:
|
||||
hits = response["hits"]["hits"]
|
||||
except (KeyError, TypeError) as error:
|
||||
@@ -175,7 +189,14 @@ class Api:
|
||||
results = [self.search_hit(hit, language) for hit in hits[:page_size]]
|
||||
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
|
||||
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
|
||||
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
|
||||
# A comma-separated index expression is the searched dataset scope;
|
||||
# hits may come from only one of its members (or there may be no hits).
|
||||
# For a single configured index/alias, keep the concrete-index provenance
|
||||
# when OpenSearch returns exactly one concrete target.
|
||||
if "," in self.index:
|
||||
index_name = self.index
|
||||
else:
|
||||
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
|
||||
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
|
||||
|
||||
@staticmethod
|
||||
@@ -273,9 +294,18 @@ class Api:
|
||||
raise ApiError(400, "overall_comment is too long")
|
||||
if not isinstance(submitted, list):
|
||||
raise ApiError(400, "results must be an array")
|
||||
if len(submitted) > MAX_PAGE_SIZE:
|
||||
raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
|
||||
if any(not isinstance(item, dict) for item in submitted):
|
||||
raise ApiError(400, "review result must be an object")
|
||||
ranks = [item.get("rank") for item in submitted]
|
||||
if any(type(rank) is not int for rank in ranks):
|
||||
raise ApiError(400, "review result rank is invalid")
|
||||
by_rank = {item["rank"]: item for item in snapshot["results"]}
|
||||
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
|
||||
if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
|
||||
raise ApiError(400, "all search results must be reviewed exactly once")
|
||||
if not snapshot["results"] and not overall_comment.strip():
|
||||
raise ApiError(400, "overall_comment is required for an empty search result")
|
||||
results = []
|
||||
for item in submitted:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
|
||||
@@ -290,8 +320,6 @@ class Api:
|
||||
if not isinstance(comment, str) or len(comment) > 4000:
|
||||
raise ApiError(400, "result comment is too long")
|
||||
results.append({**source, "rating": rating, "comment": comment.strip()})
|
||||
if not results:
|
||||
raise ApiError(400, "at least one result must be reviewed")
|
||||
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
|
||||
review_id = self.review_store.save(review)
|
||||
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
|
||||
@@ -303,6 +331,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 +363,21 @@ class Api:
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
|
||||
return 200, {"users": self.auth_store.list_users()}
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.create_user(body.get("username"), body.get("password"))
|
||||
return 201, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.delete_user(parts[2])
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
try:
|
||||
self.auth_store.reset_password(parts[2], body.get("password"))
|
||||
return 200, {"ok": True}
|
||||
@@ -374,7 +413,7 @@ def handler(api: Api):
|
||||
session = cookies.get("akyldash_session")
|
||||
token = session.value if session else None
|
||||
user = api.auth_store.user_for_token(token)
|
||||
if method == "GET" and route in {"/", "/review"}:
|
||||
if method == "GET" and route in {"/", "/review", "/users"}:
|
||||
body = api.review_page().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -386,12 +425,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 +521,7 @@ def main() -> int:
|
||||
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
|
||||
if arguments.create_admin or arguments.reset_admin_password:
|
||||
users = api.auth_store.list_users()
|
||||
admin_exists = any(user["username"].casefold() == "admin" for user in users)
|
||||
admin_exists = any(can_manage_users(user["username"]) for user in users)
|
||||
if arguments.create_admin and users:
|
||||
parser.error("accounts already exist")
|
||||
if arguments.reset_admin_password and not admin_exists:
|
||||
|
||||
@@ -22,6 +22,10 @@ class AuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def can_manage_users(username: str) -> bool:
|
||||
return isinstance(username, str) and username.casefold() == "admin"
|
||||
|
||||
|
||||
class AuthStore:
|
||||
def __init__(self, path: Path | str):
|
||||
if path != ":memory:":
|
||||
@@ -106,7 +110,8 @@ class AuthStore:
|
||||
|
||||
@staticmethod
|
||||
def _public(user: sqlite3.Row) -> dict:
|
||||
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
can_manage = can_manage_users(user["username"])
|
||||
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
|
||||
def user_for_token(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
@@ -151,7 +156,7 @@ class AuthStore:
|
||||
raise AuthError("login already exists") from error
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
if username.casefold() == "admin":
|
||||
if can_manage_users(username):
|
||||
raise AuthError("admin account cannot be deleted")
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
|
||||
|
||||
@@ -82,7 +82,7 @@ def main() -> int:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2", file=sys.stderr)
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.11.3"
|
||||
APP_VERSION = "0.13.4"
|
||||
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.4")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,19 @@ LEGAL_FORM_ALIASES = {
|
||||
"жоопкерчилиги чектелген коом": ("жчк",),
|
||||
},
|
||||
}
|
||||
# The indexed Civil Code uses the legal term "имущественный наем" for a
|
||||
# договор аренды. Keep this exact legal phrase expansion narrow: it must not
|
||||
# rewrite residential hiring, employment, sublease, or leasing queries.
|
||||
LEASE_TERM_ALIAS = re.compile(
|
||||
r"(?<!\w)(договор(?:а|у|ом|е)?)[ \t]+аренд(?:а|ы|е|у|ой|ою)(?!\w)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
|
||||
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
|
||||
r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEASE_TERM_PHRASE_BOOST = 12
|
||||
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
|
||||
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
|
||||
BOILERPLATE_PREFIXES = {
|
||||
@@ -62,12 +75,22 @@ def relaxed_query_text(language: str, query: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def lease_term_variant(query: str) -> str | None:
|
||||
if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
|
||||
return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
|
||||
return None
|
||||
|
||||
|
||||
def query_variants(language: str, query: str) -> list[str]:
|
||||
variants = [query]
|
||||
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
|
||||
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
|
||||
if pattern.search(query):
|
||||
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
|
||||
if language == "ru":
|
||||
alias = lease_term_variant(query)
|
||||
if alias:
|
||||
variants.append(alias)
|
||||
return list(dict.fromkeys(variants))
|
||||
|
||||
|
||||
@@ -169,9 +192,13 @@ def build_search_query(language: str, query: str) -> dict:
|
||||
"boost": 0.1,
|
||||
}
|
||||
})
|
||||
lease_variant = lease_term_variant(query) if language == "ru" else None
|
||||
phrase_matches = [
|
||||
{"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
|
||||
for variant in query_variants(language, query)
|
||||
{"match_phrase": {f"text_{language}": {
|
||||
"query": variant,
|
||||
"boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
|
||||
}}}
|
||||
for variant in variants
|
||||
]
|
||||
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
|
||||
return {"bool": planned}
|
||||
@@ -208,7 +235,7 @@ def main() -> int:
|
||||
if arguments.top_k <= 0:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,10 +3,11 @@ import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, handler
|
||||
|
||||
|
||||
class SearchApiTest(unittest.TestCase):
|
||||
@@ -24,6 +25,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))
|
||||
@@ -127,6 +191,44 @@ class SearchApiTest(unittest.TestCase):
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
|
||||
alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
|
||||
single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
|
||||
one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
|
||||
empty = {"hits": {"hits": []}}
|
||||
alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
|
||||
aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
|
||||
with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
|
||||
multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
|
||||
single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
|
||||
alias.filters({"language": ["ru"]})
|
||||
|
||||
calls = request.call_args_list
|
||||
multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
|
||||
multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
|
||||
self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
|
||||
self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
|
||||
self.assertEqual(multi_ky_empty["results"], [])
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
|
||||
|
||||
for call in calls[2:]:
|
||||
parsed = urllib.parse.urlsplit(call.args[0])
|
||||
self.assertEqual(parsed.query, "")
|
||||
self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
|
||||
self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
|
||||
multi_body = json.loads(calls[0].args[2])
|
||||
alias_body = json.loads(calls[2].args[2])
|
||||
self.assertEqual(multi_body, alias_body)
|
||||
self.assertNotIn("search_type", multi_body)
|
||||
self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,5 +37,29 @@ class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
|
||||
|
||||
|
||||
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
|
||||
class SearchRankingOpenSearchTest(unittest.TestCase):
|
||||
def test_generic_lease_query_ranks_general_civil_code_first(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
|
||||
api = Api(base_url, index, Path("."))
|
||||
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
|
||||
self.assertEqual(result["results"][0]["code"], "5")
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
|
||||
"set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
|
||||
)
|
||||
class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
|
||||
def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
|
||||
api = Api(base_url, index, Path("."))
|
||||
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
|
||||
self.assertIsInstance(result["results"], list)
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.evaluate_relevance import evaluate, load_queries
|
||||
from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
|
||||
from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
|
||||
|
||||
|
||||
class SearchRelevanceTest(unittest.TestCase):
|
||||
@@ -49,6 +49,36 @@ class SearchRelevanceTest(unittest.TestCase):
|
||||
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
|
||||
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
|
||||
|
||||
def test_russian_lease_phrase_expands_only_the_exact_legal_construction(self):
|
||||
original = "расторжение договора аренды"
|
||||
variants = query_variants("ru", original)
|
||||
self.assertEqual(variants[0], original)
|
||||
self.assertIn("расторжение договора имущественного найма", variants)
|
||||
body = json.loads(build_search_body("ru", original, 10))
|
||||
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||
full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
|
||||
self.assertEqual(full_text, variants)
|
||||
phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
|
||||
self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
|
||||
|
||||
for query in (
|
||||
"аренда квартиры",
|
||||
"расторжение договора аренды квартиры",
|
||||
"договор аренды земельного участка",
|
||||
"договор поднайма",
|
||||
"договор аренды с правом поднайма",
|
||||
"наем работника",
|
||||
"договор лизинга",
|
||||
):
|
||||
self.assertEqual(query_variants("ru", query), [query])
|
||||
|
||||
self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
|
||||
|
||||
self.assertEqual(
|
||||
query_variants("ru", "договору аренды"),
|
||||
["договору аренды", "договору имущественного найма"],
|
||||
)
|
||||
|
||||
def test_relaxed_match_drops_ru_template_prefix_and_stays_low_weight(self):
|
||||
body = json.loads(build_search_body("ru", "правовое регулирование расторжение договора аренды", 10))
|
||||
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||
|
||||
@@ -3,33 +3,140 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
|
||||
|
||||
|
||||
class SearchReviewTest(unittest.TestCase):
|
||||
def test_multi_index_snapshot_and_saved_review_preserve_configured_scope(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "A,B", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
cases = (
|
||||
("no hits", []),
|
||||
("A only", [{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}}]),
|
||||
("B only", [{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}}]),
|
||||
("mixed", [
|
||||
{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}},
|
||||
{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}},
|
||||
]),
|
||||
)
|
||||
for label, hits in cases:
|
||||
with self.subTest(case=label):
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": hits}}):
|
||||
result = api.handle("GET", "/search?q=test&language=ru")[1]
|
||||
token = result["review_token"]
|
||||
self.assertEqual(api.review_snapshots.verify(token)["index_name"], "A,B")
|
||||
review_results = [
|
||||
{"rank": rank, "code": item["code"], "rating": 3}
|
||||
for rank, item in enumerate(result["results"], 1)
|
||||
]
|
||||
api.handle("POST", "/search-reviews", {
|
||||
"review_token": token,
|
||||
"reviewer": "Юрист",
|
||||
"results": review_results,
|
||||
"overall_comment": "Проверена выдача для настроенного scope.",
|
||||
})
|
||||
saved = api.handle("GET", "/search-reviews/export")[1]["reviews"][-1]
|
||||
self.assertEqual(saved["index_name"], "A,B")
|
||||
|
||||
def test_review_must_cover_each_snapshot_rank_once(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": []})
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
|
||||
|
||||
def test_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
|
||||
response = {"hits": {"hits": [
|
||||
{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
|
||||
{"_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}},
|
||||
]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
token = result["review_token"]
|
||||
|
||||
malformed = [
|
||||
{"rank": True, "code": "7", "rating": 3},
|
||||
{"rank": [1], "code": "7", "rating": 3},
|
||||
{"rank": {"value": 1}, "code": "7", "rating": 3},
|
||||
{"rank": None, "code": "7", "rating": 3},
|
||||
{"rank": "1", "code": "7", "rating": 3},
|
||||
{"rank": 1.0, "code": "7", "rating": 3},
|
||||
None,
|
||||
]
|
||||
for item in malformed:
|
||||
with self.subTest(item=item):
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
for submitted in (
|
||||
[],
|
||||
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
|
||||
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
|
||||
[{"rank": 1, "code": "7", "rating": 3}],
|
||||
{"rank": 1, "code": "7", "rating": 3},
|
||||
):
|
||||
with self.subTest(submitted=submitted):
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
|
||||
with self.assertRaises(ApiError) as raised:
|
||||
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
|
||||
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "search-20260827")
|
||||
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
|
||||
self.assertEqual(saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[0]["index_name"], "search-20260827")
|
||||
self.assertEqual(exported[0]["top_result_code"], "7")
|
||||
self.assertEqual(exported[0]["results"][0]["rating"], 3)
|
||||
with self.assertRaisesRegex(ApiError, "does not match"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
|
||||
|
||||
def test_saves_signed_empty_snapshot_with_query_level_comment(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": []}}):
|
||||
result = api.handle("GET", "/search?q=редкий+запрос&language=ru")[1]
|
||||
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "current")
|
||||
body = {
|
||||
"review_token": result["review_token"],
|
||||
"reviewer": "Юрист",
|
||||
"overall_comment": "Пустая выдача подтверждена.",
|
||||
"results": [],
|
||||
}
|
||||
with self.assertRaisesRegex(ApiError, "overall_comment is required"):
|
||||
api.handle("POST", "/search-reviews", {**body, "overall_comment": ""})
|
||||
saved = api.handle("POST", "/search-reviews", body)
|
||||
self.assertEqual(saved[0], 201)
|
||||
review = api.handle("GET", "/search-reviews/export")[1]["reviews"][0]
|
||||
self.assertIsNone(review["top_result_code"])
|
||||
self.assertEqual(review["results"], [])
|
||||
self.assertEqual(review["overall_comment"], body["overall_comment"])
|
||||
|
||||
def test_review_page_enables_query_level_review_for_empty_results(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
page = api.review_page()
|
||||
self.assertIn("state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу'", page)
|
||||
self.assertIn("aria-required', String(!state.results.length)", page)
|
||||
self.assertIn("const results = state.results.map((result, index) => { const item", page)
|
||||
|
||||
def test_snapshot_and_review_page_are_available(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
@@ -41,6 +148,27 @@ class SearchReviewTest(unittest.TestCase):
|
||||
self.assertNotIn('id="error"', page)
|
||||
self.assertIn("localStorage", page)
|
||||
|
||||
def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
|
||||
result = api.handle("GET", "/search?q=test&language=ru")[1]
|
||||
current = api.review_snapshots.verify(result["review_token"])
|
||||
self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
|
||||
self.assertEqual(current["algorithm_version"], "search-8")
|
||||
|
||||
legacy_token = api.review_snapshots.create(
|
||||
"legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
|
||||
)
|
||||
legacy_saved = api.handle("POST", "/search-reviews", {
|
||||
"review_token": legacy_token,
|
||||
"reviewer": "Юрист",
|
||||
"results": [{"rank": 1, "code": "7", "rating": 3}],
|
||||
})
|
||||
self.assertEqual(legacy_saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[-1]["algorithm_version"], "search-7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -110,3 +110,55 @@ Follow the procedures in [`BACKUP.md`](../../docs/operations/BACKUP.md),
|
||||
|
||||
Automated production deployment is not enabled. Deployments remain owner-run;
|
||||
record the release and verify service health before considering it complete.
|
||||
|
||||
## Image release and owner-run cutover
|
||||
|
||||
PR CI still builds both production images with Kaniko `--no-push`. The separate
|
||||
`Release production images` workflow is manual and runs only when dispatched
|
||||
from protected `main`. It requires repository secrets named
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD`; without them it fails before any
|
||||
build. Configure those secrets only after the owner confirms a dedicated
|
||||
Gitea account/token with write access to the two repository packages and no
|
||||
unneeded administration rights. Do not reuse a runner or production host
|
||||
credential. The workflow uses the Gitea host and fixed package namespace
|
||||
`git.vakanaut.ru/admin/akyldash/{backend,opensearch}`, pushes unique
|
||||
commit/run-ID tags, captures registry digests, validates the release
|
||||
descriptor, and uploads the JSON plus SHA-256 sidecar as a Gitea Actions
|
||||
artifact using a full-SHA-pinned Gitea-compatible v3 action. PR CI also uploads
|
||||
a tiny synthetic marker with one-day retention to verify the runner's runtime
|
||||
artifact path without registry credentials. The runner cache stays disabled;
|
||||
artifact compatibility is based on the runtime API supported by act_runner
|
||||
0.2.13/Gitea 1.25.5 and will be empirically checked by PR CI. Registry and
|
||||
artifact retention must preserve release digests and descriptors for the full
|
||||
rollback window; the owner must confirm both policies.
|
||||
|
||||
The schema-v3 descriptor records source repository/ref/commit, workflow run ID,
|
||||
pinned Kaniko builder and both image digests. It is an unsigned trace record,
|
||||
not a cryptographic attestation; deployment relies on retrieving it from the
|
||||
authenticated Gitea run artifact and verifying its checksum and repository
|
||||
allowlist. Do not treat the sidecar alone as proof of authenticity.
|
||||
|
||||
For a future owner-approved cutover, use the root-owned checkout and run
|
||||
`infra/deploy/deploy_release.py` as root with the downloaded manifest, its
|
||||
matching verified cold backup, and `infra/deploy/release-policy.json`, plus
|
||||
owner-verified non-empty Russian and Kyrgyz canary queries, for example
|
||||
`--canary-ru '<known Russian query>' --canary-ky '<known Kyrgyz query>'`.
|
||||
The helper checks both queries against the public Search API through local
|
||||
HTTPS after Compose reports healthy, and requires at least one result with a
|
||||
document code before recording the new release state. Select queries only
|
||||
after corpus/index canaries have been verified; do not use generic terms that
|
||||
can match unrelated test data. The helper requires the backup image references and image IDs to match the live
|
||||
stack, serializes deploy and rollback with `/run/akyldash-deploy.lock`,
|
||||
atomically switches both protected env image refs, and always runs Compose with
|
||||
`--no-build`. It records recovery state before changing references. If ref
|
||||
replacement reports an error after an atomic rename, recovery rereads the
|
||||
actual file and restores the prior pair before restarting; an unexpected or
|
||||
unverifiable pair stops recovery and leaves a secret-free journal. A failed
|
||||
cutover refuses a subsequent release until an owner resolves the journal.
|
||||
Rollback accepts a digest-pinned previous release
|
||||
only when that backup matches the separately recorded previous refs and image
|
||||
IDs. Legacy tag rollback remains supported. Neither the helper nor registry
|
||||
secrets are installed on the VPS by this PR; deployment remains an owner-only
|
||||
operation and must wait for host/data/backup review. The exact sanitized
|
||||
evidence required from the owner is listed in
|
||||
[`DEPLOYMENT_OWNER_PACKET.md`](../../docs/operations/DEPLOYMENT_OWNER_PACKET.md).
|
||||
@@ -6,10 +6,15 @@
|
||||
типографика, компоненты и правила адаптивности портала и лаборатории.
|
||||
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||
правила работы с данными.
|
||||
- [Стратегическое сравнение с Токтом](product/platform-development-plan-toktom-2026-10-24.md) —
|
||||
продуктовая гипотеза и конкурентный анализ; это не план исполнения задач.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [План реализации и приёмки](product/implementation-roadmap.md) — единая
|
||||
очередность UI и deployment gates, со статусом доказательств; не дублирует
|
||||
продуктовую стратегию или эксплуатационные процедуры.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
@@ -30,6 +35,11 @@
|
||||
состояние production и оставшиеся проверки.
|
||||
- [Инфраструктура и эксплуатация](operations/INFRASTRUCTURE.md) — схема,
|
||||
доступы, deployment, backup, rollback и recovery.
|
||||
- [Операционные процедуры](operations/DEPLOYMENT.md) — связанные инструкции по
|
||||
deployment, [backup](operations/BACKUP.md), [rollback](operations/ROLLBACK.md),
|
||||
[recovery](operations/RECOVERY.md), обязательная процедура
|
||||
[передачи корпуса](operations/CORPUS_HANDOFF.md) и
|
||||
[пакет владельца](operations/DEPLOYMENT_OWNER_PACKET.md).
|
||||
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
|
||||
роли и этапы развития командного окружения.
|
||||
|
||||
@@ -45,4 +55,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 157 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -16,8 +16,22 @@ before placing it on the host.
|
||||
|
||||
CI jobs run in `docker.gitea.com/runner-images:ubuntu-24.04` containers with
|
||||
capacity 1, per-job limits of 2 CPU, 3 GiB RAM and 512 processes, and a 15 GiB
|
||||
dedicated ext4 loop-backed Docker data filesystem. The rootless Docker daemon
|
||||
and runner user are separate from production Docker; `codex` is not added to
|
||||
dedicated ext4 loop-backed Docker data filesystem. The runner account's home
|
||||
directory is root-owned mode `0711`, so CI cannot rename the image or mountpoint
|
||||
entries. Docker config remains under `.config`; registration state and the
|
||||
runner workspace live in `.config/act-runner`, which is runner-owned. The
|
||||
rootless Docker setup and user service use `.config/act-runner` as writable
|
||||
`HOME`, `.config` as `XDG_CONFIG_HOME`, and
|
||||
`.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.
|
||||
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
|
||||
@@ -33,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
|
||||
|
||||
@@ -49,16 +117,36 @@ for runner images/workspaces; and configures systemd services. It starts only
|
||||
the separate rootless Docker daemon; the runner and health timer stay disabled
|
||||
until repository registration. It does not change
|
||||
SSH/UFW, production Docker configuration, application data, or production
|
||||
secrets. It refuses to overwrite existing runner paths and requires at least
|
||||
25 GiB free on `/` before reserving storage.
|
||||
secrets. It can be rerun after partial setup: it validates and reuses an
|
||||
existing runner account, subordinate ID mappings, image, and mount unit. An
|
||||
existing image must be exactly 15 GiB and contain ext4; a mismatch stops the
|
||||
installer without resizing or formatting it. The runner account must retain
|
||||
a non-root system UID below `UID_MIN`, its expected home, primary group, and
|
||||
sole supplementary group. After validating the account, the installer locks
|
||||
its home directory to root ownership and mode `0711`; it refuses to rerun while
|
||||
the runner service is active, so it cannot interrupt a job. The mount
|
||||
is started only when it is not already active, and an active mount must be the
|
||||
expected ext4 loop device backed by the runner image with both `nosuid` and
|
||||
`nodev` active. Runner-home directories and user configuration files are
|
||||
created in runner-owned subdirectories. Root creates only the first `.config`
|
||||
entry beneath the already locked home; subsequent directory and config writes
|
||||
run as `akyldash-runner`. The installer rejects static symlinks and validates
|
||||
ownership and mode after setup. This also normalizes the `0755` intermediate
|
||||
directories left by the first installer version. Conflicting files or state
|
||||
stop the installer for manual inspection. A new image requires at least 25 GiB
|
||||
free on `/`. Image creation opens each parent component without following
|
||||
symlinks, creates the image exclusively relative to the opened directory, and
|
||||
passes the open file descriptor to `mkfs.ext4`; validation checks the same inode
|
||||
and rejects path replacement. Systemd mounts the image under the root-owned home
|
||||
entry, which the runner cannot replace after validation.
|
||||
|
||||
If the reviewed checkout is not present at the inventoried path, first verify
|
||||
that the target is absent and clone the reviewed PR branch there. This path is
|
||||
currently absent per the read-only audit; do not overwrite it if that changes.
|
||||
that the target is absent and clone the reviewed `main` there. Do not overwrite
|
||||
an existing checkout.
|
||||
|
||||
```bash
|
||||
sudo test ! -e /opt/akyldash/current
|
||||
sudo git clone --branch feature/infrastructure-deployment-phase-2 \
|
||||
sudo git clone --branch main \
|
||||
--single-branch https://git.vakanaut.ru/admin/akyldash.git \
|
||||
/opt/akyldash/current
|
||||
```
|
||||
@@ -75,7 +163,8 @@ not send the token in chat or save it in the checkout. Register interactively
|
||||
in a terminal so it is not placed in shell history or process arguments:
|
||||
|
||||
```bash
|
||||
sudo -u akyldash-runner -H /usr/local/bin/act_runner \
|
||||
sudo -u akyldash-runner env HOME=/var/lib/akyldash-runner/.config/act-runner \
|
||||
/usr/local/bin/act_runner \
|
||||
--config /etc/akyldash-runner/config.yaml register
|
||||
```
|
||||
|
||||
@@ -83,7 +172,7 @@ Enter `https://git.vakanaut.ru/`, the token, runner name `akyldash-ci-vps-01`,
|
||||
and label `akyldash-ci:docker://docker.gitea.com/runner-images:ubuntu-24.04`
|
||||
when prompted. The config sets the label and capacity; verify them in the
|
||||
repository runner settings. The token is saved only in the mode-0700 runner
|
||||
state directory, outside Git. Start/enable the runner after registration:
|
||||
runtime directory, outside Git. Start/enable the runner after registration:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now akyldash-act-runner.service
|
||||
@@ -106,24 +195,33 @@ 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
|
||||
|
||||
Update `runner_version` and its expected official SHA-256 together in
|
||||
`infra/runner/install-runner.sh`, review upstream release compatibility with
|
||||
the installed Gitea, and open a PR. From the reviewed checkout, re-run the
|
||||
installer only after resolving its existing-path guards. Do not replace the
|
||||
binary while a job is active. Stop the runner, install/verify the new binary,
|
||||
then start it and confirm Online plus a smoke run. Preserve `/var/lib/akyldash-runner/.runner`.
|
||||
the installed Gitea, and open a PR. Do not replace the binary while a job is
|
||||
active. Stop the runner, install/verify the new binary, then start it and
|
||||
confirm Online plus a smoke run. Preserve
|
||||
`/var/lib/akyldash-runner/.config/act-runner/.runner`.
|
||||
|
||||
## Recovery and rollback
|
||||
|
||||
The runner is disposable: production data and secrets are not on its filesystem.
|
||||
For a runner process failure, inspect `systemctl status` and
|
||||
`journalctl -u akyldash-act-runner.service`; restart the fixed runner service.
|
||||
If installation stops after creating or enabling the mount unit, inspect the
|
||||
image and mount with `stat`, `blkid`, `findmnt`, and `losetup`, then rerun the
|
||||
reviewed installer only when the image is 15 GiB ext4 and the active mount is
|
||||
backed by it. The installer recognizes that state and does not format or
|
||||
restart the mounted filesystem. If size, filesystem, unit contents, or backing
|
||||
device differ, stop and resolve the state manually; never format the existing
|
||||
image as a retry.
|
||||
For lost/corrupt registration state, stop the service and obtain a fresh
|
||||
repository-level token from Settings → Actions → Runners. Preserve the 15 GiB
|
||||
data image and state for diagnosis; re-register only after the repository
|
||||
@@ -133,6 +231,26 @@ automatic recovery step.
|
||||
|
||||
## Status
|
||||
|
||||
The configuration and scripts are prepared in Git. They have not been
|
||||
installed on the VPS, a repository-level token is not available to this agent,
|
||||
and no runner Online state or workflow execution is claimed yet.
|
||||
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.
|
||||
@@ -0,0 +1,209 @@
|
||||
# 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 publishing it under
|
||||
`DATA_ROOT/minjust-normalized`.
|
||||
|
||||
### Atomic publication gate for `DATA_ROOT`
|
||||
|
||||
The verified staging tree must be published to
|
||||
`DATA_ROOT/minjust-normalized` as one all-or-nothing operation, using an
|
||||
owner-approved atomic mechanism or a separately documented equivalent. An
|
||||
in-place copy into the live target is not an acceptable publication method.
|
||||
Preserve the current published corpus until the candidate is fully verified and
|
||||
ready for the atomic switch; never overwrite its contents in place.
|
||||
The publication mechanism and its interrupted-operation recovery have not yet
|
||||
been specified or tested; this is a release gate, not a claim that atomic corpus
|
||||
publication is implemented. Until that gate is completed, do not publish or
|
||||
replace a corpus in `DATA_ROOT`.
|
||||
|
||||
If preparation is interrupted before publication, keep the current target
|
||||
untouched. Resume only from the isolated staging tree after a full rehash and
|
||||
source comparison; if that cannot be established, fail closed and prepare a
|
||||
new staging candidate. If interruption makes the outcome of the final
|
||||
publication ambiguous, do not retry or replace anything automatically. Verify
|
||||
the target against the approved manifest and resolve the state with the owner
|
||||
before any retry. Never overwrite an existing corpus or OpenSearch volume.
|
||||
|
||||
The atomic filesystem publication of the approved corpus into `DATA_ROOT` and
|
||||
the OpenSearch alias switch are separate gates. A successful corpus publish
|
||||
does not authorize indexing or alias changes; do not conflate the two.
|
||||
|
||||
The bulk importer now refuses alias publication until all Bulk item results
|
||||
are successful, the full staged index is refreshed, its fragment count exactly
|
||||
matches the complete normalized inventory (including on resume), and approved
|
||||
Russian and Kyrgyz canaries both return text. The alias must currently target
|
||||
the exact `--expected-old-index`; wildcard removals and implicit first-time
|
||||
alias creation are rejected. OpenSearch performs removal of that exact target
|
||||
and addition of the candidate in one alias request. A mismatch or validation
|
||||
failure leaves the existing alias untouched. This is a code-level gate only:
|
||||
no production index or corpus was changed, and the live inventory and canary
|
||||
terms still require owner verification. See `backend/README.md` for the
|
||||
operator flags and use a new versioned index for every candidate.
|
||||
@@ -1,25 +1,209 @@
|
||||
# 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.
|
||||
## Windows ACL SID resolution — 2026-11-03
|
||||
|
||||
## VPS facts
|
||||
- Read-only PowerShell 5.1 inspection found that display-name conversion of
|
||||
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
|
||||
is readable. The installer and external-receiver guards now enumerate access
|
||||
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
|
||||
principals remain visible to the existing fail-closed policy. Regression
|
||||
coverage exercises synthetic unmapped SIDs through the actual installer guard
|
||||
functions. These repository changes are pending review; no Windows ACL,
|
||||
installer, task, VPS, or production state was changed. The native PowerShell
|
||||
harness still requires execution on Windows before this behavior is confirmed
|
||||
in that runtime.
|
||||
|
||||
## External deployment runtime binding — 2026-11-03
|
||||
|
||||
- PR #59 is merged to `main`; it contains the root-only read-only status
|
||||
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
|
||||
status path bounds the Gitea run list to ten entries, verifies the selected
|
||||
run/workflow/current `main`,
|
||||
validates artifact metadata and ZIP bytes, and returns only a release tuple.
|
||||
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
|
||||
compares protected Compose/Caddy bytes and installed release policy with
|
||||
Gitea content pinned to the release SHA, verifying each Git blob hash and
|
||||
size. This permits application-only release B after bootstrap A without
|
||||
advancing the root checkout or executing new root code. A runtime config or
|
||||
policy change fails closed and requires a reviewed bootstrap update. The
|
||||
Windows response state is `prepared`, never `candidate`; the `--once` task
|
||||
stores a protected local tuple and cannot call deploy. A separate disabled
|
||||
auto-apply task and config gate require the tuple to match a fresh status
|
||||
response from a previous poll. Status and apply retain current-main,
|
||||
successful CI/release provenance, descriptor digest, external backup,
|
||||
cutover, canary, and rollback checks. The receiver repeats the config check
|
||||
before journaling/stopping services and again after backup immediately before
|
||||
switching refs.
|
||||
- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию
|
||||
выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias —
|
||||
`akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи
|
||||
запускаются от интерактивного пользователя с минимальными правами, не допускают
|
||||
параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command,
|
||||
helper и trigger не устанавливались и не запускались. Путь canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и
|
||||
enabled-состояние не читались. Метаданные Windows подтверждают
|
||||
`C:\Python313\python.exe`, действительную Authenticode-подпись Python Software
|
||||
Foundation и защищённые ACL интерпретатора.
|
||||
- Linux fixtures прошли для Python import и command-path poisoning, включая очистку
|
||||
унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`.
|
||||
Installer получает ограниченный bundle из точной вершины `main`: упаковщик
|
||||
читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет
|
||||
SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до
|
||||
записи и не запускает Git. ACL проверяются явными масками мутаций и подмены;
|
||||
текущая учётная запись не может владеть кодовыми путями, а право создать
|
||||
соседнюю папку не считается подменой предка. Windows ACL/install проверки пока
|
||||
только прошли код-ревью; installer и launcher на Windows не запускались.
|
||||
Production CD остаётся выключенным.
|
||||
- Remaining owner prerequisites are installation of the reviewed root helper,
|
||||
exact forced-command/SSH host-key setup, a protected Windows client and its
|
||||
private state/config ACLs, private D: backup ACLs, and all previously listed
|
||||
backup/restore and production readiness gates. Neither the status endpoint nor client advances
|
||||
the host checkout or updates root helper code.
|
||||
|
||||
- Read-only Gitea check for fresh `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
|
||||
run #708 failed at the explicit registry credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
|
||||
skipped. No secret values were read. Image publication remains owner-blocked.
|
||||
|
||||
Owner decision recorded 2026-10-26: a complete backup must not be stored on
|
||||
the VPS, including temporarily. The local archive/full cold-backup commands in
|
||||
`BACKUP.md` are superseded and unauthorized. PR #59 now contains a proposed
|
||||
Windows-controlled external stream receiver and root-side deploy/rollback
|
||||
code. The Windows rerun passed 12 synthetic client checks and 8 protocol
|
||||
checks, plus a disposable real-NTFS fixture for the private owner ACL. The
|
||||
production D: path, SSH connection, VPS installation, full backup, and
|
||||
separate-target restore remain unverified. No verified external backup or
|
||||
separate-target restore exists, so production deployment remains blocked by
|
||||
the backup gate.
|
||||
|
||||
## Code-only deployment work — 2026-10-31
|
||||
|
||||
- Work is being prepared in the existing PR #59 branch from the then-current
|
||||
`main`; no merge, VPS operation, live release, or workflow dispatch was
|
||||
performed for this work.
|
||||
- The release descriptor now binds both the release run and its successful CI
|
||||
source run. Host-side source verification uses the installed `gitea-api`
|
||||
helper contract; the PR carries the inspected helper fixture plus a bounded
|
||||
binary-download patch. The installer verifies the fixture hash before
|
||||
applying the patch.
|
||||
- Backup bytes are streamed through a bounded SSH protocol to the fixed
|
||||
Windows destination. Root release state stores only a secret-free backup
|
||||
identity. The rollback handshake requires the exact previously pinned
|
||||
archive and creates a fresh external backup before image references change.
|
||||
Local VPS archive paths fail closed.
|
||||
- Review fixes in this PR require Gitea `status=completed` with
|
||||
`conclusion=success`, resolve `main` from `/opt/akyldash/current`, write a
|
||||
durable pending journal before stopping services, and reject an already
|
||||
applied release before downtime. The Windows client checks D: capacity,
|
||||
NTFS/path/ACL owner and allowed entries, and the local ignore rule before
|
||||
SSH; it requires 100 GiB plus 1 MiB free. The Windows rerun passed 12
|
||||
synthetic client checks, 8 protocol checks, and a disposable real-NTFS
|
||||
private owner-ACL fixture; actual D:, SSH, and VPS remain unverified.
|
||||
- Receiver cleanup after a post-publication ACL failure is identity-bound to
|
||||
the owned staging directory and permits only the expected archive files. A
|
||||
replaced, reparse, or unexpected target is left for manual review without an
|
||||
ACK; this path is covered by local fixtures and the Windows rerun.
|
||||
- Synthetic tests cover archive/protocol integrity, helper download rules,
|
||||
artifact/run provenance, receiver publication, release schema, and rollback
|
||||
refusal. The 12 synthetic Windows client checks, 8 protocol checks, and
|
||||
disposable real-NTFS private owner-ACL fixture passed. The actual D: path,
|
||||
SSH alias, Gitea helper against
|
||||
the live instance, root helper installation, artifact redirect, production
|
||||
backup/restore, and actual release/rollback remain unverified.
|
||||
- Owner bootstrap blockers remain: verify a root-controlled SSH route for the
|
||||
fixed commands using existing host-key checking; provision only the
|
||||
necessary read-only Gitea token, exact redirect origin if needed, and
|
||||
approved RU/KY canaries; confirm registry pull and retention policy; test
|
||||
the Windows client and a separate-target restore. No new key, sudo rule,
|
||||
Docker access, credential, or VPS setting is added by the code change.
|
||||
|
||||
Read-only deployment recheck: 2026-10-25. Source: SSH as `codex`, direct-origin
|
||||
and public HTTPS probes, local `main` checkout, and Gitea API. No VPS files,
|
||||
services, firewall, containers, volumes, production data, or credentials were
|
||||
changed or inspected. Root-only Docker state, UFW rule details, protected file
|
||||
metadata, current backup/restore validity, and the live corpus/index remain
|
||||
unverified. The 2026-10-18 inventory below is a historical snapshot where it
|
||||
conflicts with this dated recheck.
|
||||
|
||||
## Latest read-only recheck — 2026-10-25
|
||||
|
||||
- `https://akyldash.kg/review` returned HTTP 200 with valid TLS through the
|
||||
public Cloudflare edge and through direct HTTPS to origin `206.223.249.118`.
|
||||
This verifies that route only; it does not prove search or index readiness.
|
||||
- TCP listeners now include 22, 80, and 443. The older no-listener/Cloudflare
|
||||
521 observations below are historical, not current state.
|
||||
- VPS reports 4 CPUs, 7.7 GiB RAM (about 3.7 GiB available), and 31 GiB free
|
||||
on `/`. Current runner-mount free space was not rechecked.
|
||||
- `/opt/akyldash/current` exists, is a root-owned directory mode `0755`.
|
||||
Reading its Git SHA failed with Git's `dubious ownership` safety guard; no
|
||||
`safe.directory` setting was added, so the installed revision is unknown.
|
||||
- `akyldash-act-runner.service` is active under `akyldash-runner`; installed
|
||||
`act_runner` is `0.2.13`. The separate `akyldash-runner-restart.service`
|
||||
remains failed since 2026-10-14; its cause could not be read from the
|
||||
unprivileged journal view. No restart was attempted.
|
||||
- `akyldash-verify`, `akyldash-compose`, and `akyldash-rollback` are absent
|
||||
from PATH in the unprivileged check; no root helper or Docker operation was
|
||||
run.
|
||||
- Gitea is `1.25.5`; `main` is `b5adc09203cbbbd298054248206b9f47ae4a44ca`.
|
||||
CI run 175 and runner smoke 176 succeeded on that commit. Package write
|
||||
access and runner inventory remain outside this API principal's permissions.
|
||||
- PR #59 head `852d639c455ff1c60202405e112d47b8128d66c6` received formal
|
||||
review #308 with five requested fixes. CI run 177 failed in
|
||||
`test_digest_rollback_fails_before_stopping_or_changing_references` because
|
||||
the test's global `Path.stat` mock made `Path.exists()` report a false
|
||||
recovery-journal hit under CI Python 3.12; runner smoke 178 succeeded. A
|
||||
fix batch in this PR isolates state paths, handles ambiguous alias/cutover
|
||||
outcomes, verifies rollback image IDs, and adds a synthetic artifact upload
|
||||
check. On 2026-10-25 CI run 179 and runner smoke 180 succeeded at PR head
|
||||
`9d8ce6b6`; CI took 2m43s and the synthetic upload step reported success.
|
||||
The run API identifies run ID 567 and reports `run_attempt: 0`, Gitea's
|
||||
legacy sentinel. The release workflow therefore uses the unique run ID and
|
||||
does not depend on the unavailable attempt value. The run's artifact link was
|
||||
downloaded from Gitea UI as `artifact-smoke-567-`: the ZIP was 191 bytes,
|
||||
SHA-256 `ae0a2262147f222aacb4fdd1d12459f7160c67b1d0ee5d7ae2db1de8958f120f`,
|
||||
and contained the expected 51-byte `marker.txt`. The API artifact listing
|
||||
available to this principal returned no entries, but the downloaded file
|
||||
verifies artifact availability. No live release or VPS change was made.
|
||||
- The release descriptor SHA-256 sidecar now records `release-manifest.json`
|
||||
relative to the `release` directory, matching the flattened layout observed
|
||||
for downloaded Gitea artifacts. A local synthetic regression verifies the
|
||||
checksum from the artifact extraction root; the credentialed release
|
||||
workflow remains unrun.
|
||||
- Owner-only outstanding evidence: sanitized container/volume and protected
|
||||
file metadata; current backup and separate-target restore verification;
|
||||
live corpus/index counts and RU/KY canaries; Cloudflare TLS mode; registry
|
||||
writer identity and digest retention. Never include secret values in that
|
||||
evidence packet. The checklist is in
|
||||
[`DEPLOYMENT_OWNER_PACKET.md`](DEPLOYMENT_OWNER_PACKET.md).
|
||||
|
||||
The deployment PR adds a manual main-only image-publish workflow and an
|
||||
owner-run `--no-build` cutover path, but neither has been executed against the
|
||||
VPS. Its release descriptor is an unsigned trace record; cryptographic image
|
||||
attestation remains a separate owner decision.
|
||||
|
||||
## Historical host snapshot — 2026-10-18
|
||||
|
||||
Source: SSH as `codex`, local repository inspection, and Gitea API. Root-only
|
||||
Docker state, UFW rule details, and protected file metadata were not verified.
|
||||
|
||||
## Historical VPS facts — 2026-10-18
|
||||
|
||||
- Host: `avprogs.hlab.kz`, `206.223.249.118`, SSH port `22`, user `codex`.
|
||||
- OS: Ubuntu Server `24.04.5 LTS`; kernel `6.8.0-142-generic` from the prior
|
||||
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 +211,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 +225,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,28 +268,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. This is repository code only: no runner package, account, store,
|
||||
service, token, or registration is installed 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,
|
||||
@@ -109,8 +437,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.
|
||||
|
||||
@@ -128,12 +458,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.
|
||||
@@ -1,83 +1,237 @@
|
||||
# Deployment
|
||||
|
||||
## Current status
|
||||
## Status and authorization
|
||||
|
||||
Production deployment is not enabled or tested on the VPS. The owner completed
|
||||
the root bootstrap: Docker/Compose are active and target directories exist.
|
||||
The `codex` account cannot access the Docker socket. No root-owned checkout or
|
||||
restricted helpers are installed; protected env/secret metadata and Docker
|
||||
container/volume state remain unverified because they require root.
|
||||
The unprivileged audit sees no listener on `80`, `443`, or `1500`; Cloudflare
|
||||
still returns `521`. Do not deploy until the owner has reviewed root-only host
|
||||
state, supplied protected configuration and the approved corpus/index, and
|
||||
backup/restore have been tested.
|
||||
The release and fixed-receiver code from PR #59 is on `main`, but has not been
|
||||
installed or exercised against the VPS. This PR's compatibility and Windows
|
||||
bootstrap changes are still proposed. No release, backup, restore, or
|
||||
production service operation has been performed by this change. Production
|
||||
deployment remains blocked until the owner has verified host state, registry
|
||||
access, protected configuration,
|
||||
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
|
||||
separate target. See [current state](CURRENT_STATE.md) and the
|
||||
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
|
||||
|
||||
## First host preparation
|
||||
The existing `codex` account has no Docker socket access and receives no new
|
||||
privilege in this change. The external receiver requires an owner-controlled
|
||||
SSH entry that runs the fixed root command; no SSH key, `authorized_keys`
|
||||
entry, sudo rule, or root alias is created here. If `akyldash_vps` logs in as
|
||||
`codex`, it cannot run the root-only receiver and the client fails closed.
|
||||
Do not grant general sudo or Docker access to make the flow work.
|
||||
|
||||
1. The initial root bootstrap is complete. After reviewing and merging the
|
||||
infrastructure PR, the owner must install that approved `main` checkout at
|
||||
`/opt/akyldash/current` (the path was absent during the unprivileged audit):
|
||||
## Release verification and image publication
|
||||
|
||||
```bash
|
||||
sudo git clone --branch main --single-branch https://git.vakanaut.ru/admin/akyldash.git /opt/akyldash/current
|
||||
```
|
||||
`.gitea/workflows/ci.yaml` remains the required test and image-build workflow.
|
||||
The release workflow runs after successful main CI, verifies the source run and
|
||||
commit, and publishes digest-pinned images plus a small release descriptor.
|
||||
The descriptor records both the release workflow run ID and the successful CI
|
||||
source run ID. PR builds do not publish images. Production runtime secrets are
|
||||
not supplied to CI.
|
||||
|
||||
The checkout is root-owned by default. Then run
|
||||
`sudo bash /opt/akyldash/current/infra/bootstrap/audit-host.sh`. This
|
||||
read-only audit reports Docker state, protected-file metadata, UFW rules,
|
||||
listeners, and ISPmanager without printing secret contents. Resolve any
|
||||
existing containers, volumes, or data before proceeding; do not overwrite
|
||||
or delete them.
|
||||
2. After confirming the existing env and secret do not exist and reviewing the
|
||||
tracked defaults, run
|
||||
`sudo bash /opt/akyldash/current/infra/deploy/prepare-production-config.sh`.
|
||||
It creates `/etc/akyldash/production.env` from the tracked example and
|
||||
generates `/etc/akyldash/review_secret`; both are root-owned mode `0600`.
|
||||
It refuses to overwrite either file and does not start services or create
|
||||
volumes. Review `DATA_ROOT` and 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:
|
||||
Read-only Gitea check on 2026-11-03 for current `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
|
||||
run #708 failed at the explicit registry-credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
|
||||
#709 was skipped. No secret values were read. Image publication therefore
|
||||
remains blocked until the owner provisions the required registry secrets.
|
||||
|
||||
```bash
|
||||
sudo sh -c 'docker compose --env-file /etc/akyldash/production.env -f /opt/akyldash/current/deploy/production/compose.yaml ps --quiet backend | xargs -r docker inspect --format "{{.Image}}" > /opt/akyldash/deployed-image.txt'
|
||||
sudo chmod 0600 /opt/akyldash/deployed-image.txt
|
||||
```
|
||||
On the host, the fixed root receiver uses the installed `gitea-api` helper to
|
||||
re-fetch and verify the exact successful CI and release runs, current `main`,
|
||||
artifact name, checksum, manifest schema, builder pin, image policy, and commit
|
||||
binding. Its binary `DOWNLOAD` extension accepts HTTPS and at most one
|
||||
allowlisted HTTPS redirect, does not forward the token across origins, bounds
|
||||
the response, and refuses to overwrite an existing file. The installer checks
|
||||
the exact audited helper fixture hash before applying its patch. The helper
|
||||
and source code are not installed on the VPS by this PR.
|
||||
|
||||
Application deployment remains owner-run. 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.
|
||||
## One-shot runtime binding and bootstrap compatibility
|
||||
|
||||
## CI
|
||||
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
The dispatcher accepts only the literal status or deploy command in
|
||||
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
|
||||
evaluation. Status verifies successful current-main provenance, artifact
|
||||
metadata/ZIP, and host runtime compatibility before returning only a run ID,
|
||||
commit, and descriptor digest in a `prepared` response. It fails closed on a
|
||||
recovery journal, incomplete Gitea run listing, invalid release data, changed
|
||||
host runtime configuration, or an already applied release. The root receiver
|
||||
independently repeats release verification and the existing backup/ACK/cutover
|
||||
guards.
|
||||
|
||||
`.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.
|
||||
The Windows `--once` mode only polls the fixed read-only status endpoint and
|
||||
stores a protected local `PREPARED` tuple. It never sends a deploy request.
|
||||
Automatic deployment uses a second task and a separate
|
||||
`release-auto-apply.json` gate; both config files and both scheduler XML
|
||||
templates default to disabled. The apply task acts only when its current
|
||||
verified tuple exactly matches a tuple saved by a previous poll. An owner can
|
||||
also use `--apply-prepared`, which requires an interactive exact-SHA
|
||||
confirmation. Both deploy paths re-enter the existing root receiver and all
|
||||
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
|
||||
командой. Планировщик запускает задачи от интерактивного владельца с минимальными
|
||||
правами, пропускает повторный запуск при активном экземпляре и не будит компьютер.
|
||||
Этот PR не зарегистрировал и не включил ни одну задачу.
|
||||
|
||||
Сначала подготовьте bundle под обычной учётной записью из точной локальной
|
||||
вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает
|
||||
только Git objects указанного полного commit SHA, сверяет Git blob identity
|
||||
исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не
|
||||
копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256.
|
||||
Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива,
|
||||
manifest и Git blob IDs в памяти, затем копирует только проверенные байты.
|
||||
Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`.
|
||||
На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout
|
||||
не используются. Не изменяйте ZIP между подготовкой и установкой.
|
||||
Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer
|
||||
проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка
|
||||
упаковщика и фактической функции mode-валидации на Windows используйте
|
||||
`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`;
|
||||
`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический
|
||||
Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и
|
||||
проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает
|
||||
installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику
|
||||
выполнения PowerShell менять для него не следует.
|
||||
|
||||
Проверенный bootstrap installer проверяет подпись PSF у
|
||||
`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в
|
||||
SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию.
|
||||
Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем
|
||||
могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись
|
||||
задачи допускается владельцем только для отдельного private state/config.
|
||||
Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись,
|
||||
добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для
|
||||
родительских каталогов проверяются права, способные подменить защищённый путь;
|
||||
разрешение создать посторонний соседний каталог подменой не считается.
|
||||
Installer сохраняет существующий canonical-файл
|
||||
`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй
|
||||
disabled auto-apply config создаётся только при отсутствии. Installer формирует
|
||||
два disabled task XML для проверки владельцем, но не регистрирует и не включает
|
||||
задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только
|
||||
стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение
|
||||
импортированных модулей. До импорта receiver он заменяет унаследованный command
|
||||
lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и
|
||||
проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути.
|
||||
Для самих бинарников и их родительских каталогов проверяются reparse points,
|
||||
владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths
|
||||
выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба
|
||||
config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore
|
||||
и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner.
|
||||
|
||||
The root status/deploy helper remains the reviewed root-installed bootstrap
|
||||
code; it never checks out or executes target release code. A newer app release
|
||||
is eligible without manually advancing `/opt/akyldash/current` only when the
|
||||
host's Compose file, Caddyfile, and installed release policy match the exact
|
||||
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
|
||||
the Git blob SHA and size are verified. Status checks before preparing the
|
||||
tuple; the receiver repeats the check before creating a journal or stopping
|
||||
services, and cutover checks again after the verified external backup and
|
||||
immediately before switching image references. A Compose/Caddy mismatch
|
||||
returns `runtime_config_changed`; a policy mismatch returns
|
||||
`release_policy_changed` and requires a reviewed owner bootstrap update. No
|
||||
host checkout or root helper is automatically changed. The release must still
|
||||
be current `main`; CI/release provenance, digest, backup, canary, and rollback
|
||||
checks remain required. Gitea's filtered workflow-run listing is bounded to
|
||||
ten entries; if Gitea reports more entries or an inconsistent page, status
|
||||
returns unavailable rather than guessing.
|
||||
|
||||
## Automation boundary: deployment is not active
|
||||
|
||||
Until the owner separately activates both Windows tasks and the auto-apply
|
||||
configuration after the backup/restore and production gates, the chain ends at
|
||||
release publication. After that explicit one-time owner authorization, the
|
||||
prepared-state poll and separate apply task can process later matching
|
||||
application releases without advancing `/opt/akyldash/current` or approving
|
||||
each commit individually. A release that changes protected runtime files still
|
||||
requires a reviewed bootstrap update.
|
||||
|
||||
1. Successful `main` CI triggers image builds and publishes digest references
|
||||
plus the release descriptor artifact.
|
||||
2. `deploy-handoff.yaml` verifies the source run and may read artifact metadata.
|
||||
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
|
||||
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
|
||||
receiver. A green result means only that this status check completed.
|
||||
3. The fixed receiver is physically started only by the separately enabled
|
||||
owner-controlled auto-apply task or by an interactive owner command. The
|
||||
`--once` preparation task stores a verified tuple and never sends deploy;
|
||||
the separate apply task requires the tuple to match a fresh status response.
|
||||
No repository workflow invokes either task. Both scheduler templates and
|
||||
both configuration gates remain disabled, and neither task has been
|
||||
installed or exercised.
|
||||
|
||||
Production CD is still not active: task/credential setup has not been verified,
|
||||
and no VPS or live D: operation has been performed. This design avoids a
|
||||
Windows Actions runner entirely; the owner-controlled local task is not exposed
|
||||
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
|
||||
commands and verified host key. Do not use an unrestricted `authorized_keys`
|
||||
entry or a persistent host-mode runner with D: and SSH access.
|
||||
|
||||
## External backup and cutover
|
||||
|
||||
The fixed root release receiver accepts only a bounded protocol request over
|
||||
stdin. It does not accept a remote shell command, artifact path, or image
|
||||
reference from the caller. It rechecks the release and current `main`, takes a
|
||||
shared lock, captures running image IDs, and stops the stack. It streams the
|
||||
cold archive directly to the owner's Windows client at
|
||||
`D:\Dev\akyldash\.backups`; the VPS does not hold a full archive, including
|
||||
temporarily. The Windows client requires a real NTFS destination with private
|
||||
ACLs and a precise local Git exclude rule. It publishes only after hash,
|
||||
archive, and manifest verification, and acknowledges the exact live session
|
||||
nonce and backup identity.
|
||||
|
||||
Only after the acknowledgement does the host pull approved digest references,
|
||||
atomically replace both image refs, and start Compose with `--no-build`. It
|
||||
checks running image IDs, `/review`, and non-empty Russian and Kyrgyz search
|
||||
canaries. A stale main, source mismatch, invalid archive, image mismatch, or
|
||||
failed health/canary check stops cutover and attempts recovery using the
|
||||
pre-change references. A recovery journal blocks another operation if
|
||||
automatic recovery cannot be verified.
|
||||
|
||||
Rollback uses the root-pinned prior external archive and creates a fresh
|
||||
external backup of current state before switching refs. It restores images
|
||||
only, not data. Old local VPS backup paths and legacy release states without a
|
||||
pinned external backup identity are rejected. See [backup and restore](BACKUP.md)
|
||||
and [rollback](ROLLBACK.md).
|
||||
|
||||
The receiver records a durable pending recovery journal before stopping
|
||||
services. A retry of the exact release already recorded in pinned state is
|
||||
rejected before backup or downtime. Gitea source verification requires
|
||||
`status=completed` and `conclusion=success`, and resolves current `main` from
|
||||
the explicit production checkout. The Windows client checks the destination,
|
||||
ACL, ignore rule, and full protocol archive capacity before opening SSH.
|
||||
|
||||
## Owner bootstrap still required
|
||||
|
||||
After review and merge, the owner must decide whether to install the helper
|
||||
from a root-owned checkout and provision, without exposing values in logs:
|
||||
|
||||
- a read-only Gitea API token in `/etc/akyldash/gitea-api-token`, root-owned
|
||||
mode `0600`;
|
||||
- the exact artifact redirect origin allowlist in
|
||||
`/etc/akyldash/gitea-download-origins`, if the Gitea artifact endpoint
|
||||
redirects;
|
||||
- approved Russian and Kyrgyz search canaries in
|
||||
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`;
|
||||
- a root-only SSH path to `/usr/local/sbin/akyldash-release-deploy` and
|
||||
`/usr/local/sbin/akyldash-release-rollback` using the existing owner key and
|
||||
verified host key.
|
||||
- a dedicated SSH alias `akyldash_release` backed by a key restricted to
|
||||
`/usr/local/sbin/akyldash-release-dispatch`; its forced command must preserve
|
||||
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
|
||||
or arbitrary command access;
|
||||
- a protected installation of the Windows client under
|
||||
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
|
||||
XML templates reviewed. Keep the preparation task/config separate from the
|
||||
auto-apply task/config; the auto-apply pair remains disabled until the owner
|
||||
explicitly accepts unattended production deployment.
|
||||
|
||||
The installer intentionally does not create credentials, SSH entries, canary
|
||||
values, registry permissions, or a new privilege rule. Existing files with
|
||||
unexpected owner, mode, or content cause installation to stop for owner
|
||||
review. Do not run the installer or receiver from CI. The owner's first use
|
||||
must be a separate-target restore check and a reviewed dry run; this PR does
|
||||
not authorize a production cutover.
|
||||
|
||||
The Windows client uses the existing OpenSSH host-key checking. Its configured
|
||||
alias must reach a root-controlled endpoint capable of invoking the fixed
|
||||
receiver; no new key or sudo permission is requested. Windows ACL and NTFS
|
||||
checks, SSH host configuration, artifact redirect origin, registry pull
|
||||
access, and every production health gate remain unverified until the owner
|
||||
checks them.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Пакет проверок владельца перед деплоем
|
||||
|
||||
Здесь перечислены проверки, которые выполняет владелец. Этот документ не
|
||||
разрешает production-изменения. Не включайте значения токенов и паролей,
|
||||
содержимое env-файлов, приватные ключи, другие секреты или полный вывод
|
||||
переменных окружения.
|
||||
|
||||
## Что требуется до использования CD
|
||||
|
||||
- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но
|
||||
не установлен и не запускался. Bootstrap из этого PR также не установлен.
|
||||
После ревью и слияния обновите локальный `main` до проверенного полного SHA.
|
||||
Под обычным пользователем подготовьте bundle командой
|
||||
`prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit <SHA> --output <new ZIP path>`.
|
||||
Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает
|
||||
Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов
|
||||
commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь
|
||||
elevated installer:
|
||||
`install_windows_release_client.ps1 -ExpectedCommit <SHA> -BundlePath <path> -ExpectedBundleSha256 <digest>`.
|
||||
Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256,
|
||||
manifest и цепочку `commit → tree → файлы` в памяти и только после полной
|
||||
проверки копирует эти байты. Elevated installer не запускает Git и не читает
|
||||
`.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и
|
||||
установкой; сохраните SHA и полный commit для журнала владельца.
|
||||
- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые
|
||||
ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей
|
||||
версии и назначает владельцем новых файлов кода `Administrators`. Кодовые
|
||||
пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller;
|
||||
учётная запись задачи допускается владельцем только для отдельных private
|
||||
state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление,
|
||||
удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских
|
||||
каталогов выявляет права, позволяющие заменить защищённый путь; создание
|
||||
постороннего соседнего каталога само по себе не считается подменой.
|
||||
- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json`
|
||||
только если он остаётся отключённым, создаёт disabled auto-apply config лишь
|
||||
при отсутствии и формирует два disabled Task Scheduler XML для проверки.
|
||||
Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое
|
||||
и его enabled-состояние не читались.
|
||||
- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`,
|
||||
проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`,
|
||||
очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe`
|
||||
в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и
|
||||
никогда не отправляет deploy-запрос.
|
||||
- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного
|
||||
`enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён,
|
||||
только если свежий root status точно совпал с tuple, сохранённым предыдущим
|
||||
опросом. После явного согласования владельцем unattended deployment отдельное
|
||||
подтверждение каждого SHA не требуется. Включайте preparation config и задачу
|
||||
только после отдельного разрешения на опрос; auto-apply config и задачу — только
|
||||
после отдельного разрешения на unattended production deployment и выполнения
|
||||
всех backup/restore gate. Отключение любого auto-apply control останавливает
|
||||
будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть
|
||||
восстановлен по процедуре.
|
||||
- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command
|
||||
dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding),
|
||||
проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте
|
||||
unrestricted shell или команду в root `authorized_keys`. Существующий ключ и
|
||||
путь rollback остаются отдельным ручным действием владельца. PR не создаёт,
|
||||
не копирует и не устанавливает ключи.
|
||||
- Решите, допустим ли unattended production CD. Сейчас он не включён:
|
||||
`deploy-handoff.yaml` остаётся только status-проверкой и сообщает
|
||||
`NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост
|
||||
не открыт workflow и PR runner. Убедитесь, что root status command установлен
|
||||
из проверенного bootstrap и ключ forced command ограничен.
|
||||
- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает
|
||||
защищённые root Compose/Caddy файлы и установленную release policy с файлами
|
||||
точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs
|
||||
и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет
|
||||
его после backup. Несовпадение требует отдельного проверенного owner bootstrap
|
||||
update; код release или root helper автоматически не выполняется и не обновляется.
|
||||
Текущий `main` и CI/release provenance всё равно перепроверяются перед apply.
|
||||
Endpoint закрывается при неполной странице workflow-run API (более десяти
|
||||
кандидатов или несовпадение count). Перед включением проверьте поведение этого
|
||||
API на установленной версии Gitea.
|
||||
- Подтвердите имена registry packages и минимальные права записи/чтения для
|
||||
`admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не
|
||||
короче rollback window. Последняя read-only проверка Gitea: main CI run #706
|
||||
завершился успешно, image release run #708 завершился ошибкой из-за отсутствия
|
||||
repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only
|
||||
handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea;
|
||||
не помещайте значения в Git или логи.
|
||||
- Проверьте, что срок хранения Gitea artifacts покрывает rollback window.
|
||||
Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней.
|
||||
- Подготовьте датированный очищенный root audit: SHA установленного checkout,
|
||||
Compose services и image IDs, состояние health, пути и режимы volumes,
|
||||
владельца/режим защищённых файлов, сводку listeners и состояние recovery journal.
|
||||
- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд,
|
||||
используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный
|
||||
`codex` не должен получать общий sudo, членство в Docker group или Docker socket.
|
||||
PR не устанавливает SSH key или root alias.
|
||||
- Если потребуется, установите узко ограниченный read-only Gitea API token в
|
||||
`/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только
|
||||
наличие и scope токена, не его значение.
|
||||
- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только
|
||||
точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned,
|
||||
mode `0600`. Исправленный helper не должен отправлять этому origin token.
|
||||
- Добавьте одобренные владельцем RU и KY search canary queries в
|
||||
`/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что
|
||||
они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса.
|
||||
- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность
|
||||
публичных `/review` и search.
|
||||
|
||||
## Backup и восстановление
|
||||
|
||||
- Полный архив хранится только на Windows-носителе владельца
|
||||
`D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS.
|
||||
- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture
|
||||
прошли. До использования проверьте реальный путь D: и локальное правило
|
||||
`.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только
|
||||
после проверки hash и manifest.
|
||||
- Подтвердите шифрование D: при хранении. Архив содержит production secrets и
|
||||
private keys; протокол шифрует передачу, но не хранение файлов.
|
||||
- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные,
|
||||
SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production.
|
||||
- Подтвердите текущие counts данных и индекса, corpus generation и совместимость
|
||||
миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы.
|
||||
- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь
|
||||
rollback window.
|
||||
|
||||
Installer не создаёт credentials, SSH configuration, canary values, доступ к
|
||||
registry или новые sudo rules. PR не установлен и не запускался на production.
|
||||
Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную
|
||||
private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались;
|
||||
реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены.
|
||||
Не включайте deployment до фиксации владельцем результатов всех проверок выше.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,462 @@
|
||||
# План реализации и приёмки Акылдаш
|
||||
|
||||
Дата актуализации: **2 ноября 2026 года**. Основа сверки: `main` на
|
||||
`7ece39f212b67e497a8dfb360d5d271ab088ef57`. Это единый план работ по продукту,
|
||||
интерфейсу и переходу к production. Он не заменяет эксплуатационные runbooks
|
||||
и не подтверждает состояние живого сервиса.
|
||||
|
||||
## 1. Статус и границы достоверности
|
||||
|
||||
Различаем три вида сведений:
|
||||
|
||||
- **В коде** — наличие исходного кода на указанном commit; это не доказывает,
|
||||
что функция развёрнута или доступна пользователям.
|
||||
- **Историческое наблюдение** — результат конкретного аудита, дата и пределы
|
||||
которого указаны рядом. Оно не является повторной проверкой текущего сайта.
|
||||
- **Цель / gate** — требование, которое нужно выполнить и подтвердить до
|
||||
следующего этапа.
|
||||
|
||||
На сверенном commit источники кода показывают Backend `0.13.3`
|
||||
(`backend/search/minjust_opensearch.py`), Frontend `0.3.3`
|
||||
(`backend/search/review.html`) и Telegram-бот `0.2.2`
|
||||
(`tools/telegram-bot/bot.py`). Это версии исходного кода, а не подтверждение
|
||||
установленных production-версий. В этом PR согласованы версии в корневом и
|
||||
общем документационном README; `backend/README.md` отдельно называет версию
|
||||
загрузчика `minjust_cbd.py` (`0.9.3`). При новых изменениях сверять версию
|
||||
компонента с соответствующим исполняемым кодом.
|
||||
|
||||
В Search API и frontend реализованы поиск RU/KY, фильтры, карточка документа,
|
||||
редакции, поиск в тексте, внутренняя лаборатория, вход и административные
|
||||
действия. В коде также есть локальные Noto Sans/Serif с лицензиями, favicon и
|
||||
встроенный SVG-знак бренда. Наличие файлов и ссылок в HTML не подтверждает,
|
||||
что они загрузились в live-браузере. Результаты браузерной проверки приведены
|
||||
только в [отчёте визуальной приёмки](visual-review/README.md).
|
||||
|
||||
Production readiness не следует из наличия Compose, release workflow или
|
||||
deploy-кода. Подтверждённое состояние и ограничения находятся в
|
||||
[CURRENT_STATE.md](../operations/CURRENT_STATE.md); статус релиза — в
|
||||
[DEPLOYMENT.md](../operations/DEPLOYMENT.md), ограничения backup/restore — в
|
||||
[BACKUP.md](../operations/BACKUP.md), rollback — в
|
||||
[ROLLBACK.md](../operations/ROLLBACK.md), аварийное восстановление — в
|
||||
[RECOVERY.md](../operations/RECOVERY.md). Этот план не повторяет команды из
|
||||
этих runbooks.
|
||||
|
||||
## 2. Очерёдность работ
|
||||
|
||||
### 2.1 Продукт и качество правовых данных
|
||||
|
||||
1. Поддерживать воспроизводимое получение и нормализацию официальных
|
||||
правовых материалов с сохранением источника, редакции и контрольных сумм.
|
||||
2. Подтвердить полноту и актуальность RU/KY корпуса, правила для отсутствующих
|
||||
текстов и одноязычных редакций; не выдумывать перевод или реквизиты.
|
||||
3. Развивать замороженный relevance set и измерять качество на одной и той же
|
||||
выборке до и после изменений ранжирования.
|
||||
4. Развивать версионируемые API и справочники, сохраняя совместимость
|
||||
существующих клиентов.
|
||||
5. Публичный frontend, персональные функции и RAG остаются отдельными
|
||||
продуктовыми решениями и readiness gates. Сверяться с [обзором проекта](project-overview.md),
|
||||
[планом frontend](frontend-search-sps-plan.md) и
|
||||
[готовностью публичного frontend](frontend-design-readiness-plan.md). Конкурентная
|
||||
гипотеза и сегмент рынка отдельно описаны в
|
||||
[стратегическом сравнении с Токтом](platform-development-plan-toktom-2026-10-24.md).
|
||||
|
||||
### 2.2 Интерфейс: датированный реестр UI-01—UI-33
|
||||
|
||||
Реестр ниже сохраняет исходные наблюдения аудита **23 октября 2026 года** и
|
||||
критерии приёмки; это не список текущих открытых дефектов. Актуальный план на
|
||||
`main` указывает, что все UI-01—UI-33 реализованы в PR #54. Он также описывает
|
||||
follow-up по поиску, кабинету и лаборатории, локальную браузерную проверку и
|
||||
синтетические regression fixtures. Их результаты привязаны к указанным в
|
||||
исходном документе commits и окружению, а не являются повторным live-аудитом в
|
||||
этой документационной задаче. Сверяться с [отчётом visual-review](visual-review/README.md);
|
||||
не открывать снова выполненные пункты без нового воспроизводимого свидетельства.
|
||||
|
||||
Источники по состоянию на базовый commit этого PR сохранены постоянными
|
||||
ссылками: [UI-план на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/interface-correction-plan.md)
|
||||
и [план pixel-perfect на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/frontend-pixel-perfect-plan.md).
|
||||
UI-01—UI-33 вошли в [PR #54](https://git.vakanaut.ru/admin/akyldash/pulls/54)
|
||||
(merge commit [`01380d3c`](https://git.vakanaut.ru/admin/akyldash/commit/01380d3c09d71e32c965d974a39a4d82de0a15c5));
|
||||
последующий follow-up поиска, кабинета и лаборатории записан в
|
||||
[commit `e5a8326a`](https://git.vakanaut.ru/admin/akyldash/commit/e5a8326a5e3def1bf5d5dc94794da82a2af801ab).
|
||||
|
||||
В исходном реестре **P1** означает обязательное исправление до визуальной
|
||||
приёмки/выпуска, **P2** — существенное улучшение понятности и
|
||||
последовательности. Отсутствие функции само по себе не считается багом;
|
||||
обозначенные целевые корректировки требуют подтверждения по коду и дизайну.
|
||||
|
||||
| ID | Приоритет | Историческое наблюдение 23.10.2026 | Требование / критерий |
|
||||
|---|---|---|---|
|
||||
| UI-01 | P1 | При пустом запросе одновременно показывались подсказка и empty-состояние | Разделить initial/loading/results/empty/error; empty показывать после завершённого поиска |
|
||||
| UI-02 | P1 | До выбора документа были доступны действия и пустые select | До загрузки документа показывать понятное приглашение; действия доступны после выбора |
|
||||
| UI-03 | P1 | Фильтры имели горизонтальное переполнение (`clientWidth=194`, `scrollWidth=231` при 1064 px) | Исключить переполнение; длинные списки раскрывать по запросу |
|
||||
| UI-04 | P1 | Действие «Сбросить» обрезалось заголовком | Заголовок и действие видны целиком на поддерживаемых размерах |
|
||||
| UI-05 | P1 | Счётчики отрывались от подписей, переносы растягивали панель | Разделить колонки выбора/подписи/числа; переносить только длинные названия |
|
||||
| UI-06 | P1 | Все 28 видов и 8 статусов были раскрыты, орган и даты находились ниже первого экрана | Показывать основные значения и сворачиваемые группы, сохраняя доступ ко всему каталогу |
|
||||
| UI-07 | P1 | Ширины около 78 px не хватало для RU/KY названий языка | Подбирать размер по самой длинной локализованной подписи без обрезки |
|
||||
| UI-08 | P1 | На 390 px навигация прокручивалась горизонтально, кабинет и фильтры уходили за край | Адаптивно показывать поиск, фильтры и кабинет без горизонтальной прокрутки основных действий |
|
||||
| UI-09 | P2 | Кабинет был малозаметной иконкой во второй строке | Подписанная desktop-кнопка; на mobile — доступное имя и достаточная touch-область |
|
||||
| UI-10 | P1 | Три колонки оставались тесными на 1024/1064 px | Перейти к двухпанельному режиму и скрытию фильтров, не сжимать колонки бесконечно |
|
||||
| UI-11 | P2 | Название документа повторялось в breadcrumb, заголовке и тексте | Breadcrumb обозначает контекст; официальный текст не переписывать |
|
||||
| UI-12 | P1 | Ссылка на источник и toolbar переполнялись на промежуточной ширине | Переносить действия группами без обрезки; сохранять подписи редакции и языка |
|
||||
| UI-13 | P2 | Пустые абзацы и синие ссылки конфликтовали с оболочкой | Нормализовать отступы и ссылки без потери структуры официального текста |
|
||||
| UI-14 | P1 | Таблицы в документе шире панели (`clientWidth=412`, `scrollWidth=834`) | Дать таблице отдельную обозначенную прокрутку; не прокручивать всю страницу по горизонтали |
|
||||
| UI-15 | P1 | Встречались Temp-пути и HTTP-ссылки исходного редактора | Классифицировать ссылки безопасно, не удалять юридический текст; не считать локальные/IP-ссылки проверенными |
|
||||
| UI-16 | P2 | Развёрнутый документ терял поиск, совпадение и toolbar | Сохранять редакцию, язык, позицию и навигацию; обеспечить очевидный возврат |
|
||||
| UI-17 | P2 | Часть KY-интерфейса оставалась на русском | Инвентаризировать строки интерфейса; не заменять переводом язык документа |
|
||||
| UI-18 | P1 | На `/review` отображались два footer, общий footer был выше сохранения | Один footer после основного содержимого и панели сохранения |
|
||||
| UI-19 | P1 | Шапка, формы, панели и footer лаборатории имели разные направляющие | Единая оболочка и совпадающие границы контента |
|
||||
| UI-20 | P1 | Высокая шапка лаборатории отодвигала работу на mobile | Компактный заголовок и помощь; поиск и рабочая область доступны сразу |
|
||||
| UI-21 | P1 | Заголовок кабинета был пустым | Видимый заголовок и доступное имя диалога |
|
||||
| UI-22 | P1 | Не было постоянного крестика; закрытие находилось внизу формы | Крестик 44×44, доступное имя, Escape и возврат фокуса; отмена редактирования остаётся |
|
||||
| UI-23 | P1 | Навигация, выход, профиль и смена пароля были смешаны | Разделить «Профиль» и «Безопасность», отделить выход от формы |
|
||||
| UI-24 | P1 | Окно пользователей открывалось поверх кабинета | Не оставлять конкурирующие модальные окна; сохранять контекст и корректный фокус |
|
||||
| UI-25 | P1 | Список пользователей был длинным модальным окном с закрытием ниже экрана | Отдельный экран списка и короткие диалоги для создания/сброса |
|
||||
| UI-26 | P2 | Создание, удаление, сброс и входные данные смешивались | Действия на строке, отдельное подтверждение удаления, не раскрывать лишние формы |
|
||||
| UI-27 | P2 | Бренд, навигация, шрифты и ссылки различались между экранами | Общие header, logo, button, link, dialog и footer |
|
||||
| UI-28 | P2 | Диалоги закрывались разными способами | Единая модель окна и доступного закрытия |
|
||||
| UI-29 | P2 | Обучение появлялось с задержкой и перехватывало ввод | Запуск по запросу или ненавязчиво до начала ввода; не отбирать фокус |
|
||||
| UI-30 | P2 | Шкала 0–3 переносилась, комментарии всегда занимали место | Полные подписи; допустима сетка 2×2; раскрываемый комментарий без потери текста |
|
||||
| UI-31 | P2 | Пустой экран лаборатории выглядел готовым к отправке оценки | Явное состояние снимка, прогресс и статус черновика; не менять смысл пустой оценки |
|
||||
| UI-32 | P2 | Footer live-сайта показывал «Концепция Түндүк» | Рабочий интерфейс показывает продукт и реальные версии; концепция подписывается только в макете/демо |
|
||||
| UI-33 | P1 | Документ растягивал лабораторию до y≈636935 px, сохранение уходило ниже | Ограничить высоту области чтения и прокручивать её отдельно; сохранение доступно независимо от длины текста |
|
||||
|
||||
Дополнительные целевые критерии исходного аудита:
|
||||
|
||||
- **Сетка и ресайз колонок:** desktop — три колонки с доступными разделителями;
|
||||
ориентиры 1440 px: `280 / 408 / 736` плюс два захвата по 8 px, 1280 px:
|
||||
`256 / 384 / 624` плюс захваты. Рекомендуемые пределы фильтров 256–360 px,
|
||||
выдачи от 360 px, документа от 560 px. При недостатке ширины сначала скрыть
|
||||
фильтры, затем показывать документ отдельно.
|
||||
- Разделители должны иметь `role=separator`, доступное имя, min/max/current
|
||||
значения, pointer drag и управление стрелками клавиатуры. Отдельные кнопки
|
||||
скрытия/показа фильтров и сброса ширины обязательны; двойной клик не заменяет
|
||||
доступный сброс. Сохранённая ширина ограничивается текущим viewport.
|
||||
- Остальные правила сетки: фильтры не становятся постоянно прокручиваемой
|
||||
узкой колонкой; полный каталог значений всегда достижим; API single-select не
|
||||
изображается множественными checkbox. Длинное название органа читается в
|
||||
панели выбора полностью. Поля дат имеют постоянные «От» / «До» и переходят
|
||||
в столбец, когда не помещаются. На desktop выдача и документ могут иметь
|
||||
отдельную вертикальную прокрутку с доступными заголовком/toolbar; на mobile
|
||||
основной поток вертикальный с отдельными панелями. Footer использует
|
||||
растущий main (`min-height: 100dvh`), не перекрывает контент и следует за ним;
|
||||
панель сохранения имеет собственное место.
|
||||
- **Панели и формы:** понятный заголовок и крестик 44×44; именованный modal
|
||||
dialog, фокус внутри, Tab/Shift+Tab без выхода в фон, Escape там, где закрытие
|
||||
допустимо, возврат фокуса инициатору; фон не интерактивен. Не наслаивать
|
||||
кабинет и пользователей. Профиль и безопасность — отдельные разделы; права
|
||||
по-прежнему проверяет сервер. В форме пароля постоянные подписи, требования,
|
||||
доступное показать/скрыть и подтверждение нового пароля. Пароли не
|
||||
предзаполнять и не сохранять в материалах аудита; ввод выполняет человек.
|
||||
- Основное действие одно; отмена вторична, выход отдельно; Enter отправляет
|
||||
только активную форму. Ошибка у поля и в доступном сообщении, допустимый ввод
|
||||
сохраняется, loading исключает повторную отправку. Несохранённое редактирование
|
||||
защищено от случайной потери, обычный просмотр закрывается без лишнего
|
||||
подтверждения. После успеха сообщается результат, при ошибке есть путь
|
||||
восстановления; состояние не кодируется одним цветом.
|
||||
- Правила применяются к кабинету, входу, обязательной смене пароля, источникам,
|
||||
созданию/сбросу/удалению пользователя, обучению и просмотру документа.
|
||||
- **Приёмка:** единая оболочка/header/footer; нет общей горизонтальной
|
||||
прокрутки; initial не сообщает об empty до поиска; действия документа
|
||||
активируются по состоянию; keyboard и touch работают; RU/KY подписи и язык
|
||||
документа независимы; таблицы прокручиваются отдельно; тестируются contrast
|
||||
WCAG AA, видимый focus, reduced motion, zoom/reflow и доступные имена.
|
||||
- Viewport: **1440×900, 1280×800, 1024×768, 390×844**, дополнительно 320 CSS px
|
||||
для reflow. Визуальное сравнение — детерминированное fixture; нулевой
|
||||
pixel-diff не является критерием. Авторизация и destructive-сценарии
|
||||
проверяются только в безопасном тестовом контуре.
|
||||
|
||||
Исторически выполненные браузерные сценарии и ограничения аудита 23.10.2026:
|
||||
|
||||
| Область | Выполнено в том аудите | Не проверено тогда |
|
||||
|---|---|---|
|
||||
| Начальная страница и поиск RU | Пустой запрос, фильтры, footer; поиск «налог на прибыль», открытие кодекса | Экспертная оценка юридической релевантности |
|
||||
| Фильтры, сортировка, страницы | Статус, chip, снятие фильтра, дата/релевантность, страница 2 и сброс на страницу 1 | Все комбинации справочников |
|
||||
| Пустая выдача и документ | Контрольный заведомо пустой запрос, реальные текст и редакции, язык, разворот | Искусственное создание серверного сбоя, реальная печать/PDF |
|
||||
| Поиск в документе и локализация | Совпадения, переход к следующему, переключение RU/KY и чтение подписей | Поиск после смены редакции; полная проверка всех переводов |
|
||||
| Источники и мобильные фильтры | Открытие/закрытие панели и раздела источников | Внешний сайт Минюста, полный touch на физическом устройстве |
|
||||
| Кабинет и пользователи | Профиль, поля смены пароля, Escape/возврат фокуса; просмотр списка | Ввод пароля, создание/удаление пользователя, сброс пароля |
|
||||
| `/review` | Пустое состояние, поиск, шкала, документ, обучение | Отправка оценки и подтверждение серверного сохранения |
|
||||
| Авторизация | Наличие локальной реализации | Live login, истечение сессии и обязательная смена пароля |
|
||||
|
||||
Этот перечень описывает только прежний браузерный сеанс. Не использовать его
|
||||
как подтверждение текущих production-сценариев.
|
||||
|
||||
#### Реализация, зафиксированная в текущем плане `main`
|
||||
|
||||
UI-план, находившийся на базовом commit этого PR, отмечает UI-01—UI-33
|
||||
реализованными в PR #54 и связывает группы изменений с
|
||||
регрессионными сценариями:
|
||||
|
||||
| Пункты | Изменение, зафиксированное в плане `main` | Regression evidence, указанное там |
|
||||
|---|---|---|
|
||||
| UI-01—02 | Разделены initial/loading/results/empty/error; действия документа зависят от выбранной редакции | Empty, API error и редакция без текста |
|
||||
| UI-03—08 | Компактные/сворачиваемые фильтры, счётчики, локализованные языковые select и адаптивная навигация | Reflow 320/390/640/1024/1280/1440; на 640 px проверены основные действия `/`, `/review`, `/users` |
|
||||
| UI-09—10 | Подписанный кабинет и адаптивный layout вместо бесконечного сжатия трёх колонок | Геометрия и overflow на 640/1024/1280/1440 |
|
||||
| UI-11—16 | Breadcrumb, toolbar, отдельный scroll таблиц, очистка небезопасных URL-атрибутов и разворот документа в панели | Синтетические private-IP/relative/HTTP ссылки; ошибки и гонки редакций A/B |
|
||||
| UI-17 | Перевод интерфейсных подписей RU/KY с независимым языком документа | Переключение языка; полный ручной лингвистический review остаётся открытым |
|
||||
| UI-18—20 | Один footer, общие направляющие и ограниченная по высоте лаборатория | Один footer и длинный синтетический документ с собственным scroll |
|
||||
| UI-21—24 | Имя и разделы кабинета, безопасный переход к пользователям без наложенных диалогов | ARIA name, Escape, focus trap/return и отдельные маршруты |
|
||||
| UI-25—28 | Отдельный экран пользователей, целевые окна для действий, общий способ закрытия | Только фиктивный `fixture-created`; реальные аккаунты/API не использовались |
|
||||
| UI-29 | Обучение запускается пользователем | Локальный regression scenario |
|
||||
| UI-30—31 | Оценка 2×2, раскрываемый комментарий, disable до результата и draft state | Фиктивное сохранение и сохранность оценок при ошибке/устаревшем ответе |
|
||||
| UI-32—33 | Версии в footer и отдельная прокрутка длинного текста лаборатории | Footer versions и длинный fixture, сохранение остаётся доступно |
|
||||
|
||||
Тот же план `main` фиксирует локальный browser run на **320×800, 390×844,
|
||||
640×450, 1024×900, 1280×800 и 1440×900 CSS px**; на 1280 px измерена
|
||||
сетка 256 / 384 / 640 px. Экран лаборатории с длинным текстом имел собственную
|
||||
панель прокрутки, а сохранение/footer оставались доступны. Встроенная
|
||||
проверка fixture не использовала production API или реальные учётные записи.
|
||||
Это точная передача прежнего отчёта; её не следует трактовать как результат
|
||||
нового прогона в этом PR. Полный screen-reader review, 200–400% zoom,
|
||||
физический touch и полный ручной лингвистический review в исходном плане
|
||||
оставлены непроверенными.
|
||||
|
||||
Отдельный follow-up в плане `main` описывает реализованные после PR #54
|
||||
корректировки и regression coverage:
|
||||
|
||||
- **Поиск:** «Открыт» сбрасывается при смене результата/редакции и ставится
|
||||
только после успешной загрузки. Оглавление распознаёт самостоятельные
|
||||
заголовки RU/KY из структурированных абзацев (`p > b > span`), задаёт
|
||||
уникальные якоря и вложенность, не принимает обычные упоминания за заголовки.
|
||||
Выбор языка поиска находится в фильтрах и не обрезается; декоративный SVG и
|
||||
пустое резервное место удалены; активная навигация использует палитру.
|
||||
Проверены ошибки и гонки загрузки, структура заголовков, splitter и
|
||||
доступность диапазона его ширины.
|
||||
- **Кабинет:** ссылки действий сохраняют `<a>` семантику, крестик остаётся
|
||||
подписанной SVG-кнопкой 44×44, значения профиля стоят в парах «поле—значение»,
|
||||
длинные значения переносятся, заголовок компактнее; администратору явно
|
||||
доступно «Управление пользователями». Проверены RU/KY, отсутствие ссылки у
|
||||
не-администратора, фокус/закрытие и ширина.
|
||||
- **Лаборатория:** имя показывается без дублирующей роли; ссылки навигации имеют
|
||||
вид кнопок; оболочка, форма, рабочая область, сохранение и footer выровнены.
|
||||
Пустой live status не резервирует высоту, результаты и документ образуют
|
||||
общую поверхность с внутренней прокруткой на desktop, заголовок использует
|
||||
доступную ширину, а mobile возвращается к естественному потоку. Проверены
|
||||
desktop/mobile, пустой status, сохранение после длинного документа и page
|
||||
overflow.
|
||||
|
||||
Исходный план сообщает о локальной проверке этих изменений 24 октября 2026
|
||||
года и повышении frontend до `0.3.0` из-за регулируемой ширины панелей. Текущий
|
||||
source footer и версия кода — `0.3.3`. Его отметка «ожидаются CI и review»
|
||||
относилась к конкретному pushed SHA и не задаёт статус этого или любого нового
|
||||
PR.
|
||||
|
||||
#### Общие критерии окон и форм
|
||||
|
||||
- Desktop-окно обычно 480–640 px и не шире viewport; на mobile — отступ 16 px
|
||||
или полноэкранный режим для длинной формы. Заголовок и закрытие остаются
|
||||
доступны при прокрутке.
|
||||
- Диалог именуется через заголовок, держит фокус внутри и не делает фон
|
||||
интерактивным. Escape закрывает обычные окна; исключения (например,
|
||||
обязательная смена пароля) должны объясняться, а не маскироваться.
|
||||
- Профиль и безопасность разделены. Удаление требует отдельного подтверждения;
|
||||
создание/сброс не раскрывают дополнительные формы без запроса.
|
||||
- Поля имеют постоянные подписи; пароль не предзаполняется и не записывается
|
||||
в отчёт. Ошибки объясняют восстановление, допустимый ввод сохраняется,
|
||||
повторная отправка во время loading исключена.
|
||||
- «Отмена» вторична, выход не смешивается с сохранением; несохранённое
|
||||
редактирование защищено от случайной потери. Обычный просмотр закрывается
|
||||
без лишнего подтверждения.
|
||||
|
||||
#### Последовательность при возобновлении UI-работ
|
||||
|
||||
1. Зафиксировать актуальные SHA и версии; читать маршрут, API, `DESIGN.md`,
|
||||
UI tests и CI. Не переносить состояние live-сайта без новой проверки.
|
||||
2. Исправить общую оболочку и разделение поиска, лаборатории и служебных
|
||||
экранов; затем initial/loading/empty/error и жизненный цикл документа.
|
||||
3. Исправить фильтры, адаптивные минимумы и RU/KY; после этого — регулируемые
|
||||
колонки и скрытие фильтров.
|
||||
4. Привести формы/диалоги к общей модели с сохранением серверной авторизации,
|
||||
прав, валидации, снимков оценок и черновиков.
|
||||
5. Проверить текст, таблицы, ссылки, редакции, печать, адаптивность,
|
||||
локализацию, доступность и состояния на реальном корпусе, где доступен.
|
||||
6. Сделать детерминированный fixture-проход и независимый review; обновить
|
||||
версию только при изменении кода, а changelog — после зелёных проверок и
|
||||
разрешения blocking findings.
|
||||
|
||||
Не переносить существующий Python frontend на React/Next.js ради визуальной
|
||||
корректировки. UI-работа не меняет ранжирование, корпус или права доступа.
|
||||
|
||||
| Приёмка | Условие |
|
||||
|---|---|
|
||||
| Оболочка | Один header/footer на маршруте, единые границы и порядок контента |
|
||||
| Фильтры | Нет общей горизонтальной прокрутки; подписи/действия/счётчики видны полностью |
|
||||
| Начальное состояние | До первого завершённого запроса empty-сообщение не показано |
|
||||
| Документ | До выбора неактивные действия не вводят в заблуждение |
|
||||
| Колонки | Pointer и клавиатура; min/max; скрытие/возврат; reset; адаптация сохранённой ширины к viewport |
|
||||
| Телефон | Основные действия без горизонтального скролла; понятный возврат из фильтров/документа |
|
||||
| Окна | Заголовок, крестик, Escape, trap/return focus; окна не конкурируют слоями |
|
||||
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение; текст не теряется, панель доступна при длинном документе |
|
||||
| Локализация | RU/KY labels и символы; язык UI не подменяет язык документа |
|
||||
| Геометрия | При 1280×800 границы основных зон и отступы/размеры controls отличаются от измеренного fixture не более чем на 2 CSS px; согласованные исключения описаны |
|
||||
| Типографика и таблицы | Noto действительно загружается; читаемый длинный текст; у широкой таблицы собственный скролл |
|
||||
| Доступность | Контраст AA, focus, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion и touch targets |
|
||||
| Авторизация | Неверный пароль, expired session и обязательная смена проверены только в безопасном тестовом контуре |
|
||||
| Снимки | 1440×900, 1280×800, 1024×768, 390×844 и 320 CSS px; стабильные fixture и объяснённые различия |
|
||||
|
||||
Допуск **до 2 CSS px** для границ крупных зон и отступов/размеров controls при
|
||||
1280×800 перенесён из исторического pixel-perfect плана по стабильной ссылке
|
||||
выше. Он применяется к одинаковым детерминированным fixture; расхождения
|
||||
растра, вызванные сглаживанием текста или содержимым, сами по себе не дают
|
||||
основания обрезать или уплотнять юридический текст.
|
||||
|
||||
#### Нормативный орнамент и фактическое состояние
|
||||
|
||||
`DESIGN.md` нормативно требует небольшой золотистый SVG-орнамент в свободной
|
||||
нижней части desktop-панели фильтров, не перекрывающий длинные списки и не
|
||||
резервирующий место за счёт управления. Follow-up из commit `e5a8326a` удалил
|
||||
предыдущий декоративный SVG и его пустой резерв; в `backend/search/review.html`
|
||||
на базовом `main` целевого орнамента нет. Это не выполненное требование, а
|
||||
подтверждённое расхождение между `DESIGN.md` и реализацией. Дизайн остаётся
|
||||
нормативным до отдельного решения владельца: UI-задача должна либо вернуть
|
||||
орнамент в свободную область без влияния на управление, либо получить явное
|
||||
одобрение на изменение `DESIGN.md` и подтвердить его визуальной приёмкой.
|
||||
|
||||
### 2.3 Инфраструктура и переход к production
|
||||
|
||||
Инфраструктурные источники содержали повторяющиеся этапы deployment и
|
||||
устаревшие снимки VPS. В этом плане оставлены только gates; актуальные
|
||||
подтверждённые данные и процедуры находятся в операционных документах.
|
||||
|
||||
1. **Аудит:** сверить commit `main`, CI, Compose, зависимости, порты, volumes,
|
||||
healthchecks, данные и перечень env-переменных без вывода секретов. Для VPS
|
||||
пользоваться только read-only инвентаризацией, разрешённой
|
||||
[правилами доступа](../operations/ACCESS.md). Не переносить IP, версии,
|
||||
DNS-статус или ресурсы из старых планов как текущие факты.
|
||||
2. **Данные и конфигурация:** установить источник и допустимость корпуса,
|
||||
формат, состав, checksum/manifest, размер и воспроизводимую цепочку импорта.
|
||||
Обязательный gate любой передачи/публикации корпуса — процедура
|
||||
[CORPUS_HANDOFF.md](../operations/CORPUS_HANDOFF.md): source → staging →
|
||||
inventory/manifest и checksum comparison → атомарная публикация
|
||||
проверенного staging-корпуса в `DATA_ROOT/minjust-normalized` → отдельная
|
||||
проверка и импорт в индекс → OpenSearch alias switch. Runbook требует
|
||||
непустые source и staging, явное решение по исключению code 6 и fail-closed
|
||||
при неполных/несовпадающих данных. Атомарная файловая публикация и
|
||||
возобновление/отказ после прерывания — обязательный, но ещё не реализованный
|
||||
gate; до описания и проверки all-or-nothing механизма не заменять им
|
||||
действующий корпус. Не смешивать его с атомарным переключением OpenSearch
|
||||
alias. Следовать runbook без копирования его команд сюда; до прохождения
|
||||
gates корпус в production не передавать и не индексировать. Не подменять
|
||||
корпус пустым каталогом. Production env/секреты создаются вне Git; в
|
||||
репозитории остаются только имена переменных и шаблоны без значений.
|
||||
3. **Build/CI:** PR-проверки не получают production secrets и Docker socket.
|
||||
Публикация образов должна фиксировать digest и provenance; release workflow
|
||||
не равен deployment. Production CD не включать до прохождения owner gates,
|
||||
безопасной сборки, backup и отдельного restore.
|
||||
4. **Контролируемое изменение существующего production:** последняя датированная
|
||||
проверка `https://akyldash.kg/review` вернула HTTP 200 25 октября 2026 года
|
||||
([CURRENT_STATE](../operations/CURRENT_STATE.md)); установленный на хосте
|
||||
SHA остался неизвестен. План относится к безопасному
|
||||
обновлению существующего сервиса, а не к первому развёртыванию. До изменения
|
||||
проверять реальные данные и конфигурацию, доступное место, опубликованные
|
||||
порты, права, совместимость схем и backup readiness. Выполнять через
|
||||
существующую ограниченную процедуру; не добавлять общие sudo/Docker-
|
||||
привилегии. Базы данных и внутренние сервисы не публиковать в интернет.
|
||||
DNS, reverse proxy и TLS
|
||||
проверять до выпуска; для Cloudflare применять Full (strict). SSH/UFW и
|
||||
сетевые ограничения меняются только после owner approval.
|
||||
5. **Проверка и recovery:** проверять health/API, RU/KY canaries, доступность
|
||||
HTTPS, сохранность данных и rollback в пределах его документированных
|
||||
ограничений. Data restore — отдельная процедура в staging, не автоматический
|
||||
побочный эффект отката образа.
|
||||
6. **Автоматизация:** только после одобренного отдельного bootstrap и
|
||||
end-to-end проверки; serial deployment, digest verification, backup ACK,
|
||||
health gates и отказ без destructive cleanup. Не включать триггер только
|
||||
потому, что workflow завершился зелёным.
|
||||
|
||||
**Backup/maintenance gate:** настройки CI, проверка конфигурации и подготовка
|
||||
release provenance должны быть отделены от production cutover. Production CD
|
||||
в `CURRENT_STATE.md` остаётся неактивным. Перед каждым фактическим production
|
||||
изменением, включая миграции, создать и проверить внешний pre-change backup.
|
||||
Полный cold backup требует остановки Compose и может выполняться только в
|
||||
заранее согласованное с владельцем окно.
|
||||
Ни полный архив, ни его временная копия никогда не записываются на VPS. По
|
||||
текущей утверждённой схеме архив передаётся напрямую на фиксированный
|
||||
`D:\Dev\akyldash\.backups`; фактическая готовность D:, SSH и отдельное
|
||||
восстановление всё ещё не подтверждены. На VPS остаётся только secret-free
|
||||
идентификатор/manifest согласно `BACKUP.md`. Политика — одна retained,
|
||||
проверенная внешняя копия. При замене прежняя копия сохраняется до тех пор,
|
||||
пока новый candidate не опубликован и не проверен; на время этой проверки две
|
||||
копии могут сосуществовать. После подтверждения новой копии retention может
|
||||
изменить только владелец явным решением; автоматическая очистка запрещена.
|
||||
При неудачной публикации или проверке прежняя подтверждённая копия остаётся.
|
||||
Локальные VPS cold-backup команды из старых документов superseded и
|
||||
использоваться не должны.
|
||||
|
||||
#### Локальные generated reports
|
||||
|
||||
`/reports/` и `/tmp/` игнорируются по умолчанию, чтобы generated bulk не
|
||||
попадал в обычный `git status`. Уже отслеживаемые historical evidence остаются
|
||||
отслеживаемыми. Если конкретный отчёт нужен в Git, сначала проверить его
|
||||
содержимое, удалить секреты, персональные данные, production corpus и
|
||||
временные файлы, затем добавить только предназначенный к публикации файл
|
||||
явной командой `git add -f reports/<path>`. Не добавлять каталоги целиком.
|
||||
|
||||
Нынешний статус не переопределяется этим roadmap: на сверенной документации
|
||||
production CD остаётся неактивным, а backup/restore и внешний bootstrap имеют
|
||||
непроверенные owner prerequisites. Все точные блокировки сверять с
|
||||
`CURRENT_STATE.md` и `DEPLOYMENT.md` перед планированием действий.
|
||||
|
||||
## 3. Визуальные материалы и исходная база
|
||||
|
||||
### Макеты Figma
|
||||
|
||||
Целевые редактируемые экраны — гипотезы компоновки, а не работающий интерфейс,
|
||||
источник юридических данных или доказательство доступности.
|
||||
|
||||
| Экран | Макет |
|
||||
|---|---|
|
||||
| 01. Поиск с документом, 1440×900 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
|
||||
| 02. Фильтры скрыты | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
|
||||
| 03. Начальный поиск | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
|
||||
| 04. Лаборатория и сохранение | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
|
||||
| 05. Кабинет: профиль и закрытие | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
|
||||
| 06. Кабинет: безопасность | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
|
||||
| 07. Источники | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
|
||||
| 08. Пользователи: отдельный экран | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
|
||||
| 09–11. Телефон: выдача, чтение и фильтры (общий Figma-узел) | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
|
||||
| 12. Эталон 1280×800 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
|
||||
| 13. Планшет: две панели | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
|
||||
| 14. Кабинет на телефоне | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
|
||||
| 15. Вход, создание и подтверждение удаления | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
|
||||
|
||||
Локальные target exports: [поиск](../design/tunduk-search-target.png),
|
||||
[лаборатория](../design/tunduk-review-target.png),
|
||||
[кабинет](../design/tunduk-account-target.png). Это изображения цели; они не
|
||||
заменяют браузерную проверку.
|
||||
|
||||
В Figma экраны собраны из редактируемого текста, Auto Layout, SVG, локальных
|
||||
переменных цвета/отступов и общих экземпляров кнопок; это не один растр.
|
||||
Состояния 09–11 ведут на один общий мобильный узел, как в исходном плане.
|
||||
Прокрутка длинной выдачи/текста показывает продолжение содержимого.
|
||||
Splitter, закрытие и формы описывают целевое поведение, но не являются
|
||||
работающим web-кодом.
|
||||
|
||||
### Исторические свидетельства
|
||||
|
||||
Аудит UI выполнен **23 октября 2026 года** в авторизованной сессии на
|
||||
`https://akyldash.kg/`, где footer показывал Frontend `0.2.3`, Backend
|
||||
`0.13.0`. Сверка SHA сервиса с checkout не выполнялась. Проверены
|
||||
1440×900, 1280×800, 1024×768, 390×844 CSS px и дополнительный browser viewport
|
||||
1064×1244. Пароли не вводились; создание/удаление пользователей и отправка
|
||||
оценки не выполнялись; полный физический touch, screen-reader и принтерный
|
||||
прогоны не выполнялись. Существующие локальные browser evidence и screenshots
|
||||
собраны в [отчёте visual-review](visual-review/README.md). Ветка `main` не
|
||||
содержит отдельный архив live screenshots этого аудита. Любое использование
|
||||
результатов требует повторной проверки актуальной версии и окружения.
|
||||
|
||||
## 4. Постоянные правила дизайна и полномочия
|
||||
|
||||
[DESIGN.md](../../DESIGN.md) остаётся нормативной дизайн-системой: цветовые
|
||||
токены, типографика, бренд, композиционные принципы и доступность не
|
||||
дублируются здесь. Этот roadmap задаёт работы, исторические критерии и gates;
|
||||
он не утверждает новый дизайн и не разрешает production deployment.
|
||||
|
||||
Все пункты плана отмечать выполненными только при наличии актуального
|
||||
свидетельства. Изменения приложения проходят обычные тесты, версионирование,
|
||||
CI и независимое review. Инфраструктурные изменения отражаются в
|
||||
`CURRENT_STATE.md` только по фактическим проверкам. Никакие действия с
|
||||
production, секретами, пользователями или правовым корпусом не следуют из
|
||||
наличия этого документа. Root-only bootstrap и предоставление секретов
|
||||
выполняет/утверждает владелец в защищённой среде; DEV готовит проверяемый код
|
||||
и точный план, reviewer независимо проверяет безопасность и failure paths.
|
||||
@@ -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. Это версии
|
||||
исследованного кода; они не подтверждают развёртывание этих версий.
|
||||
@@ -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
|
||||
@@ -0,0 +1,318 @@
|
||||
# Диагностика релевантности поиска — 3 ноября 2026 года
|
||||
|
||||
## Решения и границы
|
||||
|
||||
Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
|
||||
терминологии, условный multi-index scoring и контракт для закрытия пробела
|
||||
корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
|
||||
прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
|
||||
экспертную оценку.
|
||||
|
||||
Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
|
||||
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
|
||||
относится к предшествующему составу ranking semantics; новые подписанные
|
||||
снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
|
||||
Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит
|
||||
этого отчёта и changelog меняет только документацию.
|
||||
|
||||
## Исходный benchmark
|
||||
|
||||
Зафиксированный набор содержит 7 500 русских формулировок: 15 тематических
|
||||
групп, по 500 запросов в каждой. Это 750 тем с десятью вариантами формулировки,
|
||||
а не 7 500 независимых правовых ситуаций. Исходный индекс
|
||||
`akyldash-fragments-v2` содержал около 42,6 млн фрагментов и занимал 11,2 ГБ.
|
||||
|
||||
| Показатель Акылдаш, исходный прогон | Результат |
|
||||
|---|---:|
|
||||
| Собранные выдачи | 7 500 / 7 500 |
|
||||
| Пустые выдачи | 6 |
|
||||
| Медиана / P95 ответа | 21,67 / 77,36 мс |
|
||||
| Совпадение основ темы в видимых названии/фрагменте, top 1 | 40,57% |
|
||||
| То же, top 3 / top 10 | 47,05% / 51,40% |
|
||||
|
||||
Сравнение с КонсультантПлюс в исходном отчёте — только диагностическое:
|
||||
юрисдикции и корпуса различаются. Совпадение основ темы не измеряет Recall,
|
||||
MRR, полноту ответа или юридическую правильность; российская выдача не является
|
||||
эталоном права Кыргызстана. Для вывода о качестве нужны экспертные релевантные
|
||||
коды, контрольный набор и отдельные метрики.
|
||||
|
||||
## Подтверждённые классы ошибок
|
||||
|
||||
14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
|
||||
предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
|
||||
нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
|
||||
триажа, а не заключение юриста или независимая экспертная проверка. Она не
|
||||
подтверждает полноту правового ответа. Неоценённые результаты не считались
|
||||
ошибками.
|
||||
|
||||
| Тема | Наблюдавшийся неверный приоритет | Вывод |
|
||||
|---|---:|---|
|
||||
| Расторжение договора аренды | 135066 | Решение по одной сделке с землёй не даёт общих оснований расторжения |
|
||||
| Международный коммерческий арбитраж | 26411 | Продажа земельных участков не регулирует арбитраж и отказ в признании решения |
|
||||
| Испытательный срок работника | 94603 | Земельный участок под завод/испытательный полигон совпадает словами, но не предметом |
|
||||
| Место жительства ребёнка | 50760 | Финансирование приюта не устанавливает порядок спора родителей |
|
||||
| Перевод студента в другой вуз | 14165 | Разовое финансирование студентов не устанавливает процедуру перевода |
|
||||
| Таможенные пошлины | 41854 | Индивидуальная отсрочка одному предприятию не задаёт общие правила |
|
||||
| Общий порядок предоставления земли | 54735, 54571 | Решения по отдельным участкам не являются общей процедурой |
|
||||
| Защита критической инфраструктуры | 59082 | Предоставление земли под завод не устанавливает защитные требования |
|
||||
| Купля-продажа / расторжение договора | 60053 | Расторжение одной сделки с госакциями не является общей нормой |
|
||||
| Подача искового заявления | 18516, 7115 | Отдельный спор и узкая льгота по пошлине не дают общего порядка подачи иска |
|
||||
| Принятие нормативных актов | 77190 | Первое чтение одного законопроекта не устанавливает общий процесс |
|
||||
| Подключение к водоснабжению | 135061 | Целевая помощь отдельным домохозяйствам не задаёт технические условия |
|
||||
|
||||
Проверенные варианты глобального повышения веса кодексов отвергнуты: вес 2 не
|
||||
исправил аренду и подачу иска, а вес 4 поднял Налоговый кодекс выше профильных
|
||||
источников по воинской обязанности и подаче иска. Общие веса или жёсткие
|
||||
приоритеты кодексов не добавлялись.
|
||||
|
||||
## Узкая терминологическая поправка
|
||||
|
||||
В исследованной редакции ГК КР II (код 5, редакция 460) глава 27 и статья 542
|
||||
связывают договор аренды с имущественным наймом; статьи 558–559 используют
|
||||
термин «имущественный наем». В полном тексте этой редакции буквальная фраза
|
||||
«расторжение договора аренды» не встречалась. Исходный запрос возвращал код
|
||||
135066 на первом месте, а формулировка «расторжение договора имущественного
|
||||
найма» — код 5.
|
||||
|
||||
Backend теперь сохраняет исходную строку и добавляет только для точного
|
||||
сочетания «договор [арендная форма]» вариант «договор [та же форма]
|
||||
имущественного найма». В частности, `договора аренды` превращается в
|
||||
`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
|
||||
или земельного участка, поднайма, найма работника или лизинга. Точный запрос
|
||||
остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
|
||||
не исключается из поиска.
|
||||
|
||||
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
|
||||
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
|
||||
оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
|
||||
поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
|
||||
выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
|
||||
проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
|
||||
фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
|
||||
подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
|
||||
выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
|
||||
сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый
|
||||
conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде
|
||||
жилья/земли, поднайме, трудовых отношениях, международном праве и KY
|
||||
формулировках выполнялся отдельно; rank-метрики не являются юридической
|
||||
оценкой.
|
||||
|
||||
## Условный scoring для явного multi-index scope
|
||||
|
||||
При поиске по явно настроенному comma-separated scope (`base,supplement`) API
|
||||
передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
|
||||
одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
|
||||
одиночные wildcard и alias не классифицируются по результату их expansion.
|
||||
DFS включается только при запятой в настроенной строке scope.
|
||||
Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
|
||||
Параметр применяется только к `/search`; фильтры и остальные API вызовы
|
||||
используют прежний режим.
|
||||
|
||||
OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
|
||||
frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
|
||||
точнее, но может быть медленнее. `search_type` поддерживается именно как
|
||||
параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
|
||||
Поэтому ограниченный режим использует глобальные statistics для явной
|
||||
multi-index конфигурации и не меняет behavior одиночного поиска.
|
||||
|
||||
На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
|
||||
одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
|
||||
Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
|
||||
изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
|
||||
индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о
|
||||
педагогических/научных работниках и одном запросе о болезнях животных top-1 не
|
||||
изменился. Эти шесть случаев требуют предметной оценки и не объявляются
|
||||
автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает
|
||||
аттестацию педагогических/научных работников с медицинской деятельностью, а
|
||||
случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не
|
||||
выставлялись; специальные веса или document IDs не добавлялись. Сравнение
|
||||
ограничено выборкой и не является разметкой юридической правильности.
|
||||
Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
|
||||
скорости: default всегда исполнялся первым, возможен cache/order bias.
|
||||
|
||||
Frozen experiment metadata is stored outside the repository in
|
||||
`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
|
||||
response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
|
||||
`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
|
||||
Выборка содержала base index (42 624 501 fragments) и read-only supplement
|
||||
(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
|
||||
отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
|
||||
персональные данные или выдачи документов.
|
||||
|
||||
Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
|
||||
прежнее тело запроса и неизменность single alias/index и filters. Read-only
|
||||
integration gate для root-owned runtime:
|
||||
|
||||
```bash
|
||||
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
|
||||
AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
|
||||
PYTHONPATH=backend python3 -m unittest \
|
||||
backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
|
||||
```
|
||||
|
||||
Root выполнил этот read-only gate на 8081 для `1c7ddf2`: фактический multi-index
|
||||
URI использовал DFS и подписанный token сохранял полный scope. Gate выполняет
|
||||
только поиск и не создаёт/удаляет индексы. Полный benchmark на том же code SHA
|
||||
и frozen baseline приведён ниже; 300-query experiment и более ранние прогоны
|
||||
остаются отдельными сравнительными срезами.
|
||||
|
||||
Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
|
||||
поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
|
||||
не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
|
||||
остаются помечены `search-8`. Для supplement действует отдельный rollback
|
||||
контракт ниже: отключение его подключения скрывает только добавленные редакции,
|
||||
не удаляя подготовленные файлы и базовый индекс.
|
||||
|
||||
## Итоговый pinned прогон search-8
|
||||
|
||||
Полный isolated прогон для executable SHA `1c7ddf2fcbd7c55920470e7ac52339fdab0366db`
|
||||
собрал 7 500 уникальных запросов с версией `search-8`. SHA-256 выдачи:
|
||||
`fc02e22e5b3d612813ffc0bdaf9892bb16ee4c29af3e1b77f79e58a0a45c8ec1`. Итог
|
||||
сопоставлен с frozen baseline и двумя явно указанными промежуточными режимами:
|
||||
|
||||
| Сравнение | Изменён порядок | Изменён top-1 |
|
||||
|---|---:|---:|
|
||||
| Исходный baseline `search-6` | 537 | 103 |
|
||||
| Code-only `726b1ee`, `search-7` | 521 | 93 |
|
||||
| Code + три акта, `e653f3b`, без DFS | 425 | 63 |
|
||||
|
||||
Это число изменений порядка, а не улучшений. Все шесть пустых выдач сохранились
|
||||
во всех четырёх прогонах. Среди ранее проверенных Codex (ИИ) пар с нулевой
|
||||
оценкой top-1 стало 26 вместо 28 в baseline; остальные 26 не объявлены
|
||||
исправленными, и новые выдачи автоматически не оценивались.
|
||||
|
||||
| Добавленный акт | Наблюдался в top-1 / top-3 / top-10 |
|
||||
|---|---:|
|
||||
| Трудовой кодекс КР `230021083` | 24 / 58 / 105 |
|
||||
| Земельный кодекс КР `230029465` | 4 / 11 / 26 |
|
||||
| Закон об охране здоровья `230000918` | 34 / 51 / 92 |
|
||||
|
||||
Все шесть точных названий новых актов на RU/KY дали ожидаемый текущий акт первым.
|
||||
Все 284 запроса, общие с целевым DFS экспериментом, совпали с ним по полному
|
||||
порядку кодов и редакций. Проверки dataset guard подтвердили неизменность
|
||||
исходного индекса, manifest, mappings и UUID/count, а также 115 подключённых
|
||||
файлов. 7 503 записи локальной лаборатории совпали с backup; обе SQLite
|
||||
integrity checks прошли. Записи оценок, комментарии и пользователи не менялись.
|
||||
|
||||
Шесть межтематических контекстов нельзя автоматически считать нерелевантными:
|
||||
медицинский закон остался первым в пяти запросах о педагогических/научных
|
||||
работниках и в одном запросе о болезнях животных. Для педагогических случаев
|
||||
проверенная статья 150(8) связывает аттестацию этих работников с медицинской
|
||||
деятельностью; новые оценки пар не выставлялись. Вопрос о животных также
|
||||
остался без экспертной оценки. ИИ-аннотации не доказывают полноту правового
|
||||
ответа или юридическую правильность ранжирования.
|
||||
|
||||
Режим и scope дополнительно проверены на runtime `8081`: для multi-index `/search`
|
||||
уходит DFS, для одиночного индекса и filters — прежний default; тело DSL
|
||||
одинаково. Alias проверен transport mock без создания alias. Runtime mode/API
|
||||
smoke включал HMAC/TTL, signed scope, сохранение пустого результата и
|
||||
отрицательные rank/code сценарии. Browser UI fixture прошёл на `726b1ee`; UI
|
||||
побайтно не менялся в следующих SHA, но отдельный browser прогон на `1c7ddf2`
|
||||
не выполнялся. Медиана backend `23.685 мс` описательна и не является SLO или
|
||||
доказательством ускорения.
|
||||
|
||||
Эксперимент подключал только три акта в отдельном supplement и не доказывает
|
||||
полноту всего корпуса, актуальность всех редакций, наличие вложений или
|
||||
production-готовность. Отключение overlay/supplement скроет эти новые редакции
|
||||
из локального поиска/API, но сохранит подготовленные файлы, review-записи и
|
||||
исходный индекс. Production не менялся. Подробный технический отчёт и raw
|
||||
benchmark остаются во внешнем рабочем наборе и в PR не включены.
|
||||
|
||||
## Пустые выдачи в лаборатории
|
||||
|
||||
Исходный контракт блокировал сохранение шести подписанных пустых снимков:
|
||||
API отвергал `results: []`, а интерфейс читал поля из пустого placeholder.
|
||||
Теперь signed snapshot может сохранить пустую оценку с обязательным общим
|
||||
комментарием. В записи `top_result_code` остаётся `null`, список результатов
|
||||
пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно
|
||||
один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая
|
||||
аутентификация не меняются.
|
||||
|
||||
Подписанный снимок теперь сохраняет scope запроса при явно настроенном
|
||||
multi-index выражении (`A,B`): provenance не сужается до индекса, который
|
||||
случайно вернул единственный результат, и сохраняется при пустой выдаче. Для
|
||||
одного настроенного индекса или alias остаётся прежнее поведение: единственный
|
||||
concrete `_index` записывается как provenance; если OpenSearch не сообщил его,
|
||||
сохраняется настроенный scope. Новые отзывы получают эту метку при сохранении.
|
||||
SQLite-схема, исторические записи и формат подписанных токенов не меняются.
|
||||
Откат к старому коду не требует миграции и не переписывает существующие
|
||||
отзывы; пока работает откатанная версия, новые multi-index отзывы снова могут
|
||||
получить суженную метку, поэтому исправление provenance следует вернуть вместе
|
||||
с основным выпуском.
|
||||
|
||||
Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
|
||||
сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
|
||||
оценок результатов остались пустыми намеренно. Integrity check прошёл,
|
||||
порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
|
||||
переписывались. В последующем изолированном trial шесть реальных подписанных
|
||||
пустых отзывов с общими комментариями были сохранены как записи 7498–7503;
|
||||
итоговые 7 503 строки лаборатории совпали с backup, integrity check прошёл.
|
||||
Пустые query-level отзывы не являются оценками отдельных документов.
|
||||
|
||||
## Пробел актуального корпуса и безопасный контракт закрытия
|
||||
|
||||
Отдельно от ранжирования, в исходном base index не было трёх
|
||||
подтверждённых действующих актов (все три canonical ID имели `_count = 0`):
|
||||
|
||||
| Акт | Canonical ID | Последняя проверенная редакция |
|
||||
|---|---:|---|
|
||||
| Трудовой кодекс КР от 23.01.2025 № 23 | 230021083 | 57548 от 28.07.2026 |
|
||||
| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
|
||||
| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
|
||||
|
||||
Это разрыв между нормализованными документами и индексом, а не полное
|
||||
отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
|
||||
canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
|
||||
обработанных документов. Однако трудовой акт там отстаёт от официальной
|
||||
редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
|
||||
земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
|
||||
последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical
|
||||
ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
|
||||
или поднять как замену актуальным нормам.
|
||||
|
||||
Для локальной изолированной проверки подготовлены точные официальные RU/KY
|
||||
редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно
|
||||
подключены отдельным supplement-index и доступны API через read-only overlay
|
||||
catalogues. Это не изменение base index или production и не доказательство
|
||||
полноты корпуса. Вложения и полная история новых canonical ID отдельно не
|
||||
подтверждены.
|
||||
|
||||
Закрытие пробела требует отдельного контролируемого обновления данных:
|
||||
|
||||
1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить
|
||||
исходный ответ, хеши и provenance в отдельном staging-каталоге.
|
||||
2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
|
||||
вложения; не импортировать метаданные с ненужными персональными полями.
|
||||
3. Нормализовать документы отдельно и проверить exact-count/hash. Для
|
||||
ограниченного дополнения собрать отдельный версионный индекс только из этих
|
||||
актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
|
||||
4. Изолированный read-only trial уже проверил отдельный supplement-index,
|
||||
подключённые каталоги `data_root`, RU/KY выдачу, current-edition routes,
|
||||
collapse и контрольные запросы. Перед новым подключением нужно повторно
|
||||
сверить official source и dataset guards; trial не записывал новые документы
|
||||
в base index и не менял alias.
|
||||
5. Публикация требует отдельного владельческого разрешения и проверенного
|
||||
rollback. При отключении локального supplement-index поиск и просмотр новых
|
||||
редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы
|
||||
сохраняются, базовый индекс не удаляется. PR
|
||||
фиксирует код и результаты локального испытания; production, base index и
|
||||
исходные данные не менялись.
|
||||
|
||||
## Что остаётся проверить
|
||||
|
||||
- Финальный code/data эксперимент и full 7 500 run относятся к executable SHA
|
||||
`1c7ddf2`; текущий follow-up commit меняет только этот report и changelog.
|
||||
CI и полное независимое ревью должны повторно пройти на новом PR head.
|
||||
- Browser fixture пустой выдачи прошёл на `726b1ee`; UI побайтно неизменён в
|
||||
следующих commit. Для `1c7ddf2` выполнены реальные API/runtime smokes, но новый
|
||||
полный browser run не заявляется.
|
||||
- Три supplement-акта не подтверждают полноту всего корпуса, полную историю,
|
||||
вложения, лицензирование или production readiness. Для новой публикации нужен
|
||||
свежий source/edition check и отдельное разрешение владельца.
|
||||
- Измерения top-k, AI-разметка и технические smoke checks не заменяют независимую
|
||||
экспертную оценку юридической релевантности.
|
||||
|
||||
Исходные измерения и хеши хранятся вне репозитория в рабочем наборе
|
||||
`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы
|
||||
MinJust, диагностические логи, внешние пользовательские данные или выгрузка
|
||||
лабораторных комментариев.
|
||||
@@ -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. Переключатель языка интерфейса
|
||||
и поле языка поиска остаются разными состояниями; в макете язык показан как
|
||||
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
|
||||
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
|
||||
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
|
||||
показывают композицию, но не подтверждают юридическое содержимое или доступность
|
||||
во всех браузерах.
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -17,6 +17,7 @@ import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import BinaryIO
|
||||
|
||||
COMPONENTS = {
|
||||
"data",
|
||||
@@ -68,7 +69,7 @@ def read_manifest(archive: tarfile.TarFile) -> dict:
|
||||
stream = archive.extractfile("manifest.json")
|
||||
if stream is None:
|
||||
raise ValueError("backup manifest is unreadable")
|
||||
manifest = json.load(stream)
|
||||
manifest = json.loads(stream.read().decode("utf-8"), object_pairs_hook=unique_json_object)
|
||||
components = manifest.get("components") if isinstance(manifest, dict) else None
|
||||
if (
|
||||
not isinstance(manifest, dict)
|
||||
@@ -90,6 +91,15 @@ def read_manifest(archive: tarfile.TarFile) -> dict:
|
||||
return manifest
|
||||
|
||||
|
||||
def unique_json_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate backup manifest field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def verify_archive(path: Path) -> dict:
|
||||
checksum_path = Path(f"{path}.sha256")
|
||||
expected = checksum_path.read_text(encoding="ascii").split()[0]
|
||||
@@ -99,6 +109,31 @@ def verify_archive(path: Path) -> dict:
|
||||
return read_manifest(archive)
|
||||
|
||||
|
||||
def verify_external_archive(path: Path, *, expected_sha256: str, expected_manifest_sha256: str) -> dict:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("external backup must be a regular file")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256) or digest(path) != expected_sha256:
|
||||
raise ValueError("external backup SHA-256 does not match the live session")
|
||||
with tarfile.open(path, "r:gz") as archive:
|
||||
manifest_member = next(member for member in checked_members(archive) if member.name == "manifest.json")
|
||||
stream = archive.extractfile(manifest_member)
|
||||
if stream is None:
|
||||
raise ValueError("external backup manifest is unreadable")
|
||||
manifest_bytes = stream.read(1024 * 1024 + 1)
|
||||
manifest = read_manifest(archive)
|
||||
canonical_manifest = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(manifest_bytes) > 1024 * 1024 or hashlib.sha256(canonical_manifest).hexdigest() != expected_manifest_sha256:
|
||||
raise ValueError("external backup manifest does not match the live session")
|
||||
for member in archive.getmembers():
|
||||
if member.isfile():
|
||||
stream = archive.extractfile(member)
|
||||
if stream is None:
|
||||
raise ValueError(f"external backup member is unreadable: {member.name}")
|
||||
while stream.read(1024 * 1024):
|
||||
pass
|
||||
return manifest
|
||||
|
||||
|
||||
def parse_sources(values: list[str], required: set[str] = COMPONENTS) -> dict[str, Path]:
|
||||
sources: dict[str, Path] = {}
|
||||
for value in values:
|
||||
@@ -277,43 +312,14 @@ def create_backup(
|
||||
for name in ("production-env", "review-secret"):
|
||||
if sources[name] is None or not sources[name].is_file():
|
||||
raise ValueError(f"sensitive component must be a regular file: {name}")
|
||||
if sources["data"] is not None and sources["data"].is_dir():
|
||||
check_sqlite(sources["data"])
|
||||
|
||||
manifest = {
|
||||
"format": 1,
|
||||
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"git_revision": revision,
|
||||
"images": images or {},
|
||||
"deployment_files": deployment_files or {},
|
||||
"components": sorted(sources),
|
||||
}
|
||||
manifest = backup_manifest(sources, revision, images, deployment_files)
|
||||
with tempfile.NamedTemporaryFile(dir=parent, prefix=".akyldash-backup-", delete=False) as temp:
|
||||
temporary = Path(temp.name)
|
||||
os.chmod(temporary, 0o600)
|
||||
checksum_temp = Path(f"{temporary}.sha256")
|
||||
try:
|
||||
with tarfile.open(temporary, "w:gz") as archive:
|
||||
encoded = json.dumps(manifest, sort_keys=True).encode("utf-8")
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(encoded)
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(encoded))
|
||||
for name, source in sorted(sources.items()):
|
||||
if source is None:
|
||||
info = tarfile.TarInfo(f"{name}/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o700
|
||||
archive.addfile(info)
|
||||
continue
|
||||
def protect_secrets(member: tarfile.TarInfo) -> tarfile.TarInfo:
|
||||
if name in {"production-env", "review-secret"}:
|
||||
member.mode = 0o600
|
||||
if member.name.endswith((".sqlite3", ".sqlite3-wal", ".sqlite3-shm")):
|
||||
member.mode = 0o600
|
||||
return member
|
||||
|
||||
archive.add(source, arcname=name, recursive=True, filter=protect_secrets)
|
||||
write_backup(archive, sources, manifest)
|
||||
with tarfile.open(temporary, "r:gz") as archive:
|
||||
read_manifest(archive)
|
||||
checksum_fd = os.open(checksum_temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
@@ -330,6 +336,65 @@ def create_backup(
|
||||
checksum_temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def backup_manifest(
|
||||
sources: dict[str, Path | None], revision: str, images: dict | None, deployment_files: dict[str, str] | None
|
||||
) -> dict:
|
||||
for name, source in sources.items():
|
||||
if source is not None and (source.is_symlink() or not (source.is_dir() or source.is_file())):
|
||||
raise ValueError(f"source must be a regular file or directory: {name}")
|
||||
for name in ("production-env", "review-secret"):
|
||||
if sources[name] is None or not sources[name].is_file():
|
||||
raise ValueError(f"sensitive component must be a regular file: {name}")
|
||||
if sources["data"] is not None and sources["data"].is_dir():
|
||||
check_sqlite(sources["data"])
|
||||
return {
|
||||
"format": 1,
|
||||
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"git_revision": revision,
|
||||
"images": images or {},
|
||||
"deployment_files": deployment_files or {},
|
||||
"components": sorted(sources),
|
||||
}
|
||||
|
||||
|
||||
def write_backup(archive: tarfile.TarFile, sources: dict[str, Path | None], manifest: dict) -> None:
|
||||
encoded = json.dumps(manifest, sort_keys=True).encode("utf-8")
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size = len(encoded)
|
||||
info.mode = 0o600
|
||||
archive.addfile(info, io.BytesIO(encoded))
|
||||
for name, source in sorted(sources.items()):
|
||||
if source is None:
|
||||
info = tarfile.TarInfo(f"{name}/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
info.mode = 0o700
|
||||
archive.addfile(info)
|
||||
continue
|
||||
|
||||
def protect_secrets(member: tarfile.TarInfo) -> tarfile.TarInfo:
|
||||
if name in {"production-env", "review-secret"}:
|
||||
member.mode = 0o600
|
||||
if member.name.endswith((".sqlite3", ".sqlite3-wal", ".sqlite3-shm")):
|
||||
member.mode = 0o600
|
||||
return member
|
||||
|
||||
archive.add(source, arcname=name, recursive=True, filter=protect_secrets)
|
||||
|
||||
|
||||
def create_backup_stream(
|
||||
output: BinaryIO, sources: dict[str, Path | None], revision: str,
|
||||
images: dict | None = None, deployment_files: dict[str, str] | None = None,
|
||||
manifest: dict | None = None,
|
||||
) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("external production backup must run as root")
|
||||
manifest = manifest or backup_manifest(sources, revision, images, deployment_files)
|
||||
with tarfile.open(fileobj=output, mode="w|gz") as archive:
|
||||
write_backup(archive, sources, manifest)
|
||||
output.flush()
|
||||
return manifest
|
||||
|
||||
|
||||
def restore_to_staging(backup: Path, target: Path) -> None:
|
||||
if not target.name.startswith("akyldash-restore-"):
|
||||
raise ValueError("restore target must be a new akyldash-restore-* staging directory")
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Bounded binary protocol for one live owner-controlled external backup session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import datetime as dt
|
||||
from typing import BinaryIO
|
||||
|
||||
MAGIC = b"AKYL"
|
||||
ACK_MAGIC = b"AKYA"
|
||||
CHALLENGE_MAGIC = b"AKYB"
|
||||
RESULT_MAGIC = b"AKYR"
|
||||
VERSION = 1
|
||||
CHUNK_BYTES = 1024 * 1024
|
||||
MAX_CONTROL_BYTES = 64 * 1024
|
||||
MAX_ARCHIVE_BYTES = 100 * 1024**3
|
||||
BACKUP_ID_RE = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\Z")
|
||||
SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
NONCE_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
|
||||
|
||||
def nonce() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def _json_frame(writer: BinaryIO, magic: bytes, value: dict) -> None:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if len(payload) > MAX_CONTROL_BYTES:
|
||||
raise ValueError("external backup control message exceeds its size limit")
|
||||
writer.write(magic + struct.pack("!I", len(payload)) + payload)
|
||||
writer.flush()
|
||||
|
||||
|
||||
def _read_exact(reader: BinaryIO, length: int) -> bytes:
|
||||
parts = bytearray()
|
||||
while len(parts) < length:
|
||||
block = reader.read(length - len(parts))
|
||||
if not block:
|
||||
raise ValueError("external backup session ended before a complete protocol frame")
|
||||
parts.extend(block)
|
||||
return bytes(parts)
|
||||
|
||||
|
||||
def _read_json_frame(reader: BinaryIO, magic: bytes) -> dict:
|
||||
if _read_exact(reader, len(magic)) != magic:
|
||||
raise ValueError("external backup protocol version or frame type is invalid")
|
||||
return _read_json_after_magic(reader)
|
||||
|
||||
|
||||
def _read_json_after_magic(reader: BinaryIO) -> dict:
|
||||
size = struct.unpack("!I", _read_exact(reader, 4))[0]
|
||||
if size > MAX_CONTROL_BYTES:
|
||||
raise ValueError("external backup control message exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(_read_exact(reader, size), object_pairs_hook=_unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("external backup control message is invalid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("external backup control message must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _unique_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate external backup field: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_request(writer: BinaryIO, request: dict) -> None:
|
||||
validate_request(request)
|
||||
_json_frame(writer, MAGIC, request)
|
||||
|
||||
|
||||
def read_request(reader: BinaryIO) -> dict:
|
||||
request = _read_json_frame(reader, MAGIC)
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def validate_request(request: object) -> dict:
|
||||
if (not isinstance(request, dict) or type(request.get("version")) is not int
|
||||
or request["version"] != VERSION):
|
||||
raise ValueError("external backup request has an unsupported version")
|
||||
if request.get("action") == "release" and set(request) == {
|
||||
"version", "action", "run_id", "commit", "descriptor_sha256"
|
||||
}:
|
||||
if (not isinstance(request["run_id"], str) or not request["run_id"].isdigit()
|
||||
or not isinstance(request["commit"], str) or not re.fullmatch(r"[0-9a-f]{40}", request["commit"])
|
||||
or not isinstance(request["descriptor_sha256"], str)
|
||||
or not SHA256_RE.fullmatch(request["descriptor_sha256"])):
|
||||
raise ValueError("external deployment request identity is invalid")
|
||||
return request
|
||||
if request.get("action") == "rollback" and set(request) == {
|
||||
"version", "action", "data_compatible"
|
||||
} and request["data_compatible"] is True:
|
||||
return request
|
||||
raise ValueError("external deployment request has unexpected fields or action")
|
||||
|
||||
|
||||
def read_header(reader: BinaryIO) -> dict:
|
||||
header = _read_json_frame(reader, MAGIC)
|
||||
return validate_header(header)
|
||||
|
||||
|
||||
def validate_header(header: object) -> dict:
|
||||
if not isinstance(header, dict):
|
||||
raise ValueError("external backup header is invalid")
|
||||
if set(header) != {"version", "nonce", "backup_id", "manifest", "manifest_sha256", "context"}:
|
||||
raise ValueError("external backup header has unexpected fields")
|
||||
if (type(header["version"]) is not int or header["version"] != VERSION
|
||||
or not isinstance(header["nonce"], str)
|
||||
or not NONCE_RE.fullmatch(header["nonce"]) or not isinstance(header["backup_id"], str)
|
||||
or not BACKUP_ID_RE.fullmatch(header["backup_id"]) or not isinstance(header["manifest"], dict)
|
||||
or not isinstance(header["manifest_sha256"], str)
|
||||
or not SHA256_RE.fullmatch(header["manifest_sha256"])):
|
||||
raise ValueError("external backup header is invalid")
|
||||
canonical = json.dumps(header["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != header["manifest_sha256"]:
|
||||
raise ValueError("external backup manifest identity does not match its header")
|
||||
if not isinstance(header["context"], dict):
|
||||
raise ValueError("external backup context is invalid")
|
||||
return header
|
||||
|
||||
|
||||
def read_response(reader: BinaryIO) -> tuple[str, dict]:
|
||||
magic = _read_exact(reader, 4)
|
||||
value = _read_json_after_magic(reader) if magic in {MAGIC, RESULT_MAGIC} else None
|
||||
if magic == MAGIC:
|
||||
return "header", validate_header(value)
|
||||
if magic == RESULT_MAGIC:
|
||||
result = value
|
||||
if (set(result) != {"version", "status", "message"} or type(result["version"]) is not int
|
||||
or result["version"] != VERSION
|
||||
or result["status"] not in {"success", "error"} or not isinstance(result["message"], str)):
|
||||
raise ValueError("external backup final result is invalid")
|
||||
return "result", result
|
||||
raise ValueError("external deployment response frame is invalid")
|
||||
|
||||
|
||||
def write_header(writer: BinaryIO, *, session_nonce: str, backup_id: str, manifest: dict,
|
||||
context: dict | None = None) -> str:
|
||||
if not NONCE_RE.fullmatch(session_nonce) or not BACKUP_ID_RE.fullmatch(backup_id):
|
||||
raise ValueError("external backup session identity is invalid")
|
||||
manifest_bytes = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest()
|
||||
_json_frame(writer, MAGIC, {
|
||||
"version": VERSION,
|
||||
"nonce": session_nonce,
|
||||
"backup_id": backup_id,
|
||||
"manifest": manifest,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"context": context or {},
|
||||
})
|
||||
return manifest_sha256
|
||||
|
||||
|
||||
class ArchiveFrameWriter:
|
||||
def __init__(self, writer: BinaryIO, *, maximum: int = MAX_ARCHIVE_BYTES):
|
||||
self.writer = writer
|
||||
self.maximum = maximum
|
||||
self.size = 0
|
||||
self.digest = hashlib.sha256()
|
||||
self.buffer = bytearray()
|
||||
self.closed = False
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
if self.closed:
|
||||
raise ValueError("external backup stream is already closed")
|
||||
self.size += len(data)
|
||||
if self.size > self.maximum:
|
||||
raise ValueError("external backup archive exceeds its size limit")
|
||||
self.digest.update(data)
|
||||
remaining = memoryview(data)
|
||||
while remaining:
|
||||
take = min(CHUNK_BYTES - len(self.buffer), len(remaining))
|
||||
self.buffer.extend(remaining[:take])
|
||||
remaining = remaining[take:]
|
||||
if len(self.buffer) == CHUNK_BYTES:
|
||||
self._flush_chunk(CHUNK_BYTES)
|
||||
return len(data)
|
||||
|
||||
def _flush_chunk(self, size: int) -> None:
|
||||
block = bytes(self.buffer[:size])
|
||||
del self.buffer[:size]
|
||||
self.writer.write(struct.pack("!I", len(block)))
|
||||
self.writer.write(block)
|
||||
|
||||
def finish(self) -> tuple[int, str]:
|
||||
if self.closed:
|
||||
raise ValueError("external backup stream is already closed")
|
||||
if self.buffer:
|
||||
self._flush_chunk(len(self.buffer))
|
||||
digest = self.digest.hexdigest()
|
||||
self.writer.write(struct.pack("!I", 0) + struct.pack("!Q", self.size) + bytes.fromhex(digest))
|
||||
self.writer.flush()
|
||||
self.closed = True
|
||||
return self.size, digest
|
||||
|
||||
|
||||
def receive_archive(reader: BinaryIO, output: BinaryIO, *, maximum: int = MAX_ARCHIVE_BYTES) -> tuple[int, str]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
while True:
|
||||
length = struct.unpack("!I", _read_exact(reader, 4))[0]
|
||||
if length == 0:
|
||||
expected_size = struct.unpack("!Q", _read_exact(reader, 8))[0]
|
||||
expected_digest = _read_exact(reader, 32).hex()
|
||||
break
|
||||
if length > CHUNK_BYTES or size + length > maximum:
|
||||
raise ValueError("external backup archive frame exceeds its size limit")
|
||||
chunk = _read_exact(reader, length)
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
size += length
|
||||
output.flush()
|
||||
actual_digest = digest.hexdigest()
|
||||
if size != expected_size or actual_digest != expected_digest:
|
||||
raise ValueError("external backup byte count or SHA-256 does not match its stream trailer")
|
||||
return size, actual_digest
|
||||
|
||||
|
||||
def write_ack(writer: BinaryIO, *, session_nonce: str, backup_id: str, size: int,
|
||||
archive_sha256: str, manifest_sha256: str, verified: bool) -> None:
|
||||
if (not NONCE_RE.fullmatch(session_nonce) or not BACKUP_ID_RE.fullmatch(backup_id)
|
||||
or type(size) is not int or not 0 < size <= MAX_ARCHIVE_BYTES
|
||||
or not SHA256_RE.fullmatch(archive_sha256) or not SHA256_RE.fullmatch(manifest_sha256)
|
||||
or verified is not True):
|
||||
raise ValueError("external backup acknowledgement is invalid")
|
||||
_json_frame(writer, ACK_MAGIC, {
|
||||
"version": VERSION,
|
||||
"nonce": session_nonce,
|
||||
"backup_id": backup_id,
|
||||
"size": size,
|
||||
"archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"verified": verified,
|
||||
})
|
||||
|
||||
|
||||
def write_challenge(writer: BinaryIO, identity: dict, *, session_nonce: str) -> None:
|
||||
if (not NONCE_RE.fullmatch(session_nonce) or not isinstance(identity, dict)
|
||||
or set(identity) != {"backup_id", "size", "archive_sha256", "manifest_sha256", "manifest"}):
|
||||
raise ValueError("external rollback challenge is invalid")
|
||||
if (not isinstance(identity["backup_id"], str) or not BACKUP_ID_RE.fullmatch(identity["backup_id"])
|
||||
or type(identity["size"]) is not int or not 0 < identity["size"] <= MAX_ARCHIVE_BYTES
|
||||
or not isinstance(identity["archive_sha256"], str) or not SHA256_RE.fullmatch(identity["archive_sha256"])
|
||||
or not isinstance(identity["manifest_sha256"], str) or not SHA256_RE.fullmatch(identity["manifest_sha256"])
|
||||
or not isinstance(identity["manifest"], dict)):
|
||||
raise ValueError("external rollback backup identity is invalid")
|
||||
canonical = json.dumps(identity["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != identity["manifest_sha256"]:
|
||||
raise ValueError("external rollback manifest identity is inconsistent")
|
||||
_json_frame(writer, CHALLENGE_MAGIC, {"version": VERSION, "nonce": session_nonce, **identity})
|
||||
|
||||
|
||||
def read_challenge(reader: BinaryIO) -> dict:
|
||||
value = _read_json_frame(reader, CHALLENGE_MAGIC)
|
||||
if (set(value) != {"version", "nonce", "backup_id", "size", "archive_sha256", "manifest_sha256", "manifest"}
|
||||
or type(value["version"]) is not int or value["version"] != VERSION
|
||||
or not isinstance(value["nonce"], str)
|
||||
or not NONCE_RE.fullmatch(value["nonce"]) or not isinstance(value["backup_id"], str)
|
||||
or not BACKUP_ID_RE.fullmatch(value["backup_id"]) or type(value["size"]) is not int
|
||||
or not 0 < value["size"] <= MAX_ARCHIVE_BYTES
|
||||
or not isinstance(value["archive_sha256"], str) or not SHA256_RE.fullmatch(value["archive_sha256"])
|
||||
or not isinstance(value["manifest_sha256"], str) or not SHA256_RE.fullmatch(value["manifest_sha256"])
|
||||
or not isinstance(value["manifest"], dict)):
|
||||
raise ValueError("external rollback challenge is invalid")
|
||||
canonical = json.dumps(value["manifest"], sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if hashlib.sha256(canonical).hexdigest() != value["manifest_sha256"]:
|
||||
raise ValueError("external rollback challenge manifest identity is inconsistent")
|
||||
return value
|
||||
|
||||
|
||||
def read_ack(reader: BinaryIO, *, session_nonce: str, backup_id: str, size: int,
|
||||
archive_sha256: str, manifest_sha256: str) -> dict:
|
||||
ack = _read_json_frame(reader, ACK_MAGIC)
|
||||
if (set(ack) != {"version", "nonce", "backup_id", "size", "archive_sha256", "manifest_sha256", "verified"}
|
||||
or type(ack.get("version")) is not int or type(ack.get("size")) is not int
|
||||
or ack.get("verified") is not True):
|
||||
raise ValueError("external backup acknowledgement is invalid")
|
||||
expected = {
|
||||
"version": VERSION, "nonce": session_nonce, "backup_id": backup_id,
|
||||
"size": size, "archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256, "verified": True,
|
||||
}
|
||||
if ack != expected:
|
||||
raise ValueError("external backup acknowledgement does not match this live session")
|
||||
return ack
|
||||
|
||||
|
||||
def write_result(writer: BinaryIO, *, status: str, message: str = "") -> None:
|
||||
if status not in {"success", "error"} or len(message.encode("utf-8")) > 4096:
|
||||
raise ValueError("external backup result is invalid")
|
||||
_json_frame(writer, RESULT_MAGIC, {"version": VERSION, "status": status, "message": message})
|
||||
|
||||
|
||||
def read_result(reader: BinaryIO) -> dict:
|
||||
result = _read_json_frame(reader, RESULT_MAGIC)
|
||||
if (set(result) != {"version", "status", "message"} or type(result["version"]) is not int
|
||||
or result["version"] != VERSION or result["status"] not in {"success", "error"}
|
||||
or not isinstance(result["message"], str)):
|
||||
raise ValueError("external backup final result is invalid")
|
||||
return result
|
||||
|
||||
|
||||
def send_backup_session(writer: BinaryIO, reader: BinaryIO, *, sources: dict, revision: str,
|
||||
images: dict, deployment_files: dict[str, str], context: dict) -> dict:
|
||||
from archive import backup_manifest, create_backup_stream
|
||||
|
||||
manifest = backup_manifest(sources, revision, images, deployment_files)
|
||||
session_nonce = nonce()
|
||||
backup_id = "akyldash-" + dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + session_nonce[:8]
|
||||
manifest_sha256 = write_header(
|
||||
writer, session_nonce=session_nonce, backup_id=backup_id, manifest=manifest, context=context
|
||||
)
|
||||
framed = ArchiveFrameWriter(writer)
|
||||
create_backup_stream(
|
||||
framed, sources, revision, images, deployment_files, manifest=manifest
|
||||
)
|
||||
size, archive_sha256 = framed.finish()
|
||||
read_ack(
|
||||
reader, session_nonce=session_nonce, backup_id=backup_id, size=size,
|
||||
archive_sha256=archive_sha256, manifest_sha256=manifest_sha256,
|
||||
)
|
||||
return {
|
||||
"backup_id": backup_id,
|
||||
"archive_sha256": archive_sha256,
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"size": size,
|
||||
"manifest": manifest,
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Receive and verify one cold backup from the fixed owner SSH session to D:."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from archive import verify_external_archive
|
||||
from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result,
|
||||
MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request)
|
||||
|
||||
DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash")
|
||||
DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release"
|
||||
TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json")
|
||||
AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json")
|
||||
PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json")
|
||||
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
|
||||
MAX_STATUS_BYTES = 1024
|
||||
STATUS_TIMEOUT_SECONDS = 180
|
||||
NOT_READY_REASONS = {
|
||||
"recovery_journal", "runtime_config_changed", "release_policy_changed",
|
||||
"no_current_main_release", "main_advanced", "already_applied",
|
||||
}
|
||||
MAX_ERROR_BYTES = 8192
|
||||
_WINDOWS = os.name == "nt"
|
||||
DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh"
|
||||
|
||||
|
||||
def _reparse(path: Path) -> bool:
|
||||
if path.is_symlink():
|
||||
return True
|
||||
try:
|
||||
return bool(path.stat().st_file_attributes & 0x400)
|
||||
except AttributeError:
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _directory_identity(path: Path) -> tuple[int, int]:
|
||||
info = path.lstat()
|
||||
if _reparse(path) or not stat.S_ISDIR(info.st_mode) or not info.st_ino:
|
||||
raise ValueError("backup publication directory is not a stable real directory")
|
||||
return info.st_dev, info.st_ino
|
||||
|
||||
|
||||
def _remove_owned_directory(path: Path, identity: tuple[int, int]) -> None:
|
||||
try:
|
||||
current = _directory_identity(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if current != identity:
|
||||
raise ValueError(f"refusing to remove a replaced backup directory: {path}")
|
||||
for child in path.iterdir():
|
||||
child_info = child.lstat()
|
||||
if (child.name not in {"backup.tar.gz", "backup.tar.gz.sha256"}
|
||||
or _reparse(child) or not stat.S_ISREG(child_info.st_mode)):
|
||||
raise ValueError(f"refusing to remove unexpected backup content: {child}")
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def check_private_acl(path: Path) -> None:
|
||||
command = r"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$acl = Get-Acl -LiteralPath $env:AKYLDASH_ACL_PATH
|
||||
$driveRoot = [System.IO.Path]::GetPathRoot($env:AKYLDASH_ACL_PATH)
|
||||
$volume = [System.IO.DriveInfo]::new($driveRoot)
|
||||
if (-not $volume.IsReady -or $volume.DriveFormat -ne 'NTFS') { exit 5 }
|
||||
$owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
$allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544')
|
||||
$aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
if ($aclOwner -notin $allowed) { exit 6 }
|
||||
$ownerAllowed = $false
|
||||
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 }
|
||||
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 }
|
||||
if ($sid -eq $owner) { $ownerAllowed = $true }
|
||||
}
|
||||
if (-not $ownerAllowed) { exit 4 }
|
||||
"""
|
||||
environment = {
|
||||
"SystemRoot": os.environ.get("SystemRoot", r"C:\Windows"),
|
||||
"AKYLDASH_ACL_PATH": str(path),
|
||||
}
|
||||
result = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
env=environment, text=True, capture_output=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError(f"backup path ACL is not private (PowerShell status {result.returncode})")
|
||||
|
||||
|
||||
def check_capacity(free_bytes: int) -> None:
|
||||
if free_bytes < MAX_ARCHIVE_BYTES + 1024 * 1024:
|
||||
raise ValueError("D: must have room for the maximum 100 GiB archive plus staging metadata")
|
||||
|
||||
|
||||
def check_repository_target(repository: Path, backup_id: str, *, allow_existing: bool = False) -> tuple[Path, Path]:
|
||||
if not repository.is_absolute() or not repository.is_dir() or _reparse(repository):
|
||||
raise ValueError("repository must be an existing real directory on the selected Windows volume")
|
||||
if _WINDOWS and repository.drive.upper() != "D:":
|
||||
raise ValueError("external backup destination is restricted to D:\\Dev\\akyldash")
|
||||
if _WINDOWS and repository.resolve().as_posix().lower() != "d:/dev/akyldash":
|
||||
raise ValueError("external backup destination is restricted to D:\\Dev\\akyldash")
|
||||
if _WINDOWS:
|
||||
check_capacity(shutil.disk_usage(repository).free)
|
||||
for parent in repository.parents:
|
||||
if _reparse(parent):
|
||||
raise ValueError("backup destination has a reparse-point parent")
|
||||
git_dir = subprocess.run(
|
||||
["git", "-C", str(repository), "rev-parse", "--absolute-git-dir"],
|
||||
check=True, text=True, capture_output=True,
|
||||
).stdout.strip()
|
||||
exclude = Path(git_dir) / "info" / "exclude"
|
||||
if _reparse(exclude) or not exclude.is_file():
|
||||
raise ValueError("the repository's local .git/info/exclude file is unavailable or unsafe")
|
||||
root = repository / ".backups"
|
||||
final = root / backup_id
|
||||
target_file = final / "backup.tar.gz"
|
||||
relative = target_file.relative_to(repository).as_posix()
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), "check-ignore", "--no-index", "-v", "--", relative],
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError("the exact D: backup target is not ignored by local Git metadata")
|
||||
try:
|
||||
source, _line, pattern = result.stdout.split("\t", 1)[0].rsplit(":", 2)
|
||||
except ValueError as error:
|
||||
raise ValueError("git check-ignore returned an unrecognized source record") from error
|
||||
source_path = Path(source)
|
||||
if not source_path.is_absolute():
|
||||
source_path = repository / source_path
|
||||
if source_path.resolve() != exclude.resolve() or pattern != "/.backups/":
|
||||
raise ValueError("the exact D: backup target must be ignored by .git/info/exclude")
|
||||
if root.exists():
|
||||
if _reparse(root) or not root.is_dir():
|
||||
raise ValueError("D: backup root must be a real directory")
|
||||
else:
|
||||
root.mkdir()
|
||||
check_private_acl(root)
|
||||
if final.exists() or _reparse(final):
|
||||
if not allow_existing or _reparse(final) or not final.is_dir():
|
||||
raise ValueError("backup ID already exists or resolves through a reparse point")
|
||||
elif allow_existing:
|
||||
raise ValueError("the pinned external backup is missing from D:")
|
||||
return root, final
|
||||
|
||||
|
||||
def open_previous_backup(repository: Path, challenge: dict) -> Path:
|
||||
_root, directory = check_repository_target(repository, challenge["backup_id"], allow_existing=True)
|
||||
check_private_acl(directory)
|
||||
archive_path = directory / "backup.tar.gz"
|
||||
sidecar = directory / "backup.tar.gz.sha256"
|
||||
for path in (archive_path, sidecar):
|
||||
if _reparse(path) or not path.is_file():
|
||||
raise ValueError("pinned external backup files must be regular files")
|
||||
check_private_acl(path)
|
||||
if archive_path.stat().st_size != challenge["size"]:
|
||||
raise ValueError("pinned external backup size does not match root-owned release state")
|
||||
expected_sidecar = f"{challenge['archive_sha256']} backup.tar.gz\n"
|
||||
if sidecar.read_text(encoding="ascii") != expected_sidecar:
|
||||
raise ValueError("pinned external backup checksum sidecar does not match root-owned release state")
|
||||
manifest = verify_external_archive(
|
||||
archive_path,
|
||||
expected_sha256=challenge["archive_sha256"],
|
||||
expected_manifest_sha256=challenge["manifest_sha256"],
|
||||
)
|
||||
if manifest != challenge["manifest"]:
|
||||
raise ValueError("pinned external backup contents differ from root-owned release state")
|
||||
return archive_path
|
||||
|
||||
|
||||
def _drain_stderr(stream, output: bytearray) -> None:
|
||||
while True:
|
||||
chunk = stream.read(1024)
|
||||
if not chunk:
|
||||
return
|
||||
if len(output) < MAX_ERROR_BYTES:
|
||||
output.extend(chunk[: MAX_ERROR_BYTES - len(output)])
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict:
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("release status contains duplicate JSON fields")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def validate_status_response(payload: bytes) -> dict | None:
|
||||
if not payload or len(payload) > MAX_STATUS_BYTES:
|
||||
raise ValueError("release status response is empty or exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(payload.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("release status response is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "state", "reason", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1):
|
||||
raise ValueError("release status response has an unsupported schema")
|
||||
if value["state"] == "not_ready":
|
||||
if (not isinstance(value["reason"], str) or value["reason"] not in NOT_READY_REASONS
|
||||
or value["release"] is not None):
|
||||
raise ValueError("release status not-ready response is invalid")
|
||||
return None
|
||||
if value["state"] != "prepared" or value["reason"] is not None:
|
||||
raise ValueError("release status state is invalid")
|
||||
release = value["release"]
|
||||
if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("release status candidate tuple is invalid")
|
||||
if (not isinstance(release["run_id"], str) or not re.fullmatch(r"[0-9]{1,20}", release["run_id"])
|
||||
or not isinstance(release["commit"], str) or not re.fullmatch(r"[0-9a-f]{40}", release["commit"])
|
||||
or not isinstance(release["descriptor_sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])):
|
||||
raise ValueError("release status candidate identity is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None:
|
||||
validate_request(request)
|
||||
if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path):
|
||||
raise ValueError("prepared release state path is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
if path.exists():
|
||||
check_private_acl(path)
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
check_private_acl(temporary)
|
||||
os.replace(temporary, path)
|
||||
check_private_acl(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict:
|
||||
if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file():
|
||||
raise ValueError("prepared release state is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
raw = path.read_bytes()
|
||||
if not raw or len(raw) > MAX_STATUS_BYTES:
|
||||
raise ValueError("prepared release state is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("prepared release state is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)):
|
||||
raise ValueError("prepared release state has an unsupported schema")
|
||||
release = value["release"]
|
||||
if set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("prepared release tuple is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release trigger is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file()
|
||||
or not path.parent.is_dir()):
|
||||
raise ValueError("release trigger configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
raw = stream.read(4097)
|
||||
except OSError as error:
|
||||
raise ValueError("release trigger configuration is unreadable") from error
|
||||
if not raw or len(raw) > 4096:
|
||||
raise ValueError("release trigger configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("release trigger configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict)
|
||||
or set(value) != {"schema_version", "enabled", "repository", "ssh_alias"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool
|
||||
or value["repository"] != str(DEFAULT_REPOSITORY)
|
||||
or value["ssh_alias"] != DEFAULT_DEPLOY_SSH_ALIAS):
|
||||
raise ValueError("release trigger configuration does not match the fixed deployment target")
|
||||
return value
|
||||
|
||||
|
||||
def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release apply is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()):
|
||||
raise ValueError("automatic release apply configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError("automatic release apply configuration is unreadable") from error
|
||||
if not raw or len(raw) > 1024:
|
||||
raise ValueError("automatic release apply configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("automatic release apply configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool):
|
||||
raise ValueError("automatic release apply configuration is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS:
|
||||
raise ValueError("automatic release status uses only the fixed deployment SSH alias")
|
||||
command = [
|
||||
ssh, "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
ssh_alias, RELEASE_STATUS_COMMAND,
|
||||
]
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
output = bytearray()
|
||||
overflow = threading.Event()
|
||||
|
||||
def read_status() -> None:
|
||||
while True:
|
||||
chunk = process.stdout.read(min(4096, MAX_STATUS_BYTES + 1 - len(output)))
|
||||
if not chunk:
|
||||
return
|
||||
if len(output) + len(chunk) > MAX_STATUS_BYTES:
|
||||
overflow.set()
|
||||
process.kill()
|
||||
return
|
||||
output.extend(chunk)
|
||||
|
||||
stdout_thread = threading.Thread(target=read_status, daemon=True)
|
||||
stderr_thread = threading.Thread(target=_drain_stderr, args=(process.stderr, bytearray()), daemon=True)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
try:
|
||||
return_code = process.wait(timeout=STATUS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise ValueError("release status request timed out") from error
|
||||
finally:
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
if overflow.is_set() or return_code:
|
||||
raise ValueError("release status command failed or exceeded its response limit")
|
||||
return validate_status_response(bytes(output))
|
||||
|
||||
|
||||
def receive_backup(reader, writer, *, repository: Path, request: dict, expected_context: dict | None = None) -> dict:
|
||||
frame, value = read_response(reader)
|
||||
if frame == "result":
|
||||
raise ValueError(value["message"] or "remote deploy preflight failed")
|
||||
header = value
|
||||
context = header["context"]
|
||||
if request["action"] == "release":
|
||||
expected_context = {key: request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")}
|
||||
if context != expected_context:
|
||||
raise ValueError("source backup session does not match the requested release tuple")
|
||||
elif expected_context is None or context != expected_context:
|
||||
raise ValueError("rollback backup session does not match the pinned previous backup")
|
||||
|
||||
root, final = check_repository_target(repository, header["backup_id"])
|
||||
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=root))
|
||||
staging_identity = None
|
||||
published_identity = None
|
||||
ack_started = False
|
||||
try:
|
||||
staging_identity = _directory_identity(staging)
|
||||
check_private_acl(staging)
|
||||
archive_path = staging / "backup.tar.gz"
|
||||
with archive_path.open("xb") as archive_file:
|
||||
size, archive_sha256 = receive_archive(reader, archive_file)
|
||||
archive_file.flush()
|
||||
os.fsync(archive_file.fileno())
|
||||
os.chmod(archive_path, 0o600)
|
||||
check_private_acl(archive_path)
|
||||
manifest = verify_external_archive(
|
||||
archive_path,
|
||||
expected_sha256=archive_sha256,
|
||||
expected_manifest_sha256=header["manifest_sha256"],
|
||||
)
|
||||
if manifest != header["manifest"]:
|
||||
raise ValueError("saved backup contents differ from the source-generated session manifest")
|
||||
sidecar = staging / "backup.tar.gz.sha256"
|
||||
with sidecar.open("x", encoding="ascii", newline="\n") as stream:
|
||||
stream.write(f"{archive_sha256} backup.tar.gz\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(sidecar, 0o600)
|
||||
check_private_acl(sidecar)
|
||||
if final.exists() or _reparse(final):
|
||||
raise ValueError("backup ID already exists or resolves through a reparse point")
|
||||
os.rename(staging, final)
|
||||
published_identity = staging_identity
|
||||
if _directory_identity(final) != published_identity:
|
||||
raise ValueError("published backup directory identity changed during rename")
|
||||
for path in (final, final / "backup.tar.gz", final / "backup.tar.gz.sha256"):
|
||||
check_private_acl(path)
|
||||
ack_started = True
|
||||
write_ack(
|
||||
writer, session_nonce=header["nonce"], backup_id=header["backup_id"], size=size,
|
||||
archive_sha256=archive_sha256, manifest_sha256=header["manifest_sha256"], verified=True,
|
||||
)
|
||||
return {"backup_id": header["backup_id"], "archive_sha256": archive_sha256, "size": size}
|
||||
except Exception as error:
|
||||
cleanup_path = final if published_identity is not None and not ack_started else staging
|
||||
cleanup_identity = published_identity if cleanup_path == final else staging_identity
|
||||
if cleanup_identity is not None:
|
||||
try:
|
||||
_remove_owned_directory(cleanup_path, cleanup_identity)
|
||||
except Exception as cleanup_error:
|
||||
raise ValueError(
|
||||
f"backup failed and its output could not be safely removed; inspect {cleanup_path}"
|
||||
) from cleanup_error
|
||||
raise
|
||||
|
||||
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if not ssh_alias or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" for character in ssh_alias):
|
||||
raise ValueError("SSH alias must name an existing owner-controlled host entry")
|
||||
check_repository_target(repository, "preflight-" + secrets.token_hex(16))
|
||||
process = subprocess.Popen(
|
||||
[ssh, "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
|
||||
"-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no",
|
||||
ssh_alias, "/usr/local/sbin/akyldash-release-deploy"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
errors = bytearray()
|
||||
thread = threading.Thread(target=_drain_stderr, args=(process.stderr, errors), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
write_request(process.stdin, request)
|
||||
process.stdin.flush()
|
||||
saved = receive_backup(process.stdout, process.stdin, repository=repository, request=request)
|
||||
final = read_result(process.stdout)
|
||||
if process.stdout.read(1):
|
||||
raise ValueError("unexpected trailing bytes after external deployment result")
|
||||
process.stdin.close()
|
||||
return_code = process.wait()
|
||||
thread.join()
|
||||
if return_code or final["status"] != "success":
|
||||
message = final.get("message", "remote deployment failed")
|
||||
raise ValueError(f"remote deployment failed (ssh status {return_code}): {message}")
|
||||
print(f"PASS release completed; verified external backup {saved['backup_id']} ({saved['size']} bytes)")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
thread.join()
|
||||
raise
|
||||
|
||||
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
if not config["enabled"]:
|
||||
print("Release trigger is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.")
|
||||
|
||||
|
||||
def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG,
|
||||
auto_apply_file: Path = AUTO_APPLY_CONFIG,
|
||||
prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
auto_config = load_auto_apply_config(auto_apply_file)
|
||||
if not config["enabled"] or not auto_config["enabled"]:
|
||||
print("Automatic apply is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
if _reparse(prepared_file):
|
||||
raise ValueError("prepared release state is unsafe")
|
||||
if not prepared_file.exists():
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
previous = read_prepared_request(prepared_file)
|
||||
if previous != request:
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH, confirm=input) -> None:
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("applying a prepared release requires an interactive owner confirmation")
|
||||
request = read_prepared_request(prepared_file)
|
||||
answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ")
|
||||
if answer != f"APPLY {request['commit']}":
|
||||
print("Prepared release was not applied.")
|
||||
return
|
||||
config = load_trigger_config(config_file)
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if not ssh_alias or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-" for character in ssh_alias):
|
||||
raise ValueError("SSH alias must name an existing owner-controlled host entry")
|
||||
check_repository_target(repository, "preflight-" + secrets.token_hex(16))
|
||||
process = subprocess.Popen(
|
||||
[ssh, "-T", ssh_alias, "/usr/local/sbin/akyldash-release-rollback"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
errors = bytearray()
|
||||
thread = threading.Thread(target=_drain_stderr, args=(process.stderr, errors), daemon=True)
|
||||
thread.start()
|
||||
request = {"version": 1, "action": "rollback", "data_compatible": True}
|
||||
try:
|
||||
write_request(process.stdin, request)
|
||||
process.stdin.flush()
|
||||
challenge = read_challenge(process.stdout)
|
||||
archive_path = open_previous_backup(repository, challenge)
|
||||
framed = ArchiveFrameWriter(process.stdin, maximum=challenge["size"])
|
||||
with archive_path.open("rb") as archive_file:
|
||||
for chunk in iter(lambda: archive_file.read(1024 * 1024), b""):
|
||||
framed.write(chunk)
|
||||
size, digest = framed.finish()
|
||||
if size != challenge["size"] or digest != challenge["archive_sha256"]:
|
||||
raise ValueError("D: previous backup changed during the live rollback session")
|
||||
write_ack(process.stdin, session_nonce=challenge["nonce"], backup_id=challenge["backup_id"],
|
||||
size=size, archive_sha256=digest, manifest_sha256=challenge["manifest_sha256"], verified=True)
|
||||
receive_backup(process.stdout, process.stdin, repository=repository, request=request,
|
||||
expected_context={key: challenge[key] for key in
|
||||
("backup_id", "archive_sha256", "manifest_sha256")} | {"action": "rollback"})
|
||||
final = read_result(process.stdout)
|
||||
if process.stdout.read(1):
|
||||
raise ValueError("unexpected trailing bytes after external rollback result")
|
||||
process.stdin.close()
|
||||
return_code = process.wait()
|
||||
thread.join()
|
||||
if return_code or final["status"] != "success":
|
||||
raise ValueError(f"remote rollback failed (ssh status {return_code}): {final.get('message', 'error')}")
|
||||
print(f"PASS rollback completed; fresh external backup saved for {challenge['backup_id']}")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
thread.join()
|
||||
raise
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy")
|
||||
parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation")
|
||||
parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled")
|
||||
parser.add_argument("--rollback", action="store_true")
|
||||
parser.add_argument("--data-compatible", action="store_true")
|
||||
parser.add_argument("--run-id")
|
||||
parser.add_argument("--commit")
|
||||
parser.add_argument("--descriptor-sha256")
|
||||
parser.add_argument("--ssh-alias", default="akyldash_vps")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.once:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any(
|
||||
(args.run_id, args.commit, args.descriptor_sha256))
|
||||
):
|
||||
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
|
||||
run_release_once()
|
||||
elif args.apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--apply-prepared accepts no release tuple or rollback flags")
|
||||
apply_prepared_release()
|
||||
elif args.auto_apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags")
|
||||
run_auto_apply_once()
|
||||
elif args.rollback:
|
||||
if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)):
|
||||
raise ValueError("rollback requires --data-compatible and accepts no release tuple")
|
||||
run_rollback(ssh_alias=args.ssh_alias)
|
||||
else:
|
||||
if not args.run_id or not args.commit or not args.descriptor_sha256 or args.data_compatible:
|
||||
raise ValueError("release requires --run-id, --commit, and --descriptor-sha256")
|
||||
request = {
|
||||
"version": 1, "action": "release", "run_id": args.run_id,
|
||||
"commit": args.commit, "descriptor_sha256": args.descriptor_sha256,
|
||||
}
|
||||
run_release(request, ssh_alias=args.ssh_alias)
|
||||
except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import tarfile
|
||||
@@ -8,7 +9,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from archive import COMPONENTS, compose_sources, create_backup, restore_to_staging, verify_archive
|
||||
from archive import COMPONENTS, compose_sources, create_backup, create_backup_stream, restore_to_staging, verify_archive, verify_external_archive
|
||||
|
||||
|
||||
class BackupArchiveTests(unittest.TestCase):
|
||||
@@ -50,6 +51,39 @@ class BackupArchiveTests(unittest.TestCase):
|
||||
self.assertEqual((target / "review-secret").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(verify_archive(self.backup)["components"], sorted(COMPONENTS))
|
||||
|
||||
def test_external_stream_uses_no_local_archive_and_verifies_received_bytes(self):
|
||||
stream = io.BytesIO()
|
||||
with patch("archive.os.geteuid", return_value=0):
|
||||
manifest = create_backup_stream(stream, self.sources, "a" * 40)
|
||||
self.assertFalse(self.backup.exists())
|
||||
received = self.private / "received.tar.gz"
|
||||
received.write_bytes(stream.getvalue())
|
||||
verified = verify_external_archive(
|
||||
received,
|
||||
expected_sha256=hashlib.sha256(stream.getvalue()).hexdigest(),
|
||||
expected_manifest_sha256=hashlib.sha256(
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest(),
|
||||
)
|
||||
self.assertEqual(verified["git_revision"], "a" * 40)
|
||||
|
||||
def test_external_archive_rejects_changed_manifest_and_duplicate_keys(self):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
raw = b'{"format":1,"format":1,"components":[],"images":{},"deployment_files":{}}'
|
||||
info.size = len(raw)
|
||||
archive.addfile(info, io.BytesIO(raw))
|
||||
for name in COMPONENTS:
|
||||
info = tarfile.TarInfo(name + "/")
|
||||
info.type = tarfile.DIRTYPE
|
||||
archive.addfile(info)
|
||||
received = self.private / "duplicate.tar.gz"
|
||||
received.write_bytes(stream.getvalue())
|
||||
with self.assertRaisesRegex(ValueError, "duplicate backup manifest"):
|
||||
verify_external_archive(received, expected_sha256=hashlib.sha256(stream.getvalue()).hexdigest(),
|
||||
expected_manifest_sha256="0" * 64)
|
||||
|
||||
def test_rejects_checksum_mismatch_without_creating_staging(self):
|
||||
create_backup(self.backup, self.sources, "abc123")
|
||||
self.backup.write_bytes(self.backup.read_bytes() + b"tamper")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
from external_session import (
|
||||
ArchiveFrameWriter, CHUNK_BYTES, read_ack, read_challenge, read_header, read_request, read_result,
|
||||
receive_archive, write_ack, write_challenge, write_header, write_request, write_result,
|
||||
)
|
||||
from external_session import ACK_MAGIC, MAGIC
|
||||
|
||||
|
||||
class ExternalSessionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.nonce = "a" * 64
|
||||
self.backup_id = "backup-20261031T120000Z"
|
||||
self.manifest = {"format": 1, "git_revision": "b" * 40, "images": {}}
|
||||
|
||||
def make_stream(self, payload=b"tar\x00bytes" * 100):
|
||||
stream = io.BytesIO()
|
||||
manifest_sha = write_header(
|
||||
stream, session_nonce=self.nonce, backup_id=self.backup_id, manifest=self.manifest
|
||||
)
|
||||
framed = ArchiveFrameWriter(stream, maximum=4096)
|
||||
framed.write(payload[:37])
|
||||
framed.write(payload[37:])
|
||||
size, digest = framed.finish()
|
||||
stream.seek(0)
|
||||
return stream, payload, manifest_sha, size, digest
|
||||
|
||||
def test_binary_archive_round_trip_and_live_ack_binding(self):
|
||||
stream, payload, manifest_sha, size, digest = self.make_stream()
|
||||
header = read_header(stream)
|
||||
target = io.BytesIO()
|
||||
self.assertEqual(receive_archive(stream, target, maximum=4096), (size, digest))
|
||||
self.assertEqual(target.getvalue(), payload)
|
||||
self.assertEqual(hashlib.sha256(payload).hexdigest(), digest)
|
||||
|
||||
ack = io.BytesIO()
|
||||
write_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id,
|
||||
size=size, archive_sha256=digest, manifest_sha256=manifest_sha, verified=True)
|
||||
ack.seek(0)
|
||||
self.assertEqual(read_ack(
|
||||
ack, session_nonce=header["nonce"], backup_id=header["backup_id"], size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha,
|
||||
)["verified"], True)
|
||||
|
||||
def test_single_large_write_is_split_into_bounded_frames(self):
|
||||
payload = b"x" * (CHUNK_BYTES * 2 + 13)
|
||||
stream = io.BytesIO()
|
||||
writer = ArchiveFrameWriter(stream)
|
||||
writer.write(payload)
|
||||
size, digest = writer.finish()
|
||||
stream.seek(0)
|
||||
target = io.BytesIO()
|
||||
self.assertEqual(receive_archive(stream, target), (size, digest))
|
||||
self.assertEqual(target.getvalue(), payload)
|
||||
|
||||
def test_rejects_wrong_nonce_replay_and_unverified_ack(self):
|
||||
stream, _payload, manifest_sha, size, digest = self.make_stream()
|
||||
ack = io.BytesIO()
|
||||
write_ack(ack, session_nonce="c" * 64, backup_id=self.backup_id,
|
||||
size=size, archive_sha256=digest, manifest_sha256=manifest_sha, verified=True)
|
||||
ack.seek(0)
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
read_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id, size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha)
|
||||
|
||||
def test_rejects_corrupt_digest_truncated_stream_and_oversized_chunk(self):
|
||||
stream, _payload, _manifest_sha, _size, _digest = self.make_stream()
|
||||
read_header(stream)
|
||||
corrupted = bytearray(stream.getvalue())
|
||||
corrupted[-1] ^= 1
|
||||
corrupted_stream = io.BytesIO(corrupted)
|
||||
read_header(corrupted_stream)
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
receive_archive(corrupted_stream, io.BytesIO(), maximum=4096)
|
||||
truncated = io.BytesIO(stream.getvalue()[:-3])
|
||||
read_header(truncated)
|
||||
with self.assertRaisesRegex(ValueError, "ended before"):
|
||||
receive_archive(truncated, io.BytesIO(), maximum=4096)
|
||||
with self.assertRaisesRegex(ValueError, "size limit"):
|
||||
receive_archive(io.BytesIO(b"\x00\x10\x00\x01"), io.BytesIO(), maximum=4096)
|
||||
|
||||
def test_final_result_is_bounded_and_typed(self):
|
||||
stream = io.BytesIO()
|
||||
write_result(stream, status="error", message="backup verification failed")
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_result(stream)["status"], "error")
|
||||
with self.assertRaisesRegex(ValueError, "invalid"):
|
||||
write_result(io.BytesIO(), status="maybe")
|
||||
|
||||
def test_rollback_challenge_pins_external_archive_identity(self):
|
||||
identity = {
|
||||
"backup_id": self.backup_id, "size": 128, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": hashlib.sha256(
|
||||
json.dumps(self.manifest, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest(), "manifest": self.manifest,
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
write_challenge(stream, identity, session_nonce=self.nonce)
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_challenge(stream), {"version": 1, "nonce": self.nonce, **identity})
|
||||
identity["size"] = 0
|
||||
with self.assertRaisesRegex(ValueError, "identity"):
|
||||
write_challenge(io.BytesIO(), identity, session_nonce=self.nonce)
|
||||
|
||||
def test_release_request_is_strictly_typed_and_rejects_extra_commands(self):
|
||||
request = {
|
||||
"version": 1, "action": "release", "run_id": "301", "commit": "b" * 40,
|
||||
"descriptor_sha256": "c" * 64,
|
||||
}
|
||||
stream = io.BytesIO()
|
||||
write_request(stream, request)
|
||||
stream.seek(0)
|
||||
self.assertEqual(read_request(stream), request)
|
||||
with self.assertRaisesRegex(ValueError, "unexpected fields"):
|
||||
write_request(io.BytesIO(), request | {"shell": "id"})
|
||||
|
||||
def test_protocol_rejects_boolean_integers_and_non_boolean_ack(self):
|
||||
payload = json.dumps({"version": True, "action": "rollback", "data_compatible": True}).encode()
|
||||
request = io.BytesIO(MAGIC + struct.pack("!I", len(payload)) + payload)
|
||||
with self.assertRaisesRegex(ValueError, "unsupported version"):
|
||||
read_request(request)
|
||||
ack_value = {
|
||||
"version": 1, "nonce": self.nonce, "backup_id": self.backup_id,
|
||||
"size": 128, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": "d" * 64, "verified": 1,
|
||||
}
|
||||
payload = json.dumps(ack_value).encode()
|
||||
ack = io.BytesIO(ACK_MAGIC + struct.pack("!I", len(payload)) + payload)
|
||||
with self.assertRaisesRegex(ValueError, "acknowledgement is invalid"):
|
||||
read_ack(ack, session_nonce=self.nonce, backup_id=self.backup_id, size=128,
|
||||
archive_sha256="c" * 64, manifest_sha256="d" * 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,388 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import receive_external
|
||||
from archive import COMPONENTS
|
||||
from external_session import ArchiveFrameWriter, read_ack, write_header
|
||||
|
||||
|
||||
class ExternalReceiverTests(unittest.TestCase):
|
||||
def test_windows_preflight_requires_full_protocol_archive_capacity(self):
|
||||
required = receive_external.MAX_ARCHIVE_BYTES + 1024 * 1024
|
||||
receive_external.check_capacity(required)
|
||||
with self.assertRaisesRegex(ValueError, "maximum 100 GiB archive"):
|
||||
receive_external.check_capacity(required - 1)
|
||||
|
||||
def setUp(self):
|
||||
self.request = {
|
||||
"version": 1, "action": "release", "run_id": "301", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64,
|
||||
}
|
||||
self.manifest = {
|
||||
"format": 1, "created_at": "2026-10-31T12:00:00+00:00", "git_revision": "c" * 40,
|
||||
"images": {"backend": {"configured": "backend:old", "image_id": "sha256:" + "d" * 64}},
|
||||
"deployment_files": {"deploy/production/compose.yaml": "e" * 64},
|
||||
"components": sorted(COMPONENTS),
|
||||
}
|
||||
|
||||
def source_stream(self, context=None):
|
||||
archive_stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_stream, mode="w:gz") as archive:
|
||||
manifest_bytes = json.dumps(self.manifest, sort_keys=True).encode("utf-8")
|
||||
member = tarfile.TarInfo("manifest.json")
|
||||
member.size = len(manifest_bytes)
|
||||
archive.addfile(member, io.BytesIO(manifest_bytes))
|
||||
for component in COMPONENTS:
|
||||
member = tarfile.TarInfo(component + "/")
|
||||
member.type = tarfile.DIRTYPE
|
||||
archive.addfile(member)
|
||||
payload = archive_stream.getvalue()
|
||||
stream = io.BytesIO()
|
||||
manifest_sha = write_header(
|
||||
stream, session_nonce="f" * 64, backup_id="backup-20261031T120000Z",
|
||||
manifest=self.manifest,
|
||||
context=context or {key: self.request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")},
|
||||
)
|
||||
framed = ArchiveFrameWriter(stream)
|
||||
framed.write(payload)
|
||||
size, digest = framed.finish()
|
||||
stream.seek(0)
|
||||
return stream, manifest_sha, size, digest
|
||||
|
||||
def test_verified_archive_is_atomically_published_before_ack(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, manifest_sha, size, digest = self.source_stream()
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl") as acl_check:
|
||||
result = receive_external.receive_backup(
|
||||
stream, ack, repository=root, request=self.request
|
||||
)
|
||||
self.assertEqual(result, {"backup_id": final.name, "archive_sha256": digest, "size": size})
|
||||
self.assertTrue((final / "backup.tar.gz").is_file())
|
||||
if os.name == "nt":
|
||||
for path in (final, final / "backup.tar.gz", final / "backup.tar.gz.sha256"):
|
||||
acl_check.assert_any_call(path)
|
||||
else:
|
||||
self.assertEqual((final / "backup.tar.gz").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((final / "backup.tar.gz.sha256").read_text(), f"{digest} backup.tar.gz\n")
|
||||
ack.seek(0)
|
||||
self.assertTrue(read_ack(
|
||||
ack, session_nonce="f" * 64, backup_id=final.name, size=size,
|
||||
archive_sha256=digest, manifest_sha256=manifest_sha,
|
||||
)["verified"])
|
||||
|
||||
def test_mismatched_release_binding_never_publishes_or_acks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream(
|
||||
context={"action": "release", "run_id": "999", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}
|
||||
)
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
receive_external.receive_backup(stream, ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_invalid_saved_stream_is_removed_without_publication(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream()
|
||||
raw = bytearray(stream.getvalue())
|
||||
raw[-1] ^= 1
|
||||
ack = io.BytesIO()
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
receive_external.receive_backup(io.BytesIO(raw), ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(list(root.iterdir()), [])
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_post_publish_acl_failure_removes_owned_final_without_ack(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
final = root / "backup-20261031T120000Z"
|
||||
stream, _manifest_sha, _size, _digest = self.source_stream()
|
||||
ack = io.BytesIO()
|
||||
|
||||
def fail_published_archive(path):
|
||||
if path == final / "backup.tar.gz":
|
||||
raise ValueError("post-publish ACL failed")
|
||||
|
||||
with patch.object(receive_external, "check_repository_target", return_value=(root, final)), \
|
||||
patch.object(receive_external, "check_private_acl", side_effect=fail_published_archive):
|
||||
with self.assertRaisesRegex(ValueError, "post-publish ACL failed"):
|
||||
receive_external.receive_backup(stream, ack, repository=root, request=self.request)
|
||||
self.assertFalse(final.exists())
|
||||
self.assertEqual(list(root.iterdir()), [])
|
||||
self.assertEqual(ack.getvalue(), b"")
|
||||
|
||||
def test_cleanup_refuses_a_replaced_or_reparse_directory(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
owned = root / "owned"
|
||||
final = root / "final"
|
||||
owned.mkdir()
|
||||
identity = receive_external._directory_identity(owned)
|
||||
owned.rename(root / "moved-owned")
|
||||
final.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, "replaced backup directory"):
|
||||
receive_external._remove_owned_directory(final, identity)
|
||||
self.assertTrue(final.is_dir())
|
||||
self.assertTrue((root / "moved-owned").is_dir())
|
||||
if os.name != "nt":
|
||||
link = root / "link"
|
||||
link.symlink_to(final, target_is_directory=True)
|
||||
with self.assertRaisesRegex(ValueError, "real directory"):
|
||||
receive_external._remove_owned_directory(link, identity)
|
||||
self.assertTrue(final.is_dir())
|
||||
|
||||
def test_cleanup_refuses_unexpected_directory_contents(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "owned"
|
||||
path.mkdir()
|
||||
identity = receive_external._directory_identity(path)
|
||||
(path / "foreign-data").write_text("keep", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected backup content"):
|
||||
receive_external._remove_owned_directory(path, identity)
|
||||
self.assertEqual((path / "foreign-data").read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_target_requires_exact_local_git_exclude_rule(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
repository = Path(directory)
|
||||
exclude = repository / ".git/info/exclude"
|
||||
exclude.parent.mkdir(parents=True)
|
||||
exclude.write_text("/.backups/\n", encoding="utf-8")
|
||||
responses = [
|
||||
type("Result", (), {"stdout": str(repository / ".git"), "returncode": 0})(),
|
||||
type("Result", (), {
|
||||
"stdout": f"{exclude}:1:/.backups/\t.backups/backup-id/backup.tar.gz\n",
|
||||
"returncode": 0,
|
||||
})(),
|
||||
]
|
||||
with patch.object(receive_external, "_WINDOWS", False), \
|
||||
patch.object(receive_external.subprocess, "run", side_effect=responses), \
|
||||
patch.object(receive_external, "check_private_acl"):
|
||||
root, final = receive_external.check_repository_target(
|
||||
repository, "backup-id"
|
||||
)
|
||||
self.assertEqual(root, repository / ".backups")
|
||||
self.assertEqual(final, root / "backup-id")
|
||||
|
||||
def test_windows_path_guard_rejects_a_synthetic_non_d_destination(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
repository = Path(directory)
|
||||
with patch.object(receive_external, "_WINDOWS", True):
|
||||
with self.assertRaisesRegex(ValueError, r"restricted to D:\\Dev\\akyldash"):
|
||||
receive_external.check_repository_target(repository, "backup-id")
|
||||
|
||||
def test_windows_acl_probe_does_not_inherit_credentials(self):
|
||||
result = type("Result", (), {"returncode": 0})()
|
||||
with patch.dict("os.environ", {"GITEA_TOKEN": "must-not-pass"}, clear=True), \
|
||||
patch.object(receive_external.subprocess, "run", return_value=result) as run_mock:
|
||||
receive_external.check_private_acl(Path(r"D:\Dev\akyldash\.backups"))
|
||||
child_env = run_mock.call_args.kwargs["env"]
|
||||
self.assertNotIn("GITEA_TOKEN", child_env)
|
||||
self.assertEqual(child_env["AKYLDASH_ACL_PATH"], r"D:\Dev\akyldash\.backups")
|
||||
self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1])
|
||||
|
||||
def test_private_acl_rejects_an_unapproved_acl_owner(self):
|
||||
result = type("Result", (), {"returncode": 6})()
|
||||
with patch.object(receive_external.subprocess, "run", return_value=result) as run_mock:
|
||||
with self.assertRaisesRegex(ValueError, "PowerShell status 6"):
|
||||
receive_external.check_private_acl(Path(r"D:\Dev\akyldash\.backups"))
|
||||
command = run_mock.call_args.args[0][-1]
|
||||
self.assertIn("$acl.GetOwner(", command)
|
||||
self.assertIn("if ($aclOwner -notin $allowed) { exit 6 }", command)
|
||||
|
||||
def test_destination_preflight_fails_before_opening_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(receive_external.shutil, "which", return_value="ssh"), \
|
||||
patch.object(receive_external, "check_repository_target", side_effect=ValueError("preflight failed")) as preflight, \
|
||||
patch.object(receive_external.subprocess, "Popen") as popen:
|
||||
with self.assertRaisesRegex(ValueError, "preflight failed"):
|
||||
receive_external.run_release(self.request, repository=Path(directory))
|
||||
with self.assertRaisesRegex(ValueError, "preflight failed"):
|
||||
receive_external.run_rollback(repository=Path(directory))
|
||||
self.assertEqual(preflight.call_count, 2)
|
||||
self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list))
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_status_response_accepts_only_the_fixed_prepared_tuple(self):
|
||||
response = {"schema_version": 1, "state": "prepared", "reason": None,
|
||||
"release": {"run_id": "302", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}}
|
||||
self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()),
|
||||
{"version": 1, "action": "release", **response["release"]})
|
||||
response["reason"] = []
|
||||
with self.assertRaises(ValueError):
|
||||
receive_external.validate_status_response(json.dumps(response).encode())
|
||||
self.assertIsNone(receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}'
|
||||
))
|
||||
with self.assertRaisesRegex(ValueError, "state"):
|
||||
receive_external.validate_status_response(
|
||||
json.dumps({**response, "state": "candidate"}).encode()
|
||||
)
|
||||
|
||||
def test_status_response_rejects_extra_or_duplicate_fields(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
b'"release":null,"extra":0}')
|
||||
with self.assertRaisesRegex(ValueError, "schema"):
|
||||
receive_external.validate_status_response(payload)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
b'"release":null,"release":null}')
|
||||
|
||||
def test_status_query_uses_only_fixed_command_and_non_forwarding_ssh(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready",'
|
||||
b'"reason":"no_current_main_release","release":null}\n')
|
||||
process = type("Process", (), {
|
||||
"stdout": io.BytesIO(payload), "stderr": io.BytesIO(),
|
||||
"wait": lambda self, timeout: 0,
|
||||
})()
|
||||
with patch.object(receive_external.shutil, "which", return_value="ssh"), \
|
||||
patch.object(receive_external.subprocess, "Popen", return_value=process) as popen:
|
||||
self.assertIsNone(receive_external.query_release_status())
|
||||
command = popen.call_args.args[0]
|
||||
self.assertEqual(command[-2:], ["akyldash_release", receive_external.RELEASE_STATUS_COMMAND])
|
||||
self.assertIn("ForwardAgent=no", command)
|
||||
self.assertIn("ClearAllForwardings=yes", command)
|
||||
self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
|
||||
def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self):
|
||||
config = {"schema_version": 1, "enabled": False,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY),
|
||||
"ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "trigger.json"
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
with patch.object(receive_external, "_WINDOWS", True), \
|
||||
patch.object(receive_external, "check_private_acl"), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy, \
|
||||
patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"):
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
config["enabled"] = True
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
prepared = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = prepared
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared)
|
||||
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=False):
|
||||
with self.assertRaisesRegex(ValueError, "interactive owner confirmation"):
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
)
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \
|
||||
patch.object(receive_external, "run_release") as apply:
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
confirm=lambda prompt: f"APPLY {'a' * 40}",
|
||||
)
|
||||
apply.assert_called_once_with(
|
||||
prepared, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
|
||||
def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self):
|
||||
previous = {"version": 1, "action": "release", "run_id": "301",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
latest = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "c" * 40, "descriptor_sha256": "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
prepared_file = root / "prepared.json"
|
||||
trigger_file = root / "trigger.json"
|
||||
auto_file = root / "auto.json"
|
||||
with patch.object(receive_external, "check_private_acl"):
|
||||
receive_external.write_prepared_request(previous, prepared_file)
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "query_release_status", return_value=latest) as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
query.assert_called_once()
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(prepared_file), latest)
|
||||
|
||||
query.reset_mock()
|
||||
query.return_value = latest
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
deploy.assert_called_once_with(
|
||||
latest, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release",
|
||||
)
|
||||
|
||||
def test_auto_apply_disabled_does_not_open_ssh(self):
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once()
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
def test_windows_trigger_templates_are_fixed_and_disabled(self):
|
||||
root = Path(__file__).resolve().parents[1] / "deploy"
|
||||
config = json.loads((root / "windows-release-trigger.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(config, {
|
||||
"schema_version": 1, "enabled": False,
|
||||
"repository": r"D:\Dev\akyldash", "ssh_alias": "akyldash_release",
|
||||
})
|
||||
task = ET.parse(root / "windows-release-trigger.example.xml").getroot()
|
||||
ns = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(task.findtext("t:Settings/t:MultipleInstancesPolicy", namespaces=ns), "IgnoreNew")
|
||||
self.assertEqual(task.findtext("t:Settings/t:StopIfGoingOnBatteries", namespaces=ns), "false")
|
||||
self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege")
|
||||
self.assertEqual(
|
||||
task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once',
|
||||
)
|
||||
auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(auto_config, {"schema_version": 1, "enabled": False})
|
||||
auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot()
|
||||
self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(
|
||||
auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -gt 1 || ( $# -eq 1 && $1 != status ) ]]; then
|
||||
echo "This fixed receiver is owner-run as root and accepts its request only on stdin." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
helper=/usr/local/lib/akyldash/gitea-api
|
||||
token_file=/etc/akyldash/gitea-api-token
|
||||
origins_file=/etc/akyldash/gitea-download-origins
|
||||
if [[ -L $helper || ! -x $helper || $(stat -c %U:%a "$helper") != root:750 ]]; then
|
||||
echo "The reviewed root-owned Gitea helper is not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -L $token_file || ! -f $token_file || $(stat -c %U:%a "$token_file") != root:600 ]]; then
|
||||
echo "A root-owned mode-0600 read-only Gitea API token file is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
GITEA_TOKEN=$(< "$token_file")
|
||||
if [[ ! $GITEA_TOKEN =~ ^[A-Za-z0-9_-]+$ ]]; then
|
||||
echo "The Gitea API token file is empty or has an invalid format." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITEA_DOWNLOAD_ORIGINS=
|
||||
if [[ -e $origins_file || -L $origins_file ]]; then
|
||||
if [[ -L $origins_file || ! -f $origins_file || $(stat -c %U:%a "$origins_file") != root:600 ]]; then
|
||||
echo "Gitea artifact redirect origins must be a root-owned mode-0600 file." >&2
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r origin || [[ -n $origin ]]; do
|
||||
[[ -z $origin ]] && continue
|
||||
if [[ ! $origin =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?$ ]]; then
|
||||
echo "Gitea artifact redirect origin configuration is invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n $GITEA_DOWNLOAD_ORIGINS ]]; then
|
||||
GITEA_DOWNLOAD_ORIGINS+=,
|
||||
fi
|
||||
GITEA_DOWNLOAD_ORIGINS+=$origin
|
||||
done < "$origins_file"
|
||||
fi
|
||||
|
||||
export GITEA_URL=https://git.vakanaut.ru
|
||||
export GITEA_TOKEN GITEA_DOWNLOAD_ORIGINS
|
||||
export PATH=/usr/local/lib/akyldash:/usr/bin:/bin
|
||||
if [[ $# -eq 1 ]]; then
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_status.py
|
||||
fi
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_deploy.py
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -ne 0 ]]; then
|
||||
echo "The restricted release dispatcher accepts only its exact SSH command." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
/usr/local/sbin/akyldash-release-status)
|
||||
exec /usr/local/sbin/akyldash-release-deploy status
|
||||
;;
|
||||
/usr/local/sbin/akyldash-release-deploy)
|
||||
exec /usr/local/sbin/akyldash-release-deploy
|
||||
;;
|
||||
*)
|
||||
echo "The restricted release dispatcher accepts only status or deploy." >&2
|
||||
exit 126
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 || $# -ne 0 ]]; then
|
||||
echo "This fixed receiver is owner-run as root and accepts its request only on stdin." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH=/usr/local/lib/akyldash:/usr/bin:/bin
|
||||
exec /usr/bin/python3 /usr/local/lib/akyldash/external_rollback.py
|
||||
@@ -0,0 +1,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())
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a traceable, unsigned release descriptor from verified build digests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BUILDER_IMAGE = "ghcr.io/osscontainertools/kaniko:v1.28.5-debug@sha256:c73ca67a3b494bce79a8fb6090c4f73c18997ab272d80bffc460a62343aeb412"
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = path.read_text(encoding="ascii").strip()
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
||||
raise ValueError(f"invalid Kaniko digest in {path}")
|
||||
return value
|
||||
|
||||
|
||||
def create(repository: str, commit: str, source_run_id: str, run_id: str, backend: Path, opensearch: Path) -> dict:
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9._-]*", repository):
|
||||
raise ValueError("repository must be lowercase owner/repository")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("commit must be a 40-character lowercase Git SHA-1")
|
||||
if not re.fullmatch(r"[0-9]+", run_id):
|
||||
raise ValueError("run_id must be numeric")
|
||||
if not re.fullmatch(r"[0-9]+", source_run_id):
|
||||
raise ValueError("source_run_id must be numeric")
|
||||
registry = "git.vakanaut.ru/" + repository
|
||||
return {
|
||||
"schema_version": 4,
|
||||
"source_ref": "refs/heads/main",
|
||||
"repository": repository,
|
||||
"commit": commit,
|
||||
"workflow": ".gitea/workflows/release-images.yaml",
|
||||
"run_id": run_id,
|
||||
"source_run_id": source_run_id,
|
||||
"builder": {"name": "Kaniko", "image": BUILDER_IMAGE},
|
||||
"images": {
|
||||
"backend": f"{registry}/backend@{digest(backend)}",
|
||||
"opensearch": f"{registry}/opensearch@{digest(opensearch)}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--source-run-id", required=True)
|
||||
parser.add_argument("--backend-digest", required=True, type=Path)
|
||||
parser.add_argument("--opensearch-digest", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
manifest = create(args.repository, args.commit, args.source_run_id, args.run_id, args.backend_digest, args.opensearch_digest)
|
||||
args.output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS release descriptor for {manifest['commit']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Owner-run, locked production release cutover using a verified cold backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from create_release_manifest import BUILDER_IMAGE
|
||||
from deployment_lock import deployment_lock
|
||||
from release_refs import read_images, replace_images
|
||||
from release_state import read_json, validate_images, validate_state, write_json_atomic
|
||||
from validate_release import read_json as read_manifest_json, validate_manifest
|
||||
|
||||
ROOT = Path("/opt/akyldash/current")
|
||||
COMPOSE_FILE = ROOT / "deploy/production/compose.yaml"
|
||||
ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
BACKUP_DIR = Path("/var/backups/akyldash")
|
||||
BACKUP_TOOL = Path("/usr/local/lib/akyldash/archive.py")
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
JOURNAL_FILE = Path("/opt/akyldash/release-recovery.json")
|
||||
DEPLOYED_IMAGE_FILE = Path("/opt/akyldash/deployed-image.txt")
|
||||
SERVICES = ("backend", "opensearch")
|
||||
SEARCH_CANARY_URL = "https://akyldash.kg/search"
|
||||
|
||||
|
||||
def run(command: list[str], capture: bool = False) -> str:
|
||||
result = subprocess.run(command, check=True, text=True, capture_output=capture)
|
||||
return result.stdout.strip() if capture else ""
|
||||
|
||||
|
||||
def compose(*args: str, capture: bool = False) -> str:
|
||||
return run(["docker", "compose", "--env-file", str(ENV_FILE), "-f", str(COMPOSE_FILE), *args], capture)
|
||||
|
||||
|
||||
def image_ids(*, allow_stopped: bool = False) -> dict[str, dict[str, str]]:
|
||||
configured = read_images(ENV_FILE)[1]
|
||||
records = {}
|
||||
for service, key in (("backend", "BACKEND_IMAGE"), ("opensearch", "OPENSEARCH_IMAGE")):
|
||||
container = compose("ps", "--all", "--quiet", service, capture=True)
|
||||
if not container or "\n" in container:
|
||||
raise ValueError(f"expected exactly one existing {service} container")
|
||||
inspection = run(
|
||||
["docker", "inspect", "--format", "{{.State.Running}}\t{{.Image}}", container], capture=True
|
||||
)
|
||||
running, separator, image_id = inspection.partition("\t")
|
||||
if not separator or (running != "true" and not (allow_stopped and running == "false")):
|
||||
raise ValueError(f"{service} container state is not approved for the release operation")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"invalid running image ID for {service}")
|
||||
records[service] = {"configured": configured[key], "image_id": image_id}
|
||||
return records
|
||||
|
||||
|
||||
def write_deployed_backend_image(image_id: str) -> None:
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError("invalid backend image ID for deployment record")
|
||||
with tempfile.NamedTemporaryFile(dir=DEPLOYED_IMAGE_FILE.parent, prefix=".deployed-image-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(f"{image_id}\n".encode("ascii"))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, DEPLOYED_IMAGE_FILE)
|
||||
directory_fd = os.open(DEPLOYED_IMAGE_FILE.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def require_backup_matches_current(current: dict, backup: dict) -> None:
|
||||
if current != backup:
|
||||
raise ValueError("backup image references or image IDs do not match the current deployment")
|
||||
|
||||
|
||||
def restore_previous_references(old_env: dict[str, str], new_env: dict[str, str]) -> None:
|
||||
_, actual = read_images(ENV_FILE)
|
||||
if actual == new_env:
|
||||
replace_images(ENV_FILE, new_env, old_env)
|
||||
elif actual != old_env:
|
||||
raise ValueError("production image references match neither the previous nor target release")
|
||||
_, restored = read_images(ENV_FILE)
|
||||
if restored != old_env:
|
||||
raise ValueError("previous production image references could not be verified")
|
||||
|
||||
|
||||
def app_images(value: dict, label: str) -> dict[str, dict[str, str]]:
|
||||
images = value.get("images")
|
||||
if not isinstance(images, dict) or not set(SERVICES) <= images.keys():
|
||||
raise ValueError(f"{label} does not contain both application image records")
|
||||
return validate_images({service: images[service] for service in SERVICES}, label)
|
||||
|
||||
|
||||
def validate_search_response(payload: dict, language: str) -> None:
|
||||
results = payload.get("results") if isinstance(payload, dict) else None
|
||||
if not isinstance(results, list) or not results:
|
||||
raise ValueError(f"{language} search canary returned no results")
|
||||
if not isinstance(results[0], dict) or not str(results[0].get("code", "")).strip():
|
||||
raise ValueError(f"{language} search canary returned a malformed first result")
|
||||
|
||||
|
||||
def check_search_canary(language: str, query: str) -> None:
|
||||
if language not in {"ru", "ky"} or not query.strip():
|
||||
raise ValueError("both Russian and Kyrgyz search canaries must be non-empty")
|
||||
params = urllib.parse.urlencode({"q": query, "language": language, "page_size": "1"})
|
||||
output = run(
|
||||
["curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
f"{SEARCH_CANARY_URL}?{params}"], capture=True
|
||||
)
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f"{language} search canary returned invalid JSON") from error
|
||||
validate_search_response(payload, language)
|
||||
|
||||
|
||||
def deploy_external(
|
||||
manifest_value: dict, policy_value: dict, backup_value: dict, backup_identity: dict,
|
||||
descriptor_sha256: str, current_before_stop: dict, canary_ru: str, canary_ky: str,
|
||||
) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("production cutover is owner-run and requires root")
|
||||
manifest = validate_manifest(manifest_value, policy_value)
|
||||
if manifest["builder"] != {"name": "Kaniko", "image": BUILDER_IMAGE}:
|
||||
raise ValueError("release descriptor uses an unapproved builder")
|
||||
from release_state import validate_backup_identity
|
||||
|
||||
backup_identity = validate_backup_identity({
|
||||
key: backup_identity[key]
|
||||
for key in ("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")
|
||||
})
|
||||
if backup_identity is None or backup_identity["manifest"] != backup_value:
|
||||
raise ValueError("release requires a source-generated, live-verified external backup")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
|
||||
raise ValueError("release descriptor digest is invalid")
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import require_runtime_compatibility
|
||||
|
||||
def verify_release_still_current() -> None:
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT,
|
||||
policy_file=Path("/etc/akyldash/release-policy.json"),
|
||||
)
|
||||
|
||||
verify_release_still_current()
|
||||
if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop:
|
||||
raise ValueError("stopped services do not match the pre-backup image snapshot")
|
||||
if app_images(backup_value, "external backup images") != current_before_stop:
|
||||
raise ValueError("external backup image identities do not match the current deployment")
|
||||
current_files = {
|
||||
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
|
||||
for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
|
||||
}
|
||||
if backup_value.get("deployment_files") != current_files:
|
||||
raise ValueError("external backup configuration does not match the checked out Compose and Caddy files")
|
||||
pinned = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
pinned = validate_state(read_json(STATE_FILE))
|
||||
if pinned["current"] != current_before_stop:
|
||||
raise ValueError("current deployment does not match the recorded release state")
|
||||
|
||||
backup_record = {
|
||||
"backup_id": backup_identity["backup_id"],
|
||||
"size": backup_identity["size"],
|
||||
"archive_sha256": backup_identity["archive_sha256"],
|
||||
"manifest_sha256": backup_identity["manifest_sha256"],
|
||||
"manifest": backup_value,
|
||||
}
|
||||
backup_record = validate_backup_identity(backup_record)
|
||||
|
||||
old_env = {"BACKEND_IMAGE": current_before_stop["backend"]["configured"], "OPENSEARCH_IMAGE": current_before_stop["opensearch"]["configured"]}
|
||||
new_env = {"BACKEND_IMAGE": manifest["images"]["backend"], "OPENSEARCH_IMAGE": manifest["images"]["opensearch"]}
|
||||
journal = read_json(JOURNAL_FILE)
|
||||
state_written = False
|
||||
try:
|
||||
for service in SERVICES:
|
||||
reference = manifest["images"][service]
|
||||
run(["docker", "pull", reference])
|
||||
pulled_id = run(["docker", "image", "inspect", "--format", "{{.Id}}", reference], capture=True)
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", pulled_id):
|
||||
raise ValueError(f"registry returned an invalid image ID for {service}")
|
||||
target = {
|
||||
service: {"configured": manifest["images"][service], "image_id": run(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", manifest["images"][service]], capture=True
|
||||
)}
|
||||
for service in SERVICES
|
||||
}
|
||||
journal.update({"status": "external-backup-verified", "target": target})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
verify_release_still_current()
|
||||
replace_images(ENV_FILE, old_env, new_env)
|
||||
journal["status"] = "references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
actual = image_ids()
|
||||
if actual != target:
|
||||
raise ValueError("started image IDs do not match the release descriptor")
|
||||
run(["curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
"https://akyldash.kg/review", "--output", "/dev/null"])
|
||||
check_search_canary("ru", canary_ru)
|
||||
check_search_canary("ky", canary_ky)
|
||||
write_deployed_backend_image(actual["backend"]["image_id"])
|
||||
state = {
|
||||
"schema_version": 2, "commit": manifest["commit"],
|
||||
"manifest_sha256": descriptor_sha256, "current": actual,
|
||||
"previous": current_before_stop, "previous_backup": backup_record,
|
||||
}
|
||||
write_json_atomic(STATE_FILE, state)
|
||||
state_written = True
|
||||
JOURNAL_FILE.unlink()
|
||||
print(f"PASS deployed release {manifest['commit']} with --no-build", file=sys.stderr)
|
||||
except Exception as error:
|
||||
if state_written:
|
||||
journal["status"] = "release-active-journal-cleanup-needed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
journal["recovery"] = "release is active; verify host and resolve the recovery journal"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"release is active; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
journal["status"] = "cutover-failed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
try:
|
||||
restore_previous_references(old_env, new_env)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
restored = image_ids()
|
||||
if restored != current_before_stop:
|
||||
raise ValueError("restored running image IDs do not match the previous release")
|
||||
write_deployed_backend_image(current_before_stop["backend"]["image_id"])
|
||||
if pinned is not None:
|
||||
write_json_atomic(STATE_FILE, pinned)
|
||||
elif (STATE_FILE.exists() or STATE_FILE.is_symlink()) and not STATE_FILE.is_symlink():
|
||||
STATE_FILE.unlink()
|
||||
journal["recovery"] = "previous references and running image IDs restored and verified"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = (
|
||||
f"manual recovery required ({type(recovery_error).__name__}); "
|
||||
"references, release state, and external backup identity are recorded"
|
||||
)
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"cutover failed; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
argparse.ArgumentParser(description=__doc__).parse_args()
|
||||
print("ERROR: local backup paths are disabled; use the fixed external session receiver", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""One root-owned lock shared by image cutover and rollback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import stat
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
LOCK_FILE = Path("/run/akyldash-deploy.lock")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def deployment_lock():
|
||||
descriptor = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o777 != 0o600:
|
||||
raise ValueError("deployment lock must be a regular mode-0600 file")
|
||||
if os.geteuid() == 0 and info.st_uid != 0:
|
||||
raise ValueError("deployment lock must be root-owned")
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as error:
|
||||
raise ValueError("another deployment or rollback holds the shared lock") from error
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fixed root-side Gitea release receiver and external-backup cutover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids
|
||||
from external_session import read_request, send_backup_session, validate_request, write_result
|
||||
from release_source import fetch_release, require_runtime_compatibility
|
||||
from release_state import read_json, validate_state, write_json_atomic
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
CANARIES_FILE = Path("/etc/akyldash/release-canaries.json")
|
||||
COMPOSE_FILE = ROOT / "deploy/production/compose.yaml"
|
||||
ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
|
||||
|
||||
def owner_canaries() -> tuple[str, str]:
|
||||
info = CANARIES_FILE.lstat()
|
||||
if (CANARIES_FILE.is_symlink() or not CANARIES_FILE.is_file() or info.st_uid != 0
|
||||
or info.st_mode & 0o777 != 0o600):
|
||||
raise ValueError("release canaries must be stored in a root-owned mode-0600 file")
|
||||
value = json.loads(CANARIES_FILE.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or set(value) != {"ru", "ky"}:
|
||||
raise ValueError("release canary configuration must contain exactly ru and ky")
|
||||
queries = (value["ru"], value["ky"])
|
||||
if any(not isinstance(query, str) or not query.strip() or len(query) > 256 for query in queries):
|
||||
raise ValueError("release canaries must be non-empty strings of at most 256 characters")
|
||||
return queries
|
||||
|
||||
|
||||
def restart_current(current: dict) -> None:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted service image IDs do not match the pre-backup deployment")
|
||||
|
||||
|
||||
def execute(request: dict, *, reader, writer, policy: dict | None = None, api=None,
|
||||
current_main: str | None = None, canaries: tuple[str, str] | None = None) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("the fixed external deployment receiver must run as root")
|
||||
validate_request(request)
|
||||
policy = policy if policy is not None else read_json(POLICY_FILE)
|
||||
canary_ru, canary_ky = canaries or owner_canaries()
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
manifest, _manifest_bytes = fetch_release(
|
||||
request, policy=policy, current_main=current_main, checkout=ROOT,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
|
||||
with deployment_lock():
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
current = image_ids()
|
||||
current_record = {service: current[service] for service in SERVICES}
|
||||
revision = deploy_release_revision()
|
||||
previous_state = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
previous_state = validate_state(read_json(STATE_FILE))
|
||||
if previous_state["current"] != current_record:
|
||||
raise ValueError("current deployment does not match the recorded release state")
|
||||
if (previous_state["commit"] == manifest["commit"]
|
||||
and previous_state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
raise ValueError("release is already applied")
|
||||
journal = {
|
||||
"schema_version": 2, "status": "external-backup-pending", "commit": manifest["commit"],
|
||||
"manifest_sha256": request["descriptor_sha256"], "previous": current_record,
|
||||
}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
compose("stop")
|
||||
sources, stopped_revision, stopped_images, deployment_files = compose_sources(COMPOSE_FILE, ENV_FILE)
|
||||
if stopped_revision != revision or stopped_images.get("backend") != current["backend"] or stopped_images.get("opensearch") != current["opensearch"]:
|
||||
raise ValueError("stopped production inventory does not match its pre-backup snapshot")
|
||||
backup_identity = send_backup_session(
|
||||
writer, reader, sources=sources, revision=stopped_revision,
|
||||
images=stopped_images, deployment_files=deployment_files,
|
||||
context={key: request[key] for key in ("action", "run_id", "commit", "descriptor_sha256")},
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
restart_current(current)
|
||||
JOURNAL_FILE.unlink(missing_ok=True)
|
||||
except Exception as recovery_error:
|
||||
journal.update({"status": "external-backup-failed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"external backup failed and current services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
|
||||
backup_record = {key: backup_identity[key] for key in
|
||||
("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")}
|
||||
journal = {
|
||||
"schema_version": 2, "status": "external-backup-verified", "commit": manifest["commit"],
|
||||
"manifest_sha256": request["descriptor_sha256"], "backup": backup_record,
|
||||
"previous": current_record,
|
||||
}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
deploy_external(
|
||||
manifest, policy, backup_identity["manifest"], backup_record,
|
||||
request["descriptor_sha256"], current_record, canary_ru, canary_ky,
|
||||
)
|
||||
except Exception as error:
|
||||
current_journal = read_json(JOURNAL_FILE) if JOURNAL_FILE.exists() else {}
|
||||
if current_journal.get("status") == "external-backup-verified":
|
||||
try:
|
||||
restart_current(current)
|
||||
JOURNAL_FILE.unlink()
|
||||
except Exception as recovery_error:
|
||||
current_journal["status"] = "pre-cutover-recovery-needed"
|
||||
current_journal["error_type"] = type(error).__name__
|
||||
current_journal["recovery_error_type"] = type(recovery_error).__name__
|
||||
write_json_atomic(JOURNAL_FILE, current_journal)
|
||||
raise ValueError(f"pre-cutover recovery failed; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
return {"backup_id": backup_record["backup_id"], "commit": manifest["commit"]}
|
||||
|
||||
|
||||
def deploy_release_revision() -> str:
|
||||
from deploy_release import run
|
||||
return run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
|
||||
|
||||
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
request = read_request(sys.stdin.buffer)
|
||||
result = execute(request, reader=sys.stdin.buffer, writer=sys.stdout.buffer)
|
||||
write_result(sys.stdout.buffer, status="success", message=result["backup_id"])
|
||||
except Exception as error:
|
||||
print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
try:
|
||||
write_result(sys.stdout.buffer, status="error", message=f"deployment failed ({type(error).__name__})")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-side rollback using a pinned external backup and a fresh external backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import COMPOSE_FILE, ENV_FILE, JOURNAL_FILE, ROOT, compose, image_ids
|
||||
from external_session import (nonce, read_ack, read_request,
|
||||
receive_archive, send_backup_session, write_challenge,
|
||||
write_result)
|
||||
from release_state import (read_json, validate_state, write_json_atomic)
|
||||
from rollback import rollback_external
|
||||
|
||||
STATE_FILE = ROOT.parent / "release-state.json"
|
||||
|
||||
|
||||
class Discard:
|
||||
def write(self, chunk: bytes) -> int:
|
||||
return len(chunk)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def execute(*, reader, writer) -> str:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("the fixed external rollback receiver must run as root")
|
||||
request = read_request(reader)
|
||||
if request != {"version": 1, "action": "rollback", "data_compatible": True}:
|
||||
raise ValueError("external rollback request is invalid")
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before rollback")
|
||||
|
||||
with deployment_lock():
|
||||
state = validate_state(read_json(STATE_FILE))
|
||||
if state["schema_version"] != 2 or state["previous_backup"] is None:
|
||||
raise ValueError("release state has no verified external previous backup")
|
||||
target = state["previous_backup"]
|
||||
session_nonce = nonce()
|
||||
write_challenge(writer, target, session_nonce=session_nonce)
|
||||
size, digest = receive_archive(reader, Discard(), maximum=target["size"])
|
||||
if size != target["size"] or digest != target["archive_sha256"]:
|
||||
raise ValueError("rollback bytes do not match the root-pinned external backup")
|
||||
read_ack(reader, session_nonce=session_nonce, backup_id=target["backup_id"], size=size,
|
||||
archive_sha256=digest, manifest_sha256=target["manifest_sha256"])
|
||||
|
||||
current = image_ids()
|
||||
if current != state["current"]:
|
||||
raise ValueError("current deployment does not match root-pinned release state")
|
||||
journal = {"schema_version": 2, "status": "external-backup-pending", "previous": current}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
try:
|
||||
compose("stop")
|
||||
sources, revision, stopped_images, deployment_files = compose_sources(COMPOSE_FILE, ENV_FILE)
|
||||
if any(stopped_images.get(service) != record for service, record in current.items()):
|
||||
raise ValueError("stopped deployment does not match its pre-rollback snapshot")
|
||||
fresh = send_backup_session(
|
||||
writer, reader, sources=sources, revision=revision, images=stopped_images,
|
||||
deployment_files=deployment_files,
|
||||
context={"action": "rollback", "backup_id": target["backup_id"],
|
||||
"archive_sha256": target["archive_sha256"],
|
||||
"manifest_sha256": target["manifest_sha256"]},
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted image IDs differ from the pre-rollback deployment")
|
||||
JOURNAL_FILE.unlink(missing_ok=True)
|
||||
except Exception as recovery_error:
|
||||
journal.update({"status": "external-backup-failed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"rollback backup failed and services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
|
||||
fresh_record = {key: fresh[key] for key in
|
||||
("backup_id", "size", "archive_sha256", "manifest_sha256", "manifest")}
|
||||
write_json_atomic(JOURNAL_FILE, {
|
||||
"schema_version": 2, "status": "external-backup-verified",
|
||||
"backup": fresh_record, "previous": current,
|
||||
})
|
||||
try:
|
||||
rollback_external(state=state, target_identity=target, current=current,
|
||||
fresh_backup=fresh_record, data_compatible=True)
|
||||
except Exception as error:
|
||||
journal = read_json(JOURNAL_FILE) if JOURNAL_FILE.exists() else {}
|
||||
if journal.get("status") == "external-backup-verified":
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
if image_ids() != current:
|
||||
raise ValueError("restarted image IDs differ from the pre-rollback deployment")
|
||||
JOURNAL_FILE.unlink()
|
||||
except Exception as recovery_error:
|
||||
journal.update({
|
||||
"status": "pre-cutover-recovery-needed",
|
||||
"error_type": type(error).__name__,
|
||||
"recovery_error_type": type(recovery_error).__name__,
|
||||
})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"rollback preflight failed and services need recovery; journal at {JOURNAL_FILE}") from error
|
||||
raise
|
||||
return fresh_record["backup_id"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
result = execute(reader=sys.stdin.buffer, writer=sys.stdout.buffer)
|
||||
write_result(sys.stdout.buffer, status="success", message=result)
|
||||
return 0
|
||||
except Exception as error:
|
||||
print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
|
||||
try:
|
||||
write_result(sys.stdout.buffer, status="error", message=f"rollback failed ({type(error).__name__})")
|
||||
except OSError:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only preparation endpoint for one fully verified current-main release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import fetch_release, latest_release_request, runtime_compatibility_reason
|
||||
from release_state import read_json, validate_state
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
STATUS_MAX_BYTES = 1024
|
||||
|
||||
|
||||
def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE,
|
||||
policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE,
|
||||
main_lookup=latest_main_sha) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("release status command must run as root")
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
|
||||
current_main = main_lookup(checkout)
|
||||
api_args = {"api": api} if api is not None else {}
|
||||
reason = runtime_compatibility_reason(
|
||||
current_main, checkout=checkout, policy_file=policy_file, **api_args,
|
||||
)
|
||||
if reason is not None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None}
|
||||
request = latest_release_request(current_main=current_main, checkout=checkout, **api_args)
|
||||
if request is None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None}
|
||||
if state_file.exists() or state_file.is_symlink():
|
||||
state = validate_state(read_json(state_file))
|
||||
if (state["commit"] == request["commit"]
|
||||
and state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
manifest, _raw = fetch_release(
|
||||
request, policy=read_json(policy_file), current_main=current_main, checkout=checkout,
|
||||
**api_args,
|
||||
)
|
||||
if manifest["commit"] != current_main or manifest["run_id"] != request["run_id"]:
|
||||
raise ValueError("verified release descriptor does not match the selected source")
|
||||
if main_lookup(checkout) != current_main:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None}
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
if state_file.exists() or state_file.is_symlink():
|
||||
state = validate_state(read_json(state_file))
|
||||
if (state["commit"] == request["commit"]
|
||||
and state["manifest_sha256"] == request["descriptor_sha256"]):
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"state": "prepared",
|
||||
"reason": None,
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
|
||||
|
||||
def write_status(value: dict, stream=None) -> None:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
if len(payload) > STATUS_MAX_BYTES:
|
||||
raise ValueError("release status response exceeds its size limit")
|
||||
(stream or sys.stdout.buffer).write(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
write_status(status_document())
|
||||
except Exception as error:
|
||||
print(f"release status unavailable ({type(error).__name__})", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||