25 KiB
Разовый импорт публикаций
Инструмент: 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-проекту; владелец проверяет их локально перед выполнением.
Сначала резервная копия в приватный каталог владельца:
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 владельцем БД:
docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers
Выполните 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:
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 расположен рядом с каталогом репозитория:
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:
miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
visudo -cf /etc/sudoers.d/miem-publication-import
В /home/miem-pub-import/.ssh/authorized_keys добавьте только публичный ключ
владельца с ограничениями (замените <PUBLIC_KEY> самим публичным ключом):
restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
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 сохраните действующую сессию владельца.
С клиентской машины параметры хоста/ключа берутся из окружения владельца:
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 и отдельного решения владельца:
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 временному ключу:
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.
mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
rm /etc/sudoers.d/miem-publication-import
В psql владельцем:
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 удалите только временные секреты подключения:
docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf
Эти действия не используют DROP OWNED и не удаляют публикации. SQL роли не
является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте
оставшиеся grants; не применяйте CASCADE/удаление данных.