Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,43 @@ on:
- dev
workflow_dispatch:

permissions:
contents: read

concurrency:
group: api-deploy-dev
cancel-in-progress: false

jobs:
deploy:
name: Deploy
if: github.ref == 'refs/heads/dev'
environment: dev
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: garygrossgarten/github-action-ssh@release
- uses: actions/checkout@v4
- name: Prepare isolated DEV migration credentials
id: migration_credentials
env:
DEV_DATABASE_NAME: ${{ secrets.DEV_DATABASE_NAME }}
DEV_DATABASE_USER: ${{ secrets.DEV_DATABASE_USER }}
DEV_MIGRATION_DATABASE_USER: ${{ secrets.DEV_MIGRATION_DATABASE_USER }}
DEV_MIGRATION_DATABASE_PASSWORD: ${{ secrets.DEV_MIGRATION_DATABASE_PASSWORD }}
run: python3 scripts/dev_ci_migrate.py --prepare-payload
- uses: actions/setup-node@v4
with:
host: ${{ secrets.DEV_SERVER_HOST }}
username: ${{ secrets.DEV_SERVER_USER }}
password: ${{ secrets.DEV_SERVER_PASSWORD }}
command: |
node-version: "24"
- name: Install DEV SSH transport
run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0
- name: Deploy to server
env:
NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules
DEV_SERVER_HOST: ${{ secrets.DEV_SERVER_HOST }}
DEV_SERVER_USER: ${{ secrets.DEV_SERVER_USER }}
DEV_SERVER_PASSWORD: ${{ secrets.DEV_SERVER_PASSWORD }}
DEV_SSH_HOST_KEY_SHA256: ${{ vars.DEV_SSH_HOST_KEY_SHA256 }}
DEV_DEPLOY_SCRIPT: |
set +x
set -eu
exec 2>&1

Expand All @@ -32,7 +57,6 @@ jobs:
git status --short --untracked-files=no >&2
exit 1
fi

expected_serializer_blob="$(git rev-parse HEAD:vacancy/serializers.py)"
actual_serializer_blob="$(git hash-object vacancy/serializers.py)"
if [ "$actual_serializer_blob" != "$expected_serializer_blob" ]; then
Expand Down Expand Up @@ -164,8 +188,12 @@ jobs:
raise SystemExit("Compose run detail serializer uses stale image")
PY

docker compose -f docker-compose.dev-ci.yml run --rm -T web \
python manage.py migrate --noinput < /dev/null
docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps \
--entrypoint python web scripts/dev_ci_migrate.py <<'DEV_MIGRATION_PAYLOAD'
${{ steps.migration_credentials.outputs.payload }}
DEV_MIGRATION_PAYLOAD
docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps web \
python manage.py migrate --check < /dev/null
docker compose -f docker-compose.dev-ci.yml run --rm -T web \
python manage.py showmigrations vacancy < /dev/null

Expand Down Expand Up @@ -322,3 +350,4 @@ jobs:
docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true
exit 1
fi
run: node scripts/dev_ci_ssh.cjs
81 changes: 81 additions & 0 deletions .github/workflows/dev-migration-role-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: DEV migration role CI
on:
pull_request:
branches: [dev]
paths:
- .github/workflows/dev-ci.yml
- .github/workflows/dev-migration-role-ci.yml
- scripts/dev_ci_migrate.py
- scripts/dev_ci_prepare_owner.py
- scripts/dev_ci_ssh.cjs
- scripts/tests/test_dev_ci_migrate.py
- scripts/tests/test_dev_ci_ssh.cjs
- docs/operations/dev-migration-role-university-owner.sql
workflow_dispatch:
permissions:
contents: read
jobs:
role-tests:
name: DEV migration role / PostgreSQL ${{ matrix.postgres }}
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
postgres: ["14", "18"]
services:
postgres:
image: postgres:${{ matrix.postgres }}-alpine
env:
POSTGRES_DB: postgres
POSTGRES_USER: dev_ci_test_admin
POSTGRES_PASSWORD: ci-only-disposable-password
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U dev_ci_test_admin -d postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
RUN_DEV_MIGRATION_PG_TESTS: "1"
MIGRATION_TEST_PORT: "5432"
MIGRATION_TEST_ADMIN_USER: dev_ci_test_admin
MIGRATION_TEST_ADMIN_PASSWORD: ci-only-disposable-password
PYTHONIOENCODING: utf-8
DEV_DATABASE_NAME: procollab_db_dev
DEV_DATABASE_USER: procollab_api_dev
DEV_MIGRATION_DATABASE_USER: procollab_api
DEV_MIGRATION_DATABASE_PASSWORD: |
ci-only 'quoted' "$()" `literal`
second-line
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install DEV SSH transport
run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0
- name: Verify SSH stdin transport and host pinning
env:
NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules
run: node --test scripts/tests/test_dev_ci_ssh.cjs
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install Django==4.2.11 psycopg2-binary==2.9.9
- name: Prepare masked transport fixture
id: credentials_fixture
run: python scripts/dev_ci_migrate.py --prepare-payload
- name: Verify same-job masked output transport
env:
TRANSPORT_PAYLOAD: ${{ steps.credentials_fixture.outputs.payload }}
run: |
python - <<'PY'
import importlib.util
import os
spec = importlib.util.spec_from_file_location("migration", "scripts/dev_ci_migrate.py")
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
credentials = helper.decode_payload(os.environ["TRANSPORT_PAYLOAD"])
assert credentials["password"] == os.environ["DEV_MIGRATION_DATABASE_PASSWORD"]
assert credentials["username"] == os.environ["DEV_MIGRATION_DATABASE_USER"]
PY
- run: python -m unittest discover -s scripts/tests -p 'test_dev_ci_migrate.py' -v
156 changes: 156 additions & 0 deletions docs/operations/dev-ci-migration-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# DEV migration connection в CI

