Skip to content

Commit 1734ef7

Browse files
authored
Merge pull request #784: isolate DEV migrations with owner credentials
ci: isolate DEV migrations with owner credentials
2 parents 7596455 + f737deb commit 1734ef7

9 files changed

Lines changed: 1533 additions & 9 deletions

‎.github/workflows/dev-ci.yml‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,43 @@ on:
66
- dev
77
workflow_dispatch:
88

9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: api-deploy-dev
14+
cancel-in-progress: false
15+
916
jobs:
1017
deploy:
1118
name: Deploy
19+
if: github.ref == 'refs/heads/dev'
20+
environment: dev
1221
runs-on: ubuntu-latest
1322
steps:
14-
- name: Deploy to server
15-
uses: garygrossgarten/github-action-ssh@release
23+
- uses: actions/checkout@v4
24+
- name: Prepare isolated DEV migration credentials
25+
id: migration_credentials
26+
env:
27+
DEV_DATABASE_NAME: ${{ secrets.DEV_DATABASE_NAME }}
28+
DEV_DATABASE_USER: ${{ secrets.DEV_DATABASE_USER }}
29+
DEV_MIGRATION_DATABASE_USER: ${{ secrets.DEV_MIGRATION_DATABASE_USER }}
30+
DEV_MIGRATION_DATABASE_PASSWORD: ${{ secrets.DEV_MIGRATION_DATABASE_PASSWORD }}
31+
run: python3 scripts/dev_ci_migrate.py --prepare-payload
32+
- uses: actions/setup-node@v4
1633
with:
17-
host: ${{ secrets.DEV_SERVER_HOST }}
18-
username: ${{ secrets.DEV_SERVER_USER }}
19-
password: ${{ secrets.DEV_SERVER_PASSWORD }}
20-
command: |
34+
node-version: "24"
35+
- name: Install DEV SSH transport
36+
run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0
37+
- name: Deploy to server
38+
env:
39+
NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules
40+
DEV_SERVER_HOST: ${{ secrets.DEV_SERVER_HOST }}
41+
DEV_SERVER_USER: ${{ secrets.DEV_SERVER_USER }}
42+
DEV_SERVER_PASSWORD: ${{ secrets.DEV_SERVER_PASSWORD }}
43+
DEV_SSH_HOST_KEY_SHA256: ${{ vars.DEV_SSH_HOST_KEY_SHA256 }}
44+
DEV_DEPLOY_SCRIPT: |
45+
set +x
2146
set -eu
2247
exec 2>&1
2348
@@ -32,7 +57,6 @@ jobs:
3257
git status --short --untracked-files=no >&2
3358
exit 1
3459
fi
35-
3660
expected_serializer_blob="$(git rev-parse HEAD:vacancy/serializers.py)"
3761
actual_serializer_blob="$(git hash-object vacancy/serializers.py)"
3862
if [ "$actual_serializer_blob" != "$expected_serializer_blob" ]; then
@@ -164,8 +188,12 @@ jobs:
164188
raise SystemExit("Compose run detail serializer uses stale image")
165189
PY
166190
167-
docker compose -f docker-compose.dev-ci.yml run --rm -T web \
168-
python manage.py migrate --noinput < /dev/null
191+
docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps \
192+
--entrypoint python web scripts/dev_ci_migrate.py <<'DEV_MIGRATION_PAYLOAD'
193+
${{ steps.migration_credentials.outputs.payload }}
194+
DEV_MIGRATION_PAYLOAD
195+
docker compose -f docker-compose.dev-ci.yml run --rm -T --no-deps web \
196+
python manage.py migrate --check < /dev/null
169197
docker compose -f docker-compose.dev-ci.yml run --rm -T web \
170198
python manage.py showmigrations vacancy < /dev/null
171199
@@ -322,3 +350,4 @@ jobs:
322350
docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true
323351
exit 1
324352
fi
353+
run: node scripts/dev_ci_ssh.cjs
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: DEV migration role CI
2+
on:
3+
pull_request:
4+
branches: [dev]
5+
paths:
6+
- .github/workflows/dev-ci.yml
7+
- .github/workflows/dev-migration-role-ci.yml
8+
- scripts/dev_ci_migrate.py
9+
- scripts/dev_ci_prepare_owner.py
10+
- scripts/dev_ci_ssh.cjs
11+
- scripts/tests/test_dev_ci_migrate.py
12+
- scripts/tests/test_dev_ci_ssh.cjs
13+
- docs/operations/dev-migration-role-university-owner.sql
14+
workflow_dispatch:
15+
permissions:
16+
contents: read
17+
jobs:
18+
role-tests:
19+
name: DEV migration role / PostgreSQL ${{ matrix.postgres }}
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 10
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
postgres: ["14", "18"]
26+
services:
27+
postgres:
28+
image: postgres:${{ matrix.postgres }}-alpine
29+
env:
30+
POSTGRES_DB: postgres
31+
POSTGRES_USER: dev_ci_test_admin
32+
POSTGRES_PASSWORD: ci-only-disposable-password
33+
ports: ["5432:5432"]
34+
options: >-
35+
--health-cmd "pg_isready -U dev_ci_test_admin -d postgres"
36+
--health-interval 5s --health-timeout 5s --health-retries 10
37+
env:
38+
RUN_DEV_MIGRATION_PG_TESTS: "1"
39+
MIGRATION_TEST_PORT: "5432"
40+
MIGRATION_TEST_ADMIN_USER: dev_ci_test_admin
41+
MIGRATION_TEST_ADMIN_PASSWORD: ci-only-disposable-password
42+
PYTHONIOENCODING: utf-8
43+
DEV_DATABASE_NAME: procollab_db_dev
44+
DEV_DATABASE_USER: procollab_api_dev
45+
DEV_MIGRATION_DATABASE_USER: procollab_api
46+
DEV_MIGRATION_DATABASE_PASSWORD: |
47+
ci-only 'quoted' "$()" `literal`
48+
second-line
49+
steps:
50+
- uses: actions/checkout@v4
51+
- uses: actions/setup-node@v4
52+
with:
53+
node-version: "24"
54+
- name: Install DEV SSH transport
55+
run: npm install --prefix "$RUNNER_TEMP/dev-migration-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0
56+
- name: Verify SSH stdin transport and host pinning
57+
env:
58+
NODE_PATH: ${{ runner.temp }}/dev-migration-ssh/node_modules
59+
run: node --test scripts/tests/test_dev_ci_ssh.cjs
60+
- uses: actions/setup-python@v5
61+
with:
62+
python-version: "3.11"
63+
- run: python -m pip install Django==4.2.11 psycopg2-binary==2.9.9
64+
- name: Prepare masked transport fixture
65+
id: credentials_fixture
66+
run: python scripts/dev_ci_migrate.py --prepare-payload
67+
- name: Verify same-job masked output transport
68+
env:
69+
TRANSPORT_PAYLOAD: ${{ steps.credentials_fixture.outputs.payload }}
70+
run: |
71+
python - <<'PY'
72+
import importlib.util
73+
import os
74+
spec = importlib.util.spec_from_file_location("migration", "scripts/dev_ci_migrate.py")
75+
helper = importlib.util.module_from_spec(spec)
76+
spec.loader.exec_module(helper)
77+
credentials = helper.decode_payload(os.environ["TRANSPORT_PAYLOAD"])
78+
assert credentials["password"] == os.environ["DEV_MIGRATION_DATABASE_PASSWORD"]
79+
assert credentials["username"] == os.environ["DEV_MIGRATION_DATABASE_USER"]
80+
PY
81+
- run: python -m unittest discover -s scripts/tests -p 'test_dev_ci_migrate.py' -v
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# DEV migration connection в CI
2+
3+
## Задача и границы
4+
5+
Deploy Dev применял миграции под runtime login из `.env`, что приводило к
6+
`must be owner of table`. Теперь отдельные credentials используются только внутри
7+
одноразового migration container. Web/Celery остаются под `procollab_api_dev`.
8+
API, UI, модели, numbered migrations, PROD workflow и production secrets не меняются.
9+
10+
Под migration-role здесь понимается существующий владелец `procollab_api` в отдельном
11+
CI-подключении. Новый PostgreSQL login не создаётся. Одноразовая подготовка ниже
12+
использует временное членство DEV role → migration role с последующим отзывом.
13+
CI жёстко ограничивает database name и runtime user; secrets PROD workflow не читаются.
14+
15+
## Согласованная схема DEV / PROD, 18.10.2026
16+
17+
Пользователь выбрал `procollab_api` для миграций обоих контуров, сохранив
18+
существующие подключения web/Celery. [Read-only preflight обоих контуров](https://github.com/PROCOLLAB-github/api/actions/runs/39072746180)
19+
проверил actual connection identity, ownership и role flags в READ ONLY transaction:
20+
21+
| Контур | Фактическая БД | Web/Celery сейчас | Миграции сейчас | Целевая роль миграций |
22+
| --- | --- | --- | --- | --- |
23+
| DEV | `procollab_db_dev` | `procollab_api_dev` | `procollab_api_dev` | `procollab_api` |
24+
| PROD | `procollab_db_test` | `procollab_api` | `procollab_api` | `procollab_api` — уже используется |
25+
26+
Название `procollab_db_test` здесь относится к фактическому deployed PROD:
27+
контур установлен по серверному checkout и работающему production image, а не по имени БД.
28+
Одинаковый `pg_control_system().system_identifier` подтвердил общий PostgreSQL-кластер.
29+
Следовательно, `procollab_api` — одна существующая cluster-wide роль, а не два
30+
независимых login с одинаковым названием. Её пароль не ротируется в рамках задачи.
31+
32+
В PROD все 107 tables / 100 sequences принадлежат `procollab_api`; не принадлежащих
33+
ей relations нет. Для выбранной роли изменения PROD workflow, ownership и grants
34+
не требуются. Существующий PROD runtime тоже использует эту роль; разделение
35+
runtime/migration прав в PROD не заявляется и в scope не входит.
36+
37+
В DEV остаётся одноразовая подготовка university table/sequence, описанная ниже.
38+
Секрет `DEV_MIGRATION_DATABASE_PASSWORD` добавлен пользователем; наличие подтверждено
39+
по metadata, значение не читалось и live owner authentication пока не проверена.
40+
41+
## Фактическое состояние до активации, 17.10.2026
42+
43+
[Read-only inspection](https://github.com/PROCOLLAB-github/api/actions/runs/39059401759):
44+
45+
- Runtime image: `c78078c26acb6585867b0b5c1a9be93abd76d45b-5ac944d0794f`.
46+
- PostgreSQL 14.24; database `procollab_db_dev`; runtime `procollab_api_dev`.
47+
- 95 tables / 89 sequences принадлежат `procollab_api`.
48+
- `users_university` / `users_university_id_seq` принадлежат runtime login.
49+
- Оба login не имеют SUPERUSER / CREATEDB / CREATEROLE / REPLICATION / BYPASSRLS.
50+
- У владельца уже есть default ACL для runtime на новые tables/sequences/functions.
51+
- Отдельных DEV migration secrets до задачи не было.
52+
53+
## Проверяемые требования
54+
55+
| AC | Наблюдаемый результат |
56+
| --- | --- |
57+
| M1 | Только refs/heads/dev; database строго procollab_db_dev |
58+
| M2 | Missing secret отклоняется до SSH, без fallback на application/production secrets |
59+
| M3 | Migration credentials не записываются в .env, CLI argv, web/Celery environment |
60+
| M4 | Неадминистративная migration-role может изменять существующие DEV relations |
61+
| M5 | Новые tables/sequences доступны runtime для штатных CRUD/sequence операций |
62+
| M6 | Mixed ownership отклоняется до DDL/default-privilege изменений |
63+
| M7 | После migrate обычное подключение проходит migrate --check |
64+
| M8 | Конкурирующие DEV deploy выполняются последовательно; PROD не затронут |
65+
66+
## CI secrets и последовательность
67+
68+
GitHub Environment `dev`, deployment branch policy — только branch `dev`, без
69+
manual reviewers. В нём используются отдельные secrets:
70+
71+
- `DEV_MIGRATION_DATABASE_USER`: `procollab_api`.
72+
- `DEV_MIGRATION_DATABASE_PASSWORD`: актуальный пароль этого login из Selectel.
73+
74+
Environment variable `DEV_SSH_HOST_KEY_SHA256` содержит публичный ED25519 fingerprint
75+
DEV-сервера, проверенный отдельной read-only inspection. Это не пароль. При ротации
76+
host key сначала независимо проверяется новый публичный ключ, затем обновляется
77+
эта переменная; отключать host verification нельзя.
78+
79+
Пароль добавляется владельцем через GitHub Environment secrets; в чат, issue, PR
80+
или repository file его передавать нельзя. GitHub API не позволяет прочитать
81+
ранее сохранённое значение секрета. Обычные DEV_DATABASE_* остаются без изменения.
82+
83+
Перед SSH runner проверяет credentials и создаёт base64 JSON, регистрирует его
84+
через [add-mask](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#masking-a-value-in-a-log)
85+
и передаёт как step output в stdin helper. Base64 обеспечивает только безопасный
86+
transport, не шифрование. В payload нет host/database overrides.
87+
88+
`scripts/dev_ci_ssh.cjs` использует pinned `ssh2@1.16.0`, проверяет host fingerprint
89+
и запускает только фиксированный `bash -se`. Весь deploy script передаётся через
90+
SSH stdin: secret-bearing script не попадает в argv удалённого shell и не
91+
записывается во временный файл. Старый SSH action заменён только в DEV workflow.
92+
93+
Helper проверяет фактическую identity, schema CREATE/USAGE, отсутствие admin flags,
94+
ownership tables/sequences и текущие runtime grants внутри READ ONLY transaction.
95+
Затем настраивает additive default privileges, выполняет migrate, проверяет отсутствие
96+
pending migrations и доступ runtime к relations. Отдельный container выполняет
97+
`manage.py migrate --check` с обычной `.env` до перезапуска web/Celery.
98+
99+
## Одноразовая подготовка ownership
100+
101+
До merge/активации нужно выполнить [dev-migration-role-university-owner.sql](dev-migration-role-university-owner.sql)
102+
только в `procollab_db_dev`. Транзакция переносит ровно university table и проверяет
103+
её owned sequence, сохраняя runtime privileges и данные. Это не повторяемый шаг CI.
104+
105+
На фактической PostgreSQL 14 отдельный администратор не обязателен:
106+
[GRANT PostgreSQL 14](https://www.postgresql.org/docs/14/sql-grant.html) разрешает
107+
session_user выдавать членство в собственной роли. Это отдельно проверено на
108+
[disposable PostgreSQL 14.24](https://github.com/PROCOLLAB-github/api/actions/runs/39075480886).
109+
Поведение PostgreSQL 18 отличается; helper отказывается применять эту процедуру к другим major versions.
110+
111+
`scripts/dev_ci_prepare_owner.py` использует два существующих login:
112+
113+
1. Спрашивает только скрытый пароль `procollab_api`; runtime credentials берёт из environment контейнера.
114+
2. Подключается к DEV под `procollab_api_dev` и `procollab_api`, проверяет обе identity и PostgreSQL 14.
115+
3. При необходимости временно делает `GRANT procollab_api_dev TO procollab_api`.
116+
4. Под `procollab_api` выполняет проверенный SQL смены владельца и runtime grants.
117+
5. В `finally` отзывает только добавленное членство; существующее членство не удаляет.
118+
119+
Направление grant — DEV role → migration role. Runtime DEV не становится членом
120+
PROD owner-role. Членство действует на кластер, поэтому временно расширяются права
121+
общей `procollab_api` на DEV; PROD objects и grants не меняются. Advisory lock не
122+
позволяет двум экземплярам helper конкурировать. Ошибки SQL откатывают незавершённую
123+
транзакцию перед отзывом членства; SIGINT/SIGTERM/SIGHUP выполняют cleanup.
124+
При принудительном убийстве процесса или недоступности БД cleanup нельзя гарантировать:
125+
перед продолжением проверить членство и при необходимости под `procollab_api_dev`
126+
выполнить `REVOKE procollab_api_dev FROM procollab_api`.
127+
128+
Для самого SQL нужны права фактического владельца и возможность передать ownership целевой роли;
129+
server root или обычного GRANT ALL недостаточно для этого
130+
([PostgreSQL 14 ALTER TABLE](https://www.postgresql.org/docs/14/sql-altertable.html)).
131+
При недостатке прав или неожиданной identity транзакция откатится целиком.
132+
Mass REASSIGN OWNED не используется: он затрагивает также shared database/tablespace
133+
objects ([PostgreSQL](https://www.postgresql.org/docs/16/role-removal.html)).
134+
135+
Нельзя мержить fail-closed workflow до добавления пароля и нормализации ownership:
136+
иначе следующий DEV deploy ожидаемо остановится. Старая версия приложения при
137+
ошибке preflight остаётся в работающих контейнерах.
138+
139+
## Проверка
140+
141+
`python -m unittest discover -s scripts/tests -p test_dev_ci_migrate.py -v` запускает
142+
credentials tests. Реальные PostgreSQL tests требуют disposable instance и
143+
`RUN_DEV_MIGRATION_PG_TESTS=1`; они отказываются заменять существующую тестовую БД
144+
или роли. Матрица GitHub CI покрывает PostgreSQL 14 (версия DEV) и 18.
145+
146+
`node --test scripts/tests/test_dev_ci_ssh.cjs` с установленным `ssh2@1.16.0`
147+
проверяет реальное loopback SSH соединение: stdin вместо argv, отказ чужому host
148+
key до authentication, missing config, controlled remote failure и timeout.
149+
150+
Проверяются исходный ownership failure, успешные ALTER/CREATE migrations, runtime
151+
CRUD/sequence на новой таблице, сохранность `.env`, idempotency, READ ONLY check,
152+
mixed ownership fail-before-write, отказ admin credentials, special characters
153+
в пароле и отсутствие пароля/traceback в сообщении об ошибке.
154+
155+
Фактические результаты и ограничения активации фиксируются в PR description.
156+
Без настоящих migration credentials live DDL проверка не объявляется выполненной.

0 commit comments

Comments
 (0)