313 lines
25 KiB
Markdown
313 lines
25 KiB
Markdown
# Разовый импорт публикаций
|
||
|
||
Инструмент: `scripts/import_publications.py`. Он не импортирует приложение, не читает
|
||
`.env`, не вызывает `init_db`, не создаёт таблиц и не удаляет записи. Подключение
|
||
берётся только из libpq environment/service, настроенных владельцем. Production в
|
||
рамках подготовки не проверялся и не изменялся.
|
||
|
||
Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты,
|
||
неоднозначности, конфликты DOI/года, сохранение заполненных значений и
|
||
транзакционный путь на тестовом соединении (read-only / rollback при второй
|
||
ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка
|
||
grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.
|
||
|
||
## Вход и сопоставление
|
||
|
||
Файл владельца `Статьи.csv`: UTF-8 с возможным BOM, разделитель `;`, 1797 записей,
|
||
SHA256 `1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce`.
|
||
Обязательные колонки: `Название публикации`, `Год`, `DOI`, `Авторы НИУ ВШЭ`.
|
||
Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.
|
||
|
||
Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е.
|
||
Учитываются все существующие статусы; новые сотрудники не создаются. Ноль
|
||
совпадений → `employee_not_found`, несколько → `ambiguous_employee`. Инициалы,
|
||
переводы, перестановки слов и приблизительные совпадения не используются.
|
||
|
||
Публикация сопоставляется **внутри конкретного сотрудника** по нормализованному DOI
|
||
или полному названию и году. Отсутствующий год БД допускает заполнение при
|
||
совпадении названия. Если эти способы указывают на разные записи, импорт
|
||
пропускается (`ambiguous_publication`). Разные заполненные DOI или годы —
|
||
`doi_conflict` / `year_conflict`. Существующие дубликаты не объединяются и не
|
||
удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские
|
||
связи, а не дубли внутри карточки.
|
||
|
||
Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет
|
||
ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе
|
||
ранее сохранённое значение БД. Повтор без изменений — `csv_duplicate`, дополнение
|
||
запланированной вставки — `merged_csv_duplicate`. При конфликте непустых
|
||
метаданных первое значение сохраняется; остальные пустые поля всё равно
|
||
дополняются. Отчёт содержит `metadata_conflict_first_value_kept`, список
|
||
`conflict_fields` (только названия полей, без значений) и сводный счётчик
|
||
`metadata_conflicts`. Конфликт DOI/года идентификации по-прежнему пропускает
|
||
всю связь. Если строка без DOI была запланирована первой, следующая строка с
|
||
DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают
|
||
`already_present`. Хеш новой записи детерминирован по сотруднику и DOI либо
|
||
названию/году. `source_hash` существующих записей не меняется.
|
||
|
||
Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год
|
||
(допустимо 1000–9999) или DOI пропускаются как `invalid_fields`. Повреждённая
|
||
структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД
|
||
пропускается (`unsupported_raw_data`, `unsupported_csv_metadata`).
|
||
|
||
## Таблицы, столбцы и права
|
||
|
||
| Объект | Операция / столбцы |
|
||
|---|---|
|
||
| `public.employees` | Только SELECT: `id`, `full_name` |
|
||
| `public.employee_publications` | SELECT: `id`, `employee_id`, `title`, `year`, `doi_url`, `raw_data` |
|
||
| Новая публикация | INSERT: `employee_id`, `title`, `year`, `doi_url` при наличии, `raw_data`, `source_hash`, `created_at`, `updated_at` |
|
||
| Существующая публикация | UPDATE: только пустые `title`, `year`, `doi_url`; `raw_data`; служебный `updated_at` |
|
||
| Sequence идентификатора публикации | USAGE для `nextval`; имя определяется `pg_get_serial_sequence`, обычно `public.employee_publications_id_seq` |
|
||
|
||
Остальные таблицы и sequences не затрагиваются. В `raw_data` сохраняются прежние
|
||
ключи; добавляется/дополняется `csv_enrichment` только отсутствующими или пустыми
|
||
значениями. Уже заполненные CSV-метаданные не заменяются. Поля `authors`,
|
||
`publication_id`, тип материала, язык, аннотация, ссылки HSE и статус не меняются.
|
||
CSV содержит **только авторов НИУ ВШЭ**, поэтому он не считается полным составом
|
||
авторов и не заменяет `authors`. Идентификаторы Scopus/WoS, квартиль и остальные
|
||
непустые исходные ячейки сохраняются в `raw_data.csv_enrichment` без интерпретации.
|
||
Карточки `employees.current_data` и snapshots не изменяются; отчёт и данные
|
||
таблицы не означают обновления всех представлений интерфейса.
|
||
|
||
Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public,
|
||
перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence.
|
||
Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим
|
||
таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях.
|
||
Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их;
|
||
этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при
|
||
уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.
|
||
|
||
## Применение и ограничения
|
||
|
||
Без `--apply` используется PostgreSQL `SERIALIZABLE READ ONLY`, без INSERT,
|
||
UPDATE и nextval, с rollback. С `--apply` весь план применяется одной транзакцией
|
||
SERIALIZABLE. Advisory lock предотвращает одновременное применение этим
|
||
импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд.
|
||
Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических
|
||
повторов нет. Sequence может получить пропуски при откате — это нормальное
|
||
поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.
|
||
|
||
JSON-отчёт содержит количества insert/update/skip, причины и пары
|
||
`номер CSV-записи — employee_id`, без ФИО, названий статей и секретов. Счётчики
|
||
относятся к авторским связям, а не уникальным статьям. Консоль выводит только
|
||
сводку. Отчёт сначала пишется в `.pending` до commit, после успеха переименовывается.
|
||
Если остался `.pending`, следующий запуск блокируется: сначала проверьте исход
|
||
транзакции. Отчёт `.pending` не доказывает commit; ошибка переименования после
|
||
commit также возможна. Безопасный повторный dry-run позволяет проверить состояние.
|
||
При необходимости владелец перемещает `.pending` в архив перед повтором.
|
||
|
||
**До применения нужен свежий backup БД.** Во время импорта владелец должен
|
||
исключить конкурентные записи публикаций на время транзакции, согласовав окно
|
||
работы; эта подготовка не отключает crawler. Не выполняйте production apply до
|
||
независимого ревью и развёртывания исправления crawler из этого PR.
|
||
|
||
До исправления `_upsert_employee` запускал синхронизацию публикаций даже для
|
||
неизменённой карточки. При пустом списке публикаций или отсутствии таблицы
|
||
синхронизация пропускалась. При непустом списке `_sync_employee_publications`
|
||
удалял все записи сотрудника, чей `source_hash` отсутствовал среди хешей ответа
|
||
ВШЭ, включая CSV-вставки. Найденные по `publication_id`/хешу записи полностью
|
||
получали новый `raw_data`, поэтому теряли CSV-метаданные даже без удаления.
|
||
|
||
Исправление сохраняет записи с dict `raw_data.csv_enrichment` при очистке
|
||
устаревших публикаций. При обновлении сохраняется этот ключ и заполненные
|
||
title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ
|
||
продолжают обновлять канонические поля, а исходные CSV-значения остаются в
|
||
метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с
|
||
импортированной записью по DOI или названию/году до новой вставки, сохраняет id
|
||
и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint
|
||
синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает
|
||
обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь
|
||
не приведены команды рестарта или отключения сервисов.
|
||
|
||
Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID,
|
||
даже когда DOI/название/год совпадают. Это проверяется перед изменением полей
|
||
при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи
|
||
не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается
|
||
независимо от порядка статей. Отсутствующий ID во входе не очищает существующую
|
||
CSV-привязку. Отчёт обхода (`errors` в деталях запуска) содержит CrawlError с
|
||
идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.
|
||
|
||
## Команды владельцу (не выполнялись)
|
||
|
||
Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере.
|
||
Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны
|
||
быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать
|
||
в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту;
|
||
владелец проверяет их локально перед выполнением.
|
||
|
||
Сначала резервная копия в приватный каталог владельца:
|
||
|
||
```sh
|
||
install -d -m 700 /var/backups/miem-publication-import
|
||
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
|
||
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
|
||
chmod 600 /var/backups/miem-publication-import/before.dump
|
||
```
|
||
|
||
При повторной операции используйте новый путь backup; не перезаписывайте нужную
|
||
предыдущую копию. Проверьте exit codes и архив `pg_restore --list` до выдачи write.
|
||
|
||
Откройте psql владельцем БД:
|
||
|
||
```sh
|
||
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
|
||
```
|
||
|
||
Выполните SQL (без пароля в SQL/истории):
|
||
|
||
```sql
|
||
\set ON_ERROR_STOP on
|
||
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
|
||
\password miem_publication_import
|
||
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
|
||
GRANT USAGE ON SCHEMA public TO miem_publication_import;
|
||
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
|
||
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
|
||
-- Сначала выдаются только read-права для dry-run.
|
||
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
|
||
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
|
||
has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
|
||
has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;
|
||
```
|
||
|
||
Три проверки должны вернуть false. Не выдавайте write-права при неожиданном
|
||
доступе. Если sequence не найдена, выясните фактический механизм id перед импортом.
|
||
Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и
|
||
завершение открытых сессий.
|
||
|
||
После принятого dry-run, непосредственно перед разрешённым apply:
|
||
|
||
```sql
|
||
GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
|
||
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
|
||
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||
```
|
||
|
||
Подготовьте внутри API-контейнера защищённые файлы libpq `pg_service.conf` и
|
||
`pgpass` из окружения/секрет-хранилища владельца: service
|
||
`miem_publication_import`, БД `miem_workers`, пользователь
|
||
`miem_publication_import`. Используйте внутренний PostgreSQL endpoint Compose,
|
||
порт 5432; не публикуйте порт БД. Поле password хранится только в `pgpass`,
|
||
доступном UID 65534 с mode 600. Не копируйте `.env` приложения. Этот шаг
|
||
зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.
|
||
|
||
Установите файлы (команды root; каталог контейнера не должен быть volume с
|
||
непроверенными файлами). `Статьи.csv` расположен рядом с каталогом репозитория:
|
||
|
||
```sh
|
||
docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
|
||
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
|
||
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
|
||
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
|
||
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
|
||
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
|
||
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
|
||
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
|
||
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
|
||
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
|
||
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
|
||
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
|
||
useradd --create-home --shell /bin/sh miem-pub-import
|
||
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh
|
||
```
|
||
|
||
Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR.
|
||
Секретные файлы должны существовать перед командами chown/chmod. Root-владелец
|
||
каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать
|
||
только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу
|
||
docker или общий sudo. Каталоги и executables выше должны быть root-owned и
|
||
недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker
|
||
на сервере — `/usr/bin/docker`.
|
||
|
||
Создайте `/etc/sudoers.d/miem-publication-import` с mode 440:
|
||
|
||
```sudoers
|
||
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
|
||
```
|
||
|
||
```sh
|
||
visudo -cf /etc/sudoers.d/miem-publication-import
|
||
```
|
||
|
||
В `/home/miem-pub-import/.ssh/authorized_keys` добавьте **только публичный** ключ
|
||
владельца с ограничениями (замените `<PUBLIC_KEY>` самим публичным ключом):
|
||
|
||
```text
|
||
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
|
||
```
|
||
|
||
```sh
|
||
chown root:root /home/miem-pub-import/.ssh/authorized_keys
|
||
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
|
||
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
|
||
chmod 755 /home/miem-pub-import
|
||
chmod 755 /home/miem-pub-import/.ssh
|
||
```
|
||
|
||
Root-владение home/.ssh предотвращает замену ограниченного authorized_keys.
|
||
Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль,
|
||
keyboard-interactive, пользовательские environment/rc или общий shell.
|
||
При необходимости задайте в sshd `Match User miem-pub-import` с
|
||
`AuthenticationMethods publickey`, `PasswordAuthentication no`,
|
||
`KbdInteractiveAuthentication no`, `DisableForwarding yes`, `PermitTTY no`,
|
||
`PermitUserRC no` и указанным ForceCommand. Проверка/reload SSH зависит от
|
||
конфигурации сервера; перед reload сохраните действующую сессию владельца.
|
||
|
||
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
|
||
|
||
```sh
|
||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||
```
|
||
|
||
После проверки сводки, выдачи write-grants и отдельного решения владельца:
|
||
|
||
```sh
|
||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
|
||
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'
|
||
```
|
||
|
||
Повторный dry-run должен показать ноль insert/update. При concurrent changes
|
||
сводки могут различаться; без production-проверки фактические количества неизвестны.
|
||
Владелец забирает отчёты без предоставления SCP/general shell временному ключу:
|
||
|
||
```sh
|
||
docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
|
||
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json
|
||
```
|
||
|
||
## Отзыв доступа
|
||
|
||
Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой
|
||
закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.
|
||
|
||
```sh
|
||
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
|
||
rm /etc/sudoers.d/miem-publication-import
|
||
```
|
||
|
||
В psql владельцем:
|
||
|
||
```sql
|
||
ALTER ROLE miem_publication_import NOLOGIN;
|
||
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
|
||
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
|
||
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
|
||
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
|
||
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
|
||
INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
|
||
UPDATE (title, year, doi_url, raw_data, updated_at)
|
||
ON public.employee_publications FROM miem_publication_import;
|
||
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
|
||
DROP ROLE miem_publication_import;
|
||
```
|
||
|
||
После сохранения отчётов и backup удалите только временные секреты подключения:
|
||
|
||
```sh
|
||
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
|
||
```
|
||
|
||
Эти действия не используют `DROP OWNED` и не удаляют публикации. SQL роли не
|
||
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
|
||
оставшиеся grants; не применяйте CASCADE/удаление данных.
|