Добавить воспроизводимую основу инфраструктуры
CI / tests-and-images (pull_request) Has been cancelled

This commit is contained in:
agent committed 2026-10-11 18:27:15 +03:00
1 parent fb1a67044c
commit d03da40eee
27 files changed
+664 -25

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
tests-and-images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run backend tests
run: PYTHONPATH=backend python3 -m unittest discover -s backend -p 'test_*.py' -v
- name: Run Telegram bot tests
run: python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v
- name: Build production images
run: |
docker build -f deploy/production/Dockerfile.backend -t akyldash-backend:ci .
docker build -f deploy/production/Dockerfile.opensearch -t akyldash-opensearch:ci .
+23
View File
@@ -0,0 +1,23 @@
# Акылдаш — инструкции для агента
Проект: юридическая информационно-аналитическая платформа; Search API на
Python, SQLite и OpenSearch. Production пока не развернут.
- Начинай с [инфраструктурного состояния](docs/operations/CURRENT_STATE.md) и
[правил доступа](docs/operations/ACCESS.md). Не запрашивай доступы повторно.
- Архитектура и процедуры: [инфраструктура](docs/operations/INFRASTRUCTURE.md),
[деплой](docs/operations/DEPLOYMENT.md), [backup](docs/operations/BACKUP.md),
[rollback](docs/operations/ROLLBACK.md), [восстановление](docs/operations/RECOVERY.md).
- Работай в feature/fix/refactor-ветке. `main` защищена; изменения вноси через
PR, не сливай его автоматически. Сохраняй незакоммиченные изменения.
- SSH alias — `akyldash_vps`; используй только read-only диагностику. Не давай
`codex` общий sudo или доступ к Docker socket. Root-действия требует владелец.
Для Gitea используй `gitea-api`, не читай файлы с учётными данными.
- Локальные проверки: `PYTHONPATH=backend python3 -m unittest discover -s backend -p 'test_*.py' -v` и
`python3 -m unittest discover -s tools/telegram-bot -p 'test_*.py' -v`.
Production images собираются командами из `.gitea/workflows/ci.yaml`.
- Перед изменениями production необходим проверенный backup. Формального
механизма миграций нет; проверяй совместимость SQLite/OpenSearch. Не меняй
SSH, firewall или production вручную без владельца и последующего PR.
- После каждого изменения инфраструктуры обновляй
`docs/operations/CURRENT_STATE.md` по фактическим проверкам.
+1
View File
@@ -2,6 +2,7 @@
## Не выпущено
- Backend `0.11.3`: ключ подписи поисковых снимков читается из Docker secret-файла, а не из аргументов процесса. Добавлена базовая инфраструктура VPS, drift-проверка и CI для тестов и сборки образов; production-деплой остаётся выключен до проверки runner, backup/restore и ограниченного deploy-helper.
- Frontend `0.1.2`: на планшетной ширине экран поиска переходит в одну колонку, чтобы фильтры, результаты и документ помещались в окно.
- Backend `0.11.2`: смена пароля требует текущий пароль и запрещает оставить прежний, включая временный пароль при первом входе.
- Backend `0.11.1` и frontend `0.1.1`: в управлении пользователями показываются даты первого и последнего входа, а также последнего сброса пароля.
+3 -3
View File
@@ -10,7 +10,7 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.11.2` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
`0.11.3` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
@@ -18,7 +18,7 @@ SQLite; первый пользователь `admin` создаётся инт
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.11.2` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
| Backend | `0.11.3` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -62,4 +62,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
+2 -2
View File
@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.11.2`
Версия: `0.11.3`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -253,4 +253,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.11.2 · Frontend v0.1.2
Акылдаш · Backend v0.11.3 · Frontend v0.1.2
+9 -1
View File
@@ -451,6 +451,7 @@ def main() -> int:
parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3"))
parser.add_argument("--users-db", type=Path, default=Path("data/search-users.sqlite3"))
parser.add_argument("--review-secret", default=None)
parser.add_argument("--review-secret-file", type=Path)
parser.add_argument("--cookie-secure", action=argparse.BooleanOptionalAction, default=os.environ.get("COOKIE_SECURE", "false").lower() == "true")
parser.add_argument("--create-admin", action="store_true", help="create the initial admin account and exit")
parser.add_argument("--reset-admin-password", action="store_true", help="set a temporary password for admin and exit")
@@ -458,7 +459,14 @@ def main() -> int:
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
secret = arguments.review_secret.encode() if arguments.review_secret else None
if arguments.review_secret and arguments.review_secret_file:
parser.error("choose either --review-secret or --review-secret-file")
try:
secret = arguments.review_secret.encode() if arguments.review_secret else arguments.review_secret_file.read_bytes().strip() if arguments.review_secret_file else None
except OSError as error:
parser.error(f"cannot read review secret file: {error}")
if arguments.review_secret_file and not secret:
parser.error("review secret file is empty")
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()
+1 -1
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.11.2"
APP_VERSION = "0.11.3"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
+5 -5
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Оценка поисковой выдачи · Акылдаш</title>
<style>
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", "Segoe UI", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", Georgia, serif; }
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", serif; }
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: #F7F2E8; }
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: var(--surface); }
@@ -53,7 +53,7 @@
.review__comment { min-height: 4rem; resize: vertical; background: var(--surface); }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 24rem; }
.review__document-meta { margin-block-end: 1rem; padding-block-end: 1rem; border-block-end: 1px solid var(--line); color: var(--muted); }
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; font: 400 1.125rem/1.65 "Noto Sans", "Segoe UI", sans-serif; }
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; font: 400 1.125rem/1.65 "Noto Sans", sans-serif; }
.review__document-body img { max-width: 100%; height: auto; }
.review__actions { max-width: 1320px; margin: 1.5rem auto 0; padding-inline: clamp(1rem, 3vw, 3rem); }
.review__actions-inner { display: flex; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
@@ -115,7 +115,7 @@
.search__document { min-height: 60vh; }
.search__document-controls { display: flex; flex-wrap: wrap; gap: .75rem; }
.search__document-controls label { display: grid; gap: .25rem; min-width: 11rem; color: var(--muted); font-size: .875rem; }
.search__document-body { max-width: 75ch; margin: auto; font: 400 1.125rem/1.65 "Noto Sans", "Segoe UI", sans-serif; }
.search__document-body { max-width: 75ch; margin: auto; font: 400 1.125rem/1.65 "Noto Sans", sans-serif; }
.search__document-body h1, .search__document-body h2, .search__document-body h3 { font-family: var(--serif); }
.search__document-body img { max-width: 100%; height: auto; }
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -176,7 +176,7 @@
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.1.2 · Backend v0.11.2 · Внутренняя лаборатория релевантности</div></footer>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.1.2 · Backend v0.11.3 · Внутренняя лаборатория релевантности</div></footer>
<script>
document.body.classList.add('review__locked');
const DRAFT_KEY = 'akyldash-search-review-draft';
@@ -193,7 +193,7 @@
let account = null;
const reviewRoute = location.pathname === '/review';
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.1.2 · Backend v0.11.2'; }
if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.1.2 · Backend v0.11.3'; }
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = `${user.username} · ${user.role}`; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); $('users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
+23
View File
@@ -1,4 +1,6 @@
import json
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
@@ -36,6 +38,27 @@ class SearchApiTest(unittest.TestCase):
with self.assertRaisesRegex(ApiError, "within 10000 results"):
api.handle("GET", "/search?q=x&page=100&page_size=100")
def test_review_secret_file_is_read_without_command_line_value(self):
from search.api import main
with tempfile.TemporaryDirectory() as temporary:
secret_file = Path(temporary) / "secret"
secret_file.write_text("secret-value\n", encoding="utf-8")
with patch("sys.argv", ["search.api", "--review-secret-file", str(secret_file)]), patch("search.api.Api") as api, patch("search.api.ThreadingHTTPServer"):
main()
self.assertEqual(api.call_args.args[4], b"secret-value")
def test_review_secret_file_rejects_empty_content(self):
from search.api import main
with tempfile.TemporaryDirectory() as temporary:
secret_file = Path(temporary) / "secret"
secret_file.touch()
with patch("sys.argv", ["search.api", "--review-secret-file", str(secret_file)]), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as result:
main()
self.assertEqual(result.exception.code, 2)
def test_production_api_uses_registration_intent_boosts(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
+2 -3
View File
@@ -1,9 +1,8 @@
# Absolute path on the production host containing minjust-normalized/.
DATA_ROOT=/volume1/docker/akyldash/data
DATA_ROOT=/var/lib/akyldash/data
REVIEW_SECRET_FILE=/etc/akyldash/review_secret
BACKEND_PORT=8080
SEARCH_INDEX=akyldash-fragments-current
OPENSEARCH_MEM_LIMIT=4g
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
# Generate with: openssl rand -hex 32
REVIEW_SECRET=replace-with-a-random-secret
COOKIE_SECURE=true
+9 -2
View File
@@ -12,8 +12,15 @@ workspace; it does not replace host-level network controls or TLS.
## First deployment
1. Copy this directory to the host with the repository source.
2. Copy `.env.example` to `.env`, set an absolute `DATA_ROOT`, and replace
`REVIEW_SECRET` with a random value. Do not commit `.env`.
2. Copy `.env.example` to `.env`, set an absolute `DATA_ROOT`, and create the
secret file referenced by `REVIEW_SECRET_FILE` outside the repository:
```bash
sudo install -d -o root -g root -m 0700 /etc/akyldash
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/akyldash/review_secret'
```
Do not commit `.env` or the secret file.
3. Put the normalized dataset under `$DATA_ROOT/minjust-normalized/`.
4. Check the rendered configuration:
+20 -3
View File
@@ -1,9 +1,16 @@
name: akyldash
services:
opensearch:
build:
context: ../..
dockerfile: deploy/production/Dockerfile.opensearch
restart: unless-stopped
logging:
driver: local
options:
max-size: 10m
max-file: "3"
environment:
discovery.type: single-node
bootstrap.memory_lock: "true"
@@ -32,8 +39,12 @@ services:
context: ../..
dockerfile: deploy/production/Dockerfile.backend
restart: unless-stopped
logging:
driver: local
options:
max-size: 10m
max-file: "3"
environment:
REVIEW_SECRET: ${REVIEW_SECRET:?set REVIEW_SECRET in .env}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
command:
- python
@@ -53,8 +64,10 @@ services:
- /app/data/search-reviews.sqlite3
- --users-db
- /app/data/search-users.sqlite3
- --review-secret
- ${REVIEW_SECRET}
- --review-secret-file
- /run/secrets/review_secret
secrets:
- review_secret
ports:
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
depends_on:
@@ -65,3 +78,7 @@ services:
volumes:
opensearch-data:
secrets:
review_secret:
file: ${REVIEW_SECRET_FILE:?set REVIEW_SECRET_FILE in .env}
+5 -1
View File
@@ -26,6 +26,10 @@
- [Текущий статус](operations/project-status.md) — выполненные шаги, риски и
ближайшие действия.
- [Состояние инфраструктуры](operations/CURRENT_STATE.md) — подтверждённое
состояние production и оставшиеся проверки.
- [Инфраструктура и эксплуатация](operations/INFRASTRUCTURE.md) — схема,
доступы, deployment, backup, rollback и recovery.
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
роли и этапы развития командного окружения.
@@ -41,4 +45,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
+58
View File
@@ -0,0 +1,58 @@
# Infrastructure access
## Discover existing access first
In a new Codex session, inspect the SSH alias and key file metadata without
printing key contents, then check the connection:
```bash
ssh -F ~/.ssh/config -G akyldash_vps | awk '$1 == "hostname" || $1 == "user" || $1 == "port" || $1 == "identityfile" { print }'
test -r ~/.ssh/akyldash_codex && echo 'SSH key is available'
ssh -F ~/.ssh/config -o BatchMode=yes akyldash_vps 'id; hostname'
gitea-api GET /version
gitea-api GET /repos/admin/akyldash
```
Do not ask for the host, login, password, token, or key again before these
checks. Never print a private key, token, or complete environment file.
## VPS
- SSH alias: `akyldash_vps`, user `codex`, host `206.223.249.118`, port `22`.
- SSH configuration: `~/.ssh/config`.
- Identity file: `~/.ssh/akyldash_codex` (path only; never copy its contents).
- `codex` can run unprivileged read-only diagnostics. The audited account has no
non-interactive sudo and must not be added to `sudo` or the `docker` group.
- Host package installation, service setup, firewall changes, secret creation,
backup restore, and production changes require the VPS owner to run a reviewed
root command. No restricted deploy-helper is installed yet.
- Never connect to other VPS hosts or Synology as part of this deployment.
This execution environment's system SSH config include had unsafe file
permissions, so plain `ssh akyldash_vps` failed. `ssh -F ~/.ssh/config ...`
worked. Network policy may also require an approved SSH command. If the key is
missing in a future execution environment, report that limitation; do not
retrieve it from another machine.
## Gitea
- Repository: `https://git.vakanaut.ru/admin/akyldash`.
- Use the installed `gitea-api` helper; it reads credentials from the
environment. Never inspect local credential files, print environment values,
or construct HTTP authorization headers.
- The audited API token can read/write this repository and create issues, but
does not have `read:user` or `read:package`. An API `403` caused by a missing
scope is not evidence that the account itself lacks the permission.
- Protected `main` requires one approval. Do not push directly to `main`, merge
automatically, or start a review. Work on a feature branch and use a PR.
- Gitea Actions and package features are enabled for the repository, but runner
availability and package publishing permissions have not been confirmed.
## Who can do what
| Role | Operations |
|---|---|
| `codex` over SSH | Read-only host diagnostics; inspect service health and public listeners |
| Restricted deploy-helper | Intended future allowlisted backup/deploy/status actions; not installed |
| VPS owner/root | Bootstrap, package installation, SSH/firewall, secret setup, recovery, and approval of production changes |
| Gitea repository writer | Feature branch push and PR creation; protected-main merge needs an approval |
+28
View File
@@ -0,0 +1,28 @@
# Backup and restore
## Current status
No production data or backup mechanism existed during the 2026-10-11 audit.
`/var/backups/akyldash` is a planned root-only location, not an active backup.
Do not report backup protection as enabled until an archive and restore have
both been checked.
## Required release policy
- 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,
and required secret file in the protected backup set. Store secrets separately
with equivalent access controls.
- Verify archive readability, checksums, and SQLite integrity before
replacing the previous known-good 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 data is not yet managed by a tested backup script. Until that exists, the
owner must pause deployment and approve a maintenance window for a reviewed
backup/restore procedure. Copying live SQLite files with `cp` or an unchecked
tar archive is not an approved backup.
+61
View File
@@ -0,0 +1,61 @@
# Infrastructure current state
Last audit: 2026-10-11. Source: read-only SSH session as `codex`, local
repository inspection, and Gitea API. This document separates measured facts
from planned configuration.
## VPS facts
- Host: `avprogs.hlab.kz`, `206.223.249.118`, SSH port `22`, user `codex`.
- OS/kernel: Ubuntu Server `24.04.5 LTS`, `6.8.0-142-generic`.
- Resources: 4 vCPU, 7.7 GiB RAM, 3.8 GiB swap, 99 GiB disk with 93 GiB free.
- Network: interface `ens3`, IPv4 `206.223.249.118/32`.
- `codex` is UID/GID 1000, groups `codex` and `users`; non-interactive sudo is
unavailable.
- SSH service is running. The inspected sshd configuration has public-key and
password authentication enabled and `PermitRootLogin yes`; no SSH changes
were made.
- Docker, Docker Compose, Git, Nginx, Caddy, Certbot, application paths, and
application services are absent. Only base OS services and SSH were running.
- Public listeners observed: TCP `22`; local resolver listeners on loopback.
- UFW is reported active in the supplied VPS notes, but the audit account could
not independently read its status. Treat firewall state and rules as
unverified.
## Application and Gitea facts
- 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 Gitea token lacks `read:user` and `read:package`; runner status and package
publishing permissions could not be checked. No workflow existed at audit.
- Production Compose describes Python Search API `0.11.3`, SQLite account and
review data, and OpenSearch `3.7.0` with `analysis-icu`. Nothing is running.
- Compose binds the API to `127.0.0.1:8080`, leaves OpenSearch private, uses a
named OpenSearch volume, and a host data bind mount. TLS/reverse proxy,
registry digest deployment, backup, rollback helper, and systemd deployment
are not configured.
- 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
deployed on this VPS).
## Checks not completed
- SSH completed with the user config explicitly selected; plain SSH was blocked
by permissions on a system config include. The private key was not read.
- UFW rules require root and remain unverified.
- DNS delegation, HTTPS, Cloudflare Full (strict), public application health,
Actions runner availability, registry publishing, and production deployment
have not been tested.
- No packages or services were installed or changed on the VPS.
## Next owner actions
1. Review and run the root bootstrap on the dedicated host.
2. Review SSH/password/root policy and firewall rules separately; this change
did not touch them.
3. Set up DNS, origin certificate, reverse proxy, protected secret files, and
initial corpus/index.
4. Confirm a Gitea Actions runner and package permissions, then test CI and
backup/restore before considering automated production deploy.
+9
View File
@@ -0,0 +1,9 @@
# Infrastructure decisions
| Date | Decision | Reason and alternatives | Status / limits |
|---|---|---|---|
| 2026-10-11 | Use Docker Compose on the dedicated Ubuntu VPS | Matches the existing production Compose baseline and workload; Kubernetes adds no demonstrated need | Planned; Docker is not installed |
| 2026-10-11 | Keep `codex` out of sudo and the Docker group | Docker socket access is effectively root; use an owner-run bootstrap and a narrowly reviewed deploy-helper | No helper installed yet |
| 2026-10-11 | Keep Search API on loopback and OpenSearch on the private Compose network | Minimizes host exposure; HTTPS terminates at a reviewed reverse proxy | Compose target only; no proxy or firewall audit privileges |
| 2026-10-11 | Mount the review signing key as a Compose secret file | Avoids putting its value in command-line arguments; an environment variable or CLI value is easier to inspect in process metadata | Implemented in repository Compose; not deployed |
| 2026-10-11 | Do not enable production deployment from Gitea Actions yet | Runner, package publishing scope, deploy-helper, backup/restore, and production health are not verified | Revisit after controlled end-to-end test |
+37
View File
@@ -0,0 +1,37 @@
# Deployment
## Current status
Production deployment is not enabled or tested. The VPS has no Docker, Git,
application data, proxy, registry pull configuration, backup process, or
restricted deploy-helper. Do not deploy until the owner has completed the root
bootstrap, provided a protected secret file and a reviewed proxy/firewall
configuration, and a backup and restore have been tested.
## First host preparation
1. Review the current inventory and state, then have the owner run
`sudo bash infra/bootstrap/bootstrap-ubuntu-24.04.sh` from a verified copy
of this branch on the VPS. The script installs Docker Engine/Compose and Git,
sets Docker log rotation, and creates root-owned paths. It refuses other OS
versions and does not change SSH, UFW, sudoers, or user groups.
2. Install the repository checkout at `/opt/akyldash/current` and configure
`/etc/akyldash/production.env` plus `/etc/akyldash/review_secret` outside
Git. The secret file must be root-readable only.
3. Review DNS and certificate issuance, choose and configure the reverse proxy,
then ask the owner to open only the required ports after confirming current
firewall rules.
4. Create and verify the first backup before starting services. Load and
validate the OpenSearch index, create the initial admin interactively, and
verify `/review` over HTTPS.
The remaining steps are intentionally manual until a restricted deploy-helper,
registry permissions, a live Gitea runner, and the backup path are tested.
## CI
`.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 Actions is enabled at repository level, but a
successful workflow run has not yet confirmed that a compatible runner is
online.
+56
View File
@@ -0,0 +1,56 @@
# Infrastructure
## Intended layout
Production is planned for the dedicated Ubuntu VPS at `206.223.249.118`.
`deploy/production/compose.yaml` defines a Python Search API and private
OpenSearch 3.7 node. The API binds to loopback port `8080`; OpenSearch is only
exposed to the Compose network. The host reverse proxy should terminate HTTPS
for `akyldash.kg` and proxy to the API. Cloudflare should use Full (strict) only
after the origin certificate and DNS path are verified.
The API stores its account and review SQLite files beside the normalized corpus
under the persistent data directory. OpenSearch uses a named volume. The API's
review signing key is mounted from a host file; it is not passed in process
arguments or committed to Git. Docker logs are bounded to 10 MiB per file and
three files per service.
## Actual state
The 2026-10-11 read-only audit found a fresh Ubuntu 24.04.5 host with SSH only.
Docker, Git, a reverse proxy, application directories, Compose services, TLS,
and backups are not installed/configured. The `codex` account has no
non-interactive sudo. See [CURRENT_STATE.md](CURRENT_STATE.md) and the
[production inventory](../../infra/inventory/production.yml).
The Compose file is a repository baseline, not proof of deployment. It still
builds images locally; immutable registry images and automated deploy are not
configured. No migration framework exists. The application uses SQLite and
OpenSearch; SMTP registration/recovery is not implemented.
## Repository paths and target host paths
- Compose and Dockerfiles: `deploy/production/`.
- Host checkout target: `/opt/akyldash/current`.
- Environment configuration: `/etc/akyldash/production.env`.
- Secret file: `/etc/akyldash/review_secret`, mode `0600`.
- Application data: `/var/lib/akyldash/data`.
- Intended backups: `/var/backups/akyldash`.
All host paths above are targets; none existed at audit time. Root bootstrap is
prepared at [bootstrap-ubuntu-24.04.sh](../../infra/bootstrap/bootstrap-ubuntu-24.04.sh).
It does not edit SSH or firewall configuration, create credentials, deploy the
application, or grant `codex` Docker/root access.
## Network and operations
Target public TCP listeners are SSH `22` and HTTPS proxy `80`/`443`. Backend
`8080` must remain loopback-only and OpenSearch `9200` private to Compose. The
firewall state could not be independently verified because the SSH account has
no sudo; do not change it without owner approval. DNS, HTTPS, Cloudflare origin
mode, and production health have not been verified.
Use [DEPLOYMENT.md](DEPLOYMENT.md), [BACKUP.md](BACKUP.md),
[ROLLBACK.md](ROLLBACK.md), and [RECOVERY.md](RECOVERY.md) for operational
limits and procedures. Configuration changes must go through Git and review;
do not reconcile a Git/server difference by overwriting the VPS automatically.
+33
View File
@@ -0,0 +1,33 @@
# Recovery on a clean VPS
## Scope and safety
The bootstrap below prepares Ubuntu packages and directories only. It is not a
complete recovery: production secrets, data, reverse proxy, TLS, registry
credentials, backup restore, and deploy-helper still require owner-controlled
setup. Run only on the new recovery host after verifying its identity. Never
run recovery commands against the existing production by accident.
## Procedure
1. Provision a clean Ubuntu Server 24.04 host and confirm hostname, IP, console
access, and a tested owner recovery path.
2. Create the SSH-authorized `codex` account without sudo or Docker group access.
Preserve the host's existing root SSH and firewall policy until an owner has
reviewed the change.
3. Check out the approved Git revision and run
`sudo bash infra/bootstrap/bootstrap-ubuntu-24.04.sh`. It installs Docker
Engine, Compose, Git, Docker log limits, and target directories.
4. Install the reviewed reverse proxy and its HTTPS configuration after DNS
points to the recovery host. Verify Cloudflare Full (strict) end to end.
5. Provide production configuration and secret files through the owner's
protected channel; never commit or paste secret values into chat.
6. Restore the latest verified backup to the new host. Validate SQLite
databases, corpus checksums, and the OpenSearch snapshot/index before serving
traffic.
7. Start the approved image digest, verify container health and
`https://akyldash.kg/review`, then record actual versions and checks in
[CURRENT_STATE.md](CURRENT_STATE.md).
Do not repoint DNS or retire the old host until the owner verifies recovery and
approves the cutover. The restore and cutover have not been practiced yet.
+17
View File
@@ -0,0 +1,17 @@
# Rollback
## Current status
There is no production release, pinned image digest, backup, or deploy-helper
to roll back. Automatic rollback is not configured.
After deployment automation is reviewed, rollback may switch the backend to a
previous immutable image digest only after health checks fail and only when the
data format remains compatible. This application currently has no formal
migration framework, but its SQLite files and OpenSearch indexes are persistent;
compatibility must be checked for each change. If data compatibility is
uncertain, stop and report rather than restoring or deleting production data.
Any data restore requires the owner, a verified pre-change backup, and a
maintenance window. Preserve the failed release and its backup until recovery
is confirmed.
+2 -2
View File
@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.11.2`
- Backend: `0.11.3`
- Frontend: `0.1.2` — поиск, лаборатория релевантности, аккаунты
## Краткий итог
@@ -281,4 +281,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
+1 -1
View File
@@ -262,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.1
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
+1 -1
View File
@@ -44,4 +44,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.2 · Frontend v0.1.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "Run this script as root." >&2
exit 1
fi
. /etc/os-release
if [[ ${ID:-} != ubuntu || ${VERSION_ID:-} != 24.04 ]]; then
echo "Expected Ubuntu 24.04; found ${PRETTY_NAME:-unknown}." >&2
exit 1
fi
if ! id codex >/dev/null 2>&1; then
echo "Create the SSH-authorized codex account before running bootstrap." >&2
exit 1
fi
if [[ -e /etc/docker/daemon.json ]] && ! python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("log-driver") == "local" and d.get("log-opts",{}).get("max-size") == "10m" and d.get("log-opts",{}).get("max-file") == "3"' /etc/docker/daemon.json; then
echo "/etc/docker/daemon.json exists with different log settings; review it manually." >&2
exit 1
fi
install -d -m 0755 /etc/docker
if [[ ! -e /etc/docker/daemon.json ]]; then
cat > /etc/docker/daemon.json <<'EOF'
{
"log-driver": "local",
"log-opts": {"max-size": "10m", "max-file": "3"}
}
EOF
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl git
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
"$(dpkg --print-architecture)" "$VERSION_CODENAME" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
install -d -o root -g root -m 0755 /opt/akyldash
install -d -o root -g root -m 0700 /etc/akyldash
install -d -o root -g root -m 0750 /var/lib/akyldash/data
install -d -o root -g root -m 0700 /var/backups/akyldash
systemctl enable --now docker
docker compose version
echo "Bootstrap complete. codex was not added to sudo or the docker group; SSH and firewall settings were not changed."
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ${1:-} != --remote ]]; then
ssh_alias=${1:-akyldash_vps}
exec ssh -F "${HOME}/.ssh/config" -o BatchMode=yes "$ssh_alias" 'sudo -n bash -s -- --remote' < "$0"
fi
if [[ ${EUID} -ne 0 ]]; then
echo "Run the remote check as root; it is read-only." >&2
exit 1
fi
failed=0
for package in git docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; do
if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -qx 'install ok installed'; then
printf 'PASS package %s\n' "$package"
else
printf 'FAIL package %s missing\n' "$package"
failed=1
fi
done
if systemctl is-active --quiet docker; then
echo 'PASS Docker service active'
else
echo 'FAIL Docker service inactive'
failed=1
fi
if docker compose version >/dev/null 2>&1; then
echo 'PASS Docker Compose responds'
else
echo 'FAIL Docker Compose unavailable'
failed=1
fi
for path in /opt/akyldash /etc/akyldash /var/lib/akyldash/data /var/backups/akyldash; do
if [[ -d $path ]]; then
printf 'PASS directory %s\n' "$path"
else
printf 'FAIL directory %s missing\n' "$path"
failed=1
fi
done
for path in /etc/akyldash /var/backups/akyldash; do
if [[ -d $path && $(stat -c %a "$path") == 700 ]]; then
printf 'PASS permissions %s mode 0700\n' "$path"
elif [[ -d $path ]]; then
printf 'FAIL permissions %s mode %s, expected 0700\n' "$path" "$(stat -c %a "$path")"
failed=1
fi
done
if ufw status | grep -q '^Status: active$'; then
echo 'PASS UFW active'
if ! ufw status verbose | grep -q '^Default: deny (incoming)'; then
echo 'FAIL UFW default incoming policy is not deny'
failed=1
fi
while read -r rule action direction; do
[[ $action == ALLOW && $direction == IN ]] || continue
ports=${rule//\/tcp/}
ports=${ports//\/udp/}
IFS=, read -ra allowed <<< "$ports"
for port in "${allowed[@]}"; do
case "$port" in
22|80|443) ;;
*) printf 'FAIL unexpected UFW allow rule %s\n' "$rule"; failed=1 ;;
esac
done
done < <(ufw status | awk 'NR > 3 && NF >= 3 {print $1, $2, $3}')
else
echo 'FAIL UFW inactive or unavailable'
failed=1
fi
tcp_listeners=$(ss -H -lnt | awk '{print $4}')
for endpoint in $tcp_listeners; do
address=${endpoint%:*}
port=${endpoint##*:}
case "$address" in 127.*|::1|\[::1\]) continue ;; esac
case "$port" in 22|80|443) ;; *) printf 'FAIL unexpected non-loopback TCP listener %s\n' "$endpoint"; failed=1 ;; esac
done
printf 'INFO non-loopback TCP listeners: %s\n' "${tcp_listeners//$'\n'/,}"
udp_listeners=$(ss -H -lnu | awk '{print $5}')
for endpoint in $udp_listeners; do
address=${endpoint%:*}
case "$address" in 127.*|::1|\[::1\]) continue ;; esac
printf 'FAIL unexpected non-loopback UDP listener %s\n' "$endpoint"
failed=1
done
if [[ -x /usr/local/sbin/akyldash-deploy ]]; then
echo 'PASS restricted deploy helper present'
else
echo 'FAIL restricted deploy helper missing'
failed=1
fi
if [[ -f /opt/akyldash/deployed-image.txt ]]; then
expected=$(cat /opt/akyldash/deployed-image.txt)
container=$(docker ps -aq --filter label=com.docker.compose.project=akyldash --filter label=com.docker.compose.service=backend | head -n1)
actual=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null || true)
if [[ $expected == *@sha256:* && $actual == "$expected" ]]; then
printf 'PASS running backend image matches %s\n' "$expected"
else
echo 'FAIL running backend image does not match recorded digest'
failed=1
fi
else
echo 'FAIL deployed image digest record missing'
failed=1
fi
exit "$failed"
+72
View File
@@ -0,0 +1,72 @@
project: akyldash
environment: production
ssh:
alias: akyldash_vps
host: 206.223.249.118
port: 22
user: codex
identity_file: ~/.ssh/akyldash_codex
domain: akyldash.kg
facts:
hostname: avprogs.hlab.kz
os: Ubuntu Server 24.04.5 LTS
kernel: 6.8.0-142-generic
cpu_vcpus: 4
memory_gib: 7.7
swap_gib: 3.8
disk_gib: 99
disk_free_gib: 93
network_interface: ens3
ipv4: 206.223.249.118/32
user_groups: [codex, users]
sudo_noninteractive: false
installed:
- OpenSSH server
- systemd-resolved
- unattended-upgrades
absent:
- Docker Engine
- Docker Compose plugin
- Git
- reverse proxy
access_audit_date: 2026-10-11
target:
compose_file_in_repository: deploy/production/compose.yaml
compose_file_on_host: /opt/akyldash/current/deploy/production/compose.yaml
production_env_file: /etc/akyldash/production.env
secrets_directory: /etc/akyldash
data_directory: /var/lib/akyldash/data
backup_directory: /var/backups/akyldash
services:
backend:
container: compose project service backend
image: built from deploy/production/Dockerfile.backend; digest deployment not configured
host_bind: 127.0.0.1:8080
healthcheck: HTTP GET /review on container port 8080
persistent_data: /var/lib/akyldash/data (bind mount)
opensearch:
container: compose project service opensearch
image: opensearchproject/opensearch:3.7.0 plus analysis-icu plugin
port: 9200 on private Compose network only
persistent_data: named Compose volume opensearch-data
reverse_proxy:
target: Caddy or another reviewed HTTPS proxy on TCP 80/443
status: not installed or configured
external_ports_target: [22, 80, 443]
compose_network: default private Compose network
secrets:
- REVIEW_SECRET (host file mounted as /run/secrets/review_secret)
- Gitea credentials from environment-backed gitea-api helper
- SSH key at ~/.ssh/akyldash_codex
checks:
- infra/bootstrap/verify.sh akyldash_vps
- docker compose -f deploy/production/compose.yaml ps
- curl -fsS https://akyldash.kg/review
backup_path: /var/backups/akyldash (target; mechanism not configured)
versions:
application:
backend: 0.11.3
frontend: 0.1.2
telegram_bot: 0.2.2
infrastructure: unknown; no production install or release exists
docker_engine: not installed