## Задача и границы

Deploy Dev применял миграции под runtime login из `.env`, что приводило к
`must be owner of table`. Теперь отдельные credentials используются только внутри
одноразового migration container. Web/Celery остаются под `procollab_api_dev`.
API, UI, модели, numbered migrations, PROD workflow и production secrets не меняются.

Под migration-role здесь понимается существующий владелец `procollab_api` в отдельном
CI-подключении. Новый PostgreSQL login не создаётся. Одноразовая подготовка ниже
использует временное членство DEV role → migration role с последующим отзывом.
CI жёстко ограничивает database name и runtime user; secrets PROD workflow не читаются.

## Согласованная схема DEV / PROD, 18.10.2026

Пользователь выбрал `procollab_api` для миграций обоих контуров, сохранив
существующие подключения web/Celery. [Read-only preflight обоих контуров](https://github.com/PROCOLLAB-github/api/actions/runs/39072746180)
проверил actual connection identity, ownership и role flags в READ ONLY transaction:

| Контур | Фактическая БД | Web/Celery сейчас | Миграции сейчас | Целевая роль миграций |
| --- | --- | --- | --- | --- |
| DEV | `procollab_db_dev` | `procollab_api_dev` | `procollab_api_dev` | `procollab_api` |
| PROD | `procollab_db_test` | `procollab_api` | `procollab_api` | `procollab_api` — уже используется |

Название `procollab_db_test` здесь относится к фактическому deployed PROD:
контур установлен по серверному checkout и работающему production image, а не по имени БД.
Одинаковый `pg_control_system().system_identifier` подтвердил общий PostgreSQL-кластер.
Следовательно, `procollab_api` — одна существующая cluster-wide роль, а не два
независимых login с одинаковым названием. Её пароль не ротируется в рамках задачи.

В PROD все 107 tables / 100 sequences принадлежат `procollab_api`; не принадлежащих
ей relations нет. Для выбранной роли изменения PROD workflow, ownership и grants
не требуются. Существующий PROD runtime тоже использует эту роль; разделение
runtime/migration прав в PROD не заявляется и в scope не входит.

В DEV остаётся одноразовая подготовка university table/sequence, описанная ниже.
Секрет `DEV_MIGRATION_DATABASE_PASSWORD` добавлен пользователем; наличие подтверждено
по metadata, значение не читалось и live owner authentication пока не проверена.

## Фактическое состояние до активации, 17.10.2026

[Read-only inspection](https://github.com/PROCOLLAB-github/api/actions/runs/39059401759):

- Runtime image: `c78078c26acb6585867b0b5c1a9be93abd76d45b-5ac944d0794f`.
- PostgreSQL 14.24; database `procollab_db_dev`; runtime `procollab_api_dev`.
- 95 tables / 89 sequences принадлежат `procollab_api`.
- `users_university` / `users_university_id_seq` принадлежат runtime login.
- Оба login не имеют SUPERUSER / CREATEDB / CREATEROLE / REPLICATION / BYPASSRLS.
- У владельца уже есть default ACL для runtime на новые tables/sequences/functions.
- Отдельных DEV migration secrets до задачи не было.

## Проверяемые требования

| AC | Наблюдаемый результат |
| --- | --- |
| M1 | Только refs/heads/dev; database строго procollab_db_dev |
| M2 | Missing secret отклоняется до SSH, без fallback на application/production secrets |
| M3 | Migration credentials не записываются в .env, CLI argv, web/Celery environment |
| M4 | Неадминистративная migration-role может изменять существующие DEV relations |
| M5 | Новые tables/sequences доступны runtime для штатных CRUD/sequence операций |
| M6 | Mixed ownership отклоняется до DDL/default-privilege изменений |
| M7 | После migrate обычное подключение проходит migrate --check |
| M8 | Конкурирующие DEV deploy выполняются последовательно; PROD не затронут |

## CI secrets и последовательность

GitHub Environment `dev`, deployment branch policy — только branch `dev`, без
manual reviewers. В нём используются отдельные secrets:

- `DEV_MIGRATION_DATABASE_USER`: `procollab_api`.
- `DEV_MIGRATION_DATABASE_PASSWORD`: актуальный пароль этого login из Selectel.

Environment variable `DEV_SSH_HOST_KEY_SHA256` содержит публичный ED25519 fingerprint
DEV-сервера, проверенный отдельной read-only inspection. Это не пароль. При ротации
host key сначала независимо проверяется новый публичный ключ, затем обновляется
эта переменная; отключать host verification нельзя.

Пароль добавляется владельцем через GitHub Environment secrets; в чат, issue, PR
или repository file его передавать нельзя. GitHub API не позволяет прочитать
ранее сохранённое значение секрета. Обычные DEV_DATABASE_* остаются без изменения.

Перед SSH runner проверяет credentials и создаёт base64 JSON, регистрирует его
через [add-mask](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#masking-a-value-in-a-log)
и передаёт как step output в stdin helper. Base64 обеспечивает только безопасный
transport, не шифрование. В payload нет host/database overrides.

`scripts/dev_ci_ssh.cjs` использует pinned `ssh2@1.16.0`, проверяет host fingerprint
и запускает только фиксированный `bash -se`. Весь deploy script передаётся через
SSH stdin: secret-bearing script не попадает в argv удалённого shell и не
записывается во временный файл. Старый SSH action заменён только в DEV workflow.

Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags,
ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction.
Затем настраивает additive default privileges, выполняет migrate, проверяет отсутствие
pending migrations и доступ runtime к relations. Отдельный container выполняет
`manage.py migrate --check` с обычной `.env` до перезапуска web/Celery.

## Одноразовая подготовка ownership

До merge/активации нужно выполнить [dev-migration-role-university-owner.sql](dev-migration-role-university-owner.sql)
только в `procollab_db_dev`. Транзакция переносит ровно university table и проверяет
её owned sequence, сохраняя runtime privileges и данные. Это не повторяемый шаг CI.

На фактической PostgreSQL 14 отдельный администратор не обязателен:
[GRANT PostgreSQL 14](https://www.postgresql.org/docs/14/sql-grant.html) разрешает
session_user выдавать членство в собственной роли. Это отдельно проверено на
[disposable PostgreSQL 14.24](https://github.com/PROCOLLAB-github/api/actions/runs/39075480886).
Поведение PostgreSQL 18 отличается; helper отказывается применять эту процедуру к другим major versions.

`scripts/dev_ci_prepare_owner.py` использует два существующих login:

1. Спрашивает только скрытый пароль `procollab_api`; runtime credentials берёт из environment контейнера.
2. Подключается к DEV под `procollab_api_dev` и `procollab_api`, проверяет обе identity и PostgreSQL 14.
3. При необходимости временно делает `GRANT procollab_api_dev TO procollab_api`.
4. Под `procollab_api` выполняет проверенный SQL смены владельца и runtime grants.
5. В `finally` отзывает только добавленное членство; существующее членство не удаляет.

Направление grant — DEV role → migration role. Runtime DEV не становится членом
PROD owner-role. Членство действует на кластер, поэтому временно расширяются права
общей `procollab_api` на DEV; PROD objects и grants не меняются. Advisory lock не
позволяет двум экземплярам helper конкурировать. Ошибки SQL откатывают незавершённую
транзакцию перед отзывом членства; SIGINT/SIGTERM/SIGHUP выполняют cleanup.
При принудительном убийстве процесса или недоступности БД cleanup нельзя гарантировать:
перед продолжением проверить членство и при необходимости под `procollab_api_dev`
выполнить `REVOKE procollab_api_dev FROM procollab_api`.

Для самого SQL нужны права фактического владельца и возможность передать ownership целевой роли;
server root или обычного GRANT ALL недостаточно для этого
([PostgreSQL 14 ALTER TABLE](https://www.postgresql.org/docs/14/sql-altertable.html)).
При недостатке прав или неожиданной identity транзакция откатится целиком.
Mass REASSIGN OWNED не используется: он затрагивает также shared database/tablespace
objects ([PostgreSQL](https://www.postgresql.org/docs/16/role-removal.html)).

Нельзя мержить fail-closed workflow до добавления пароля и нормализации ownership:
иначе следующий DEV deploy ожидаемо остановится. Старая версия приложения при
ошибке preflight остаётся в работающих контейнерах.

## Проверка

`python -m unittest discover -s scripts/tests -p test_dev_ci_migrate.py -v` запускает
credentials tests. Реальные PostgreSQL tests требуют disposable instance и
`RUN_DEV_MIGRATION_PG_TESTS=1`; они отказываются заменять существующую тестовую БД
или роли. Матрица GitHub CI покрывает PostgreSQL 14 (версия DEV) и 18.

`node --test scripts/tests/test_dev_ci_ssh.cjs` с установленным `ssh2@1.16.0`
проверяет реальное loopback SSH соединение: stdin вместо argv, отказ чужому host
key до authentication, missing config, controlled remote failure и timeout.

Проверяются исходный ownership failure, успешные ALTER/CREATE migrations, runtime
CRUD/sequence на новой таблице, сохранность `.env`, idempotency, READ ONLY check,
mixed ownership fail-before-write, отказ admin credentials, special characters
в пароле и отсутствие пароля/traceback в сообщении об ошибке.

Фактические результаты и ограничения активации фиксируются в PR description.
Без настоящих migration credentials live DDL проверка не объявляется выполненной.
Loading
Loading