From 323d677f5303834dd1d0e671a5525d79e038b2a1 Mon Sep 17 00:00:00 2001 From: Aleksandr Yurchenko Date: Fri, 17 Jul 2026 15:08:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(samples):=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20World=20=D0=BD=D0=B0=20Chinook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mysql-check.yml | 4 +- .github/workflows/postgres-check.yml | 25 +- .github/workflows/sql-lab-check.yml | 5 + Makefile | 152 +++++++-- README.md | 136 +++++--- adminer/plugins-enabled/002-login-help.php | 4 +- initdb/mysql/050_load_optional_samples.sh | 86 ++++- initdb/mysql/099_check_training_access.sh | 51 ++- initdb/postgres/050_load_optional_samples.sh | 312 +++++++++++++------ initdb/postgres/099_check_training_access.sh | 226 ++++++++++---- 10 files changed, 761 insertions(+), 240 deletions(-) diff --git a/.github/workflows/mysql-check.yml b/.github/workflows/mysql-check.yml index ac53c9f..3415f12 100644 --- a/.github/workflows/mysql-check.yml +++ b/.github/workflows/mysql-check.yml @@ -49,10 +49,10 @@ jobs: databases="$( docker compose --env-file .docker.env -p "${COMPOSE_PROJECT_NAME}" \ exec -T mysql sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --user=root --batch --skip-column-names --execute="SELECT GROUP_CONCAT(SCHEMA_NAME ORDER BY SCHEMA_NAME) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME IN (\"demo\", \"world\", \"sakila\");"' \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql --user=root --batch --skip-column-names --execute="SELECT GROUP_CONCAT(SCHEMA_NAME ORDER BY SCHEMA_NAME) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME IN (\"demo\", \"sakila\", \"chinook\");"' \ | tr -d '\r' )" - test "${databases}" = "demo,sakila,world" + test "${databases}" = "chinook,demo,sakila" - name: Check MySQL training access run: make check-mysql-access diff --git a/.github/workflows/postgres-check.yml b/.github/workflows/postgres-check.yml index 91cbec8..0035e10 100644 --- a/.github/workflows/postgres-check.yml +++ b/.github/workflows/postgres-check.yml @@ -33,7 +33,7 @@ jobs: - name: Validate Compose configuration run: make config - - name: Prepare PostgreSQL sample + - name: Prepare PostgreSQL samples run: make samples-postgres - name: Start only PostgreSQL @@ -47,12 +47,18 @@ jobs: set +a databases="$( - docker compose --env-file .docker.env -p "${COMPOSE_PROJECT_NAME}" \ - exec -T postgres sh -c \ - 'PGPASSWORD="$POSTGRES_PASSWORD" psql --host=127.0.0.1 --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" --tuples-only --no-align --command="SELECT string_agg(datname, chr(44) ORDER BY datname) FROM pg_catalog.pg_database WHERE datname IN (current_database(), chr(112) || chr(97) || chr(103) || chr(105) || chr(108) || chr(97));"' \ - | tr -d '\r' + PGPASSWORD="${POSTGRES_SUPERUSER_PASSWORD}" \ + docker compose --env-file .docker.env -p "${COMPOSE_PROJECT_NAME}" \ + exec -T -e PGPASSWORD postgres \ + psql --host=127.0.0.1 --username="${POSTGRES_SUPERUSER}" \ + --dbname="${POSTGRES_DATABASE}" --tuples-only --no-align \ + <<'SQL' | tr -d '\r' + SELECT string_agg(datname, ',' ORDER BY datname) + FROM pg_catalog.pg_database + WHERE datname IN (current_database(), 'pagila', 'chinook'); + SQL )" - test "${databases}" = "demo,pagila" + test "${databases}" = "chinook,demo,pagila" - name: Check PostgreSQL training access run: make check-postgres-access @@ -78,7 +84,7 @@ jobs: ;; esac - - name: Verify Pagila loader idempotency + - name: Verify Pagila and Chinook loader idempotency run: | set -Eeuo pipefail set -a @@ -90,8 +96,9 @@ jobs: exec -T postgres /docker-entrypoint-initdb.d/050_load_optional_samples.sh )" echo "${loader_output}" - grep -Fq 'already complete' <<< "${loader_output}" - grep -Fq 'skipping reload' <<< "${loader_output}" + grep -Fq 'Optional PostgreSQL sample Pagila is already complete' <<< "${loader_output}" + grep -Fq 'Optional PostgreSQL sample Chinook is already complete' <<< "${loader_output}" + test "$(grep -Fc 'skipping reload.' <<< "${loader_output}")" = 2 - name: Restart only PostgreSQL run: | diff --git a/.github/workflows/sql-lab-check.yml b/.github/workflows/sql-lab-check.yml index 6e8ea00..93f17ef 100644 --- a/.github/workflows/sql-lab-check.yml +++ b/.github/workflows/sql-lab-check.yml @@ -81,6 +81,11 @@ jobs: grep -F 'DB_USER' "${html_file}" grep -F 'DB_PASSWORD' "${html_file}" grep -F 'demo' "${html_file}" + grep -F 'chinook' "${html_file}" + if grep -Fi 'world' "${html_file}"; then + echo 'Adminer exposes World as an available training database' >&2 + exit 1 + fi if grep -E "value=['\"]db['\"]" "${html_file}"; then echo 'Adminer exposes invalid server db' >&2 exit 1 diff --git a/Makefile b/Makefile index dc75434..26e27c4 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,19 @@ LOAD_ENV = set -a; source "$(ENV_FILE)"; set +a; COMPOSE = docker compose --env-file "$(ENV_FILE)" -p "$$(awk -F= '$$1 == "COMPOSE_PROJECT_NAME" { print substr($$0, index($$0, "=") + 1); exit }' "$(ENV_FILE)")" COMPOSE_UI = $(COMPOSE) --profile ui -SAMPLES_TMP_DIR := .tmp/mysql-samples -WORLD_URL := https://downloads.mysql.com/docs/world-db.zip +MYSQL_SAMPLES_TMP_DIR := .tmp/mysql-samples SAKILA_URL := https://downloads.mysql.com/docs/sakila-db.zip POSTGRES_SAMPLES_TMP_DIR := .tmp/postgres-samples PAGILA_REF := 5ba5a57aeb159f75f02aca2432d3c262186d13d3 PAGILA_BASE_URL := https://raw.githubusercontent.com/devrimgunduz/pagila/$(PAGILA_REF) +CHINOOK_REF := 4a944a942426e1f3263fe539155fb7ef92b04b4a +CHINOOK_BASE_URL := https://raw.githubusercontent.com/lerocha/chinook-database/$(CHINOOK_REF) +CHINOOK_LICENSE_URL := $(CHINOOK_BASE_URL)/LICENSE.md +CHINOOK_MYSQL_URL := $(CHINOOK_BASE_URL)/ChinookDatabase/DataSources/Chinook_MySql.sql +CHINOOK_POSTGRES_URL := $(CHINOOK_BASE_URL)/ChinookDatabase/DataSources/Chinook_PostgreSql.sql +CHINOOK_LICENSE_BLOB := 7487a9edc2d42e50d7a38ab1fbdba33ac63230f7 +CHINOOK_MYSQL_BLOB := cdbd482f1be7fde54644480ec7c794ff2764b109 +CHINOOK_POSTGRES_BLOB := d93a20d08239ac6bdd8a56601e148f5d4d048593 # Поддерживаются оба варианта: `make log mysql` (исторический интерфейс) и # `make log SERVICE=mysql`. Второй positional goal становится no-op, чтобы он @@ -64,8 +71,8 @@ help: @echo " make up-postgres[-ui] запустить только PostgreSQL, опционально с UI" @echo " make up-ui / make down-ui включить / остановить только Adminer" @echo " make check проверить Compose и доступ DB_USER к обеим СУБД" - @echo " make samples-mysql скачать optional samples World и Sakila" - @echo " make samples-postgres скачать optional sample Pagila" + @echo " make samples-mysql скачать optional samples Chinook и Sakila" + @echo " make samples-postgres скачать optional samples Pagila и Chinook" @echo " make clean-{mysql,postgres,all} CONFIRM=1" @echo " make reinit-{mysql,postgres,all} CONFIRM=1" @@ -247,31 +254,96 @@ sh: check-env samples-mysql: check-env @command -v curl >/dev/null || { echo "ERROR: требуется curl" >&2; exit 1; } @command -v unzip >/dev/null || { echo "ERROR: требуется unzip" >&2; exit 1; } + @command -v git >/dev/null || { echo "ERROR: требуется git для проверки Git blob SHA" >&2; exit 1; } @set -Eeuo pipefail; $(LOAD_ENV) \ - cleanup() { rm -rf "$(SAMPLES_TMP_DIR)"; }; \ + tmp_root="$(MYSQL_SAMPLES_TMP_DIR)"; \ + download_dir="$${tmp_root}/download"; \ + ready_dir="$${tmp_root}/ready"; \ + previous_dir="$${tmp_root}/previous"; \ + target_dir="$${MYSQL_SAMPLES_DIR}"; \ + cleanup() { \ + if [[ -d "$${previous_dir}" && ! -e "$${target_dir}" ]]; then \ + mv "$${previous_dir}" "$${target_dir}"; \ + fi; \ + rm -rf "$${tmp_root}"; \ + }; \ trap cleanup EXIT; \ - cleanup; \ - mkdir -p "$(SAMPLES_TMP_DIR)/world" "$(SAMPLES_TMP_DIR)/sakila" "$${MYSQL_SAMPLES_DIR}"; \ - echo "Скачиваем официальные MySQL samples World и Sakila..."; \ - curl -fL "$(WORLD_URL)" -o "$(SAMPLES_TMP_DIR)/world-db.zip"; \ - curl -fL "$(SAKILA_URL)" -o "$(SAMPLES_TMP_DIR)/sakila-db.zip"; \ - unzip -q "$(SAMPLES_TMP_DIR)/world-db.zip" -d "$(SAMPLES_TMP_DIR)/world"; \ - unzip -q "$(SAMPLES_TMP_DIR)/sakila-db.zip" -d "$(SAMPLES_TMP_DIR)/sakila"; \ - world_source="$$(find "$(SAMPLES_TMP_DIR)/world" -type f -name world.sql -print -quit)"; \ - sakila_schema_source="$$(find "$(SAMPLES_TMP_DIR)/sakila" -type f -name sakila-schema.sql -print -quit)"; \ - sakila_data_source="$$(find "$(SAMPLES_TMP_DIR)/sakila" -type f -name sakila-data.sql -print -quit)"; \ - test -n "$$world_source" || { echo "ERROR: архив World не содержит world.sql" >&2; exit 1; }; \ + rm -rf "$${tmp_root}"; \ + mkdir -p "$${download_dir}/sakila" "$${ready_dir}"; \ + if [[ -L "$${target_dir}" ]]; then \ + echo "ERROR: MYSQL_SAMPLES_DIR не должен быть символической ссылкой: $${target_dir}" >&2; \ + exit 1; \ + fi; \ + if [[ -e "$${target_dir}" && ! -d "$${target_dir}" ]]; then \ + echo "ERROR: MYSQL_SAMPLES_DIR должен быть каталогом: $${target_dir}" >&2; \ + exit 1; \ + fi; \ + if [[ -d "$${target_dir}" ]] && find "$${target_dir}" -mindepth 1 -maxdepth 1 \ + ! -name .gitkeep ! -name 010_world.sql ! -name 010_chinook.sql \ + ! -name 020_sakila_schema.sql ! -name 021_sakila_data.sql -print -quit | grep -q .; then \ + echo "ERROR: $${target_dir} содержит посторонние файлы; безопасная замена отменена" >&2; \ + exit 1; \ + fi; \ + echo "Скачиваем Chinook из lerocha/chinook-database@$(CHINOOK_REF) и официальный Sakila..."; \ + curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ + "$(CHINOOK_LICENSE_URL)" -o "$${download_dir}/LICENSE.md"; \ + curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ + "$(CHINOOK_MYSQL_URL)" -o "$${download_dir}/Chinook_MySql.sql"; \ + curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ + "$(SAKILA_URL)" -o "$${download_dir}/sakila-db.zip"; \ + test "$$(git hash-object --no-filters "$${download_dir}/LICENSE.md")" = "$(CHINOOK_LICENSE_BLOB)" || { echo "ERROR: неожиданный Git blob SHA LICENSE.md" >&2; exit 1; }; \ + test "$$(git hash-object --no-filters "$${download_dir}/Chinook_MySql.sql")" = "$(CHINOOK_MYSQL_BLOB)" || { echo "ERROR: неожиданный Git blob SHA Chinook_MySql.sql" >&2; exit 1; }; \ + test -s "$${download_dir}/LICENSE.md" || { echo "ERROR: LICENSE.md пуст" >&2; exit 1; }; \ + test -s "$${download_dir}/Chinook_MySql.sql" || { echo "ERROR: Chinook_MySql.sql пуст" >&2; exit 1; }; \ + sed 's/\r$$//' "$${download_dir}/LICENSE.md" > "$${download_dir}/LICENSE.normalized.md"; \ + sed 's/\r$$//' "$${download_dir}/Chinook_MySql.sql" > "$${download_dir}/Chinook_MySql.normalized.sql"; \ + chinook_source="$${download_dir}/Chinook_MySql.normalized.sql"; \ + grep -Fq 'Chinook Database - Version 1.4.5' "$${chinook_source}" || { echo "ERROR: неожиданная версия Chinook MySQL" >&2; exit 1; }; \ + grep -Fq 'DB Server: MySql' "$${chinook_source}" || { echo "ERROR: неожиданный DB Server Chinook MySQL" >&2; exit 1; }; \ + for table_name in Album Artist Customer Invoice Track; do \ + grep -Fq "CREATE TABLE \`$${table_name}\`" "$${chinook_source}" || { echo "ERROR: Chinook MySQL не содержит таблицу $${table_name}" >&2; exit 1; }; \ + grep -Fq "INSERT INTO \`$${table_name}\`" "$${chinook_source}" || { echo "ERROR: Chinook MySQL не содержит данные $${table_name}" >&2; exit 1; }; \ + done; \ + for expected_line in 'DROP DATABASE IF EXISTS `Chinook`;' 'CREATE DATABASE `Chinook`;' 'USE `Chinook`;'; do \ + test "$$(grep -Fxc "$${expected_line}" "$${chinook_source}" || true)" = 1 || { echo "ERROR: неожиданный формат database-level строки: $${expected_line}" >&2; exit 1; }; \ + done; \ + test "$$(awk 'BEGIN { in_comment = 0; count = 0 } { upper = toupper($$0) } /^[[:space:]]*\/\*/ { in_comment = 1 } !in_comment && upper ~ /^[[:space:]]*((DROP|CREATE)[[:space:]]+DATABASE|USE[[:space:]])/ { count++ } /\*\// { in_comment = 0 } END { print count }' "$${chinook_source}")" = 3 || { echo "ERROR: Chinook MySQL содержит неожиданные database-level statements" >&2; exit 1; }; \ + { \ + echo '-- Chinook Database MIT license notice (upstream LICENSE.md):'; \ + sed 's/^/-- /' "$${download_dir}/LICENSE.normalized.md"; \ + echo; \ + awk '$$0 != "DROP DATABASE IF EXISTS `Chinook`;" && $$0 != "CREATE DATABASE `Chinook`;" && $$0 != "USE `Chinook`;"' "$${chinook_source}"; \ + } > "$${ready_dir}/010_chinook.sql"; \ + if awk 'BEGIN { in_comment = 0; found = 0 } { upper = toupper($$0) } /^[[:space:]]*\/\*/ { in_comment = 1 } !in_comment && upper ~ /^[[:space:]]*((DROP|CREATE)[[:space:]]+DATABASE|USE[[:space:]])/ { found = 1 } /\*\// { in_comment = 0 } END { exit(found ? 0 : 1) }' "$${ready_dir}/010_chinook.sql"; then \ + echo "ERROR: готовый Chinook MySQL содержит database-level setup" >&2; \ + exit 1; \ + fi; \ + while IFS= read -r license_line || [[ -n "$${license_line}" ]]; do \ + grep -Fxq -- "-- $${license_line}" "$${ready_dir}/010_chinook.sql" || { echo "ERROR: MIT notice перенесён не полностью" >&2; exit 1; }; \ + done < "$${download_dir}/LICENSE.normalized.md"; \ + unzip -q "$${download_dir}/sakila-db.zip" -d "$${download_dir}/sakila"; \ + sakila_schema_source="$$(find "$${download_dir}/sakila" -type f -name sakila-schema.sql -print -quit)"; \ + sakila_data_source="$$(find "$${download_dir}/sakila" -type f -name sakila-data.sql -print -quit)"; \ test -n "$$sakila_schema_source" || { echo "ERROR: архив Sakila не содержит sakila-schema.sql" >&2; exit 1; }; \ test -n "$$sakila_data_source" || { echo "ERROR: архив Sakila не содержит sakila-data.sql" >&2; exit 1; }; \ - cp "$$world_source" "$${MYSQL_SAMPLES_DIR}/010_world.sql"; \ - cp "$$sakila_schema_source" "$${MYSQL_SAMPLES_DIR}/020_sakila_schema.sql"; \ - cp "$$sakila_data_source" "$${MYSQL_SAMPLES_DIR}/021_sakila_data.sql"; \ - echo "✅ Optional samples World и Sakila подготовлены в $${MYSQL_SAMPLES_DIR}."; \ + cp "$$sakila_schema_source" "$${ready_dir}/020_sakila_schema.sql"; \ + cp "$$sakila_data_source" "$${ready_dir}/021_sakila_data.sql"; \ + if [[ -f "$${target_dir}/.gitkeep" ]]; then cp "$${target_dir}/.gitkeep" "$${ready_dir}/.gitkeep"; else touch "$${ready_dir}/.gitkeep"; fi; \ + mkdir -p "$$(dirname "$${target_dir}")"; \ + if [[ "$$(stat -c %d "$${ready_dir}")" != "$$(stat -c %d "$$(dirname "$${target_dir}")")" ]]; then \ + echo "ERROR: .tmp и MYSQL_SAMPLES_DIR должны находиться на одной файловой системе для атомарной публикации" >&2; \ + exit 1; \ + fi; \ + if [[ -d "$${target_dir}" ]]; then mv "$${target_dir}" "$${previous_dir}"; fi; \ + mv "$${ready_dir}" "$${target_dir}"; \ + rm -rf "$${previous_dir}"; \ + echo "✅ Optional samples Chinook и Sakila подготовлены в $${target_dir}."; \ echo "Они загрузятся только при следующей чистой инициализации MySQL."; \ echo "Для существующего MYSQL_DATA_DIR выполните явно: make reinit-mysql CONFIRM=1" samples-postgres: check-env @command -v curl >/dev/null || { echo "ERROR: требуется curl" >&2; exit 1; } + @command -v git >/dev/null || { echo "ERROR: требуется git для проверки Git blob SHA" >&2; exit 1; } @set -Eeuo pipefail; $(LOAD_ENV) \ tmp_root="$(POSTGRES_SAMPLES_TMP_DIR)"; \ download_dir="$${tmp_root}/download"; \ @@ -296,15 +368,19 @@ samples-postgres: check-env exit 1; \ fi; \ if [[ -d "$${target_dir}" ]] && find "$${target_dir}" -mindepth 1 -maxdepth 1 \ - ! -name 010_pagila_schema.sql ! -name 020_pagila_data.sql -print -quit | grep -q .; then \ + ! -name 010_pagila_schema.sql ! -name 020_pagila_data.sql ! -name 030_chinook.sql -print -quit | grep -q .; then \ echo "ERROR: $${target_dir} содержит посторонние файлы; безопасная замена отменена" >&2; \ exit 1; \ fi; \ - echo "Скачиваем Pagila из devrimgunduz/pagila@$(PAGILA_REF)..."; \ + echo "Скачиваем Pagila из devrimgunduz/pagila@$(PAGILA_REF) и Chinook из lerocha/chinook-database@$(CHINOOK_REF)..."; \ curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ "$(PAGILA_BASE_URL)/pagila-schema.sql" -o "$${download_dir}/pagila-schema.sql"; \ curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ "$(PAGILA_BASE_URL)/pagila-data.sql" -o "$${download_dir}/pagila-data.sql"; \ + curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ + "$(CHINOOK_LICENSE_URL)" -o "$${download_dir}/LICENSE.md"; \ + curl --fail --location --retry 3 --retry-all-errors --connect-timeout 15 --max-time 180 \ + "$(CHINOOK_POSTGRES_URL)" -o "$${download_dir}/Chinook_PostgreSql.sql"; \ test -s "$${download_dir}/pagila-schema.sql" || { echo "ERROR: pagila-schema.sql пуст" >&2; exit 1; }; \ test -s "$${download_dir}/pagila-data.sql" || { echo "ERROR: pagila-data.sql пуст" >&2; exit 1; }; \ grep -Fq 'CREATE TABLE public.actor' "$${download_dir}/pagila-schema.sql" || { echo "ERROR: в schema нет таблицы actor" >&2; exit 1; }; \ @@ -313,8 +389,38 @@ samples-postgres: check-env grep -Fq 'COPY public.actor' "$${download_dir}/pagila-data.sql" || { echo "ERROR: в data нет COPY для actor" >&2; exit 1; }; \ grep -Fq 'COPY public.rental' "$${download_dir}/pagila-data.sql" || { echo "ERROR: в data нет COPY для rental" >&2; exit 1; }; \ grep -Fxq '\.' "$${download_dir}/pagila-data.sql" || { echo "ERROR: в data нет завершителей COPY" >&2; exit 1; }; \ + test "$$(git hash-object --no-filters "$${download_dir}/LICENSE.md")" = "$(CHINOOK_LICENSE_BLOB)" || { echo "ERROR: неожиданный Git blob SHA LICENSE.md" >&2; exit 1; }; \ + test "$$(git hash-object --no-filters "$${download_dir}/Chinook_PostgreSql.sql")" = "$(CHINOOK_POSTGRES_BLOB)" || { echo "ERROR: неожиданный Git blob SHA Chinook_PostgreSql.sql" >&2; exit 1; }; \ + test -s "$${download_dir}/LICENSE.md" || { echo "ERROR: LICENSE.md пуст" >&2; exit 1; }; \ + test -s "$${download_dir}/Chinook_PostgreSql.sql" || { echo "ERROR: Chinook_PostgreSql.sql пуст" >&2; exit 1; }; \ + sed 's/\r$$//' "$${download_dir}/LICENSE.md" > "$${download_dir}/LICENSE.normalized.md"; \ + sed 's/\r$$//' "$${download_dir}/Chinook_PostgreSql.sql" > "$${download_dir}/Chinook_PostgreSql.normalized.sql"; \ + chinook_source="$${download_dir}/Chinook_PostgreSql.normalized.sql"; \ + grep -Fq 'Chinook Database - Version 1.4.5' "$${chinook_source}" || { echo "ERROR: неожиданная версия Chinook PostgreSQL" >&2; exit 1; }; \ + grep -Fq 'DB Server: PostgreSql' "$${chinook_source}" || { echo "ERROR: неожиданный DB Server Chinook PostgreSQL" >&2; exit 1; }; \ + for table_name in album artist customer invoice track; do \ + grep -Fq "CREATE TABLE $${table_name}" "$${chinook_source}" || { echo "ERROR: Chinook PostgreSQL не содержит таблицу $${table_name}" >&2; exit 1; }; \ + grep -Fq "INSERT INTO $${table_name}" "$${chinook_source}" || { echo "ERROR: Chinook PostgreSQL не содержит данные $${table_name}" >&2; exit 1; }; \ + done; \ + for expected_line in 'DROP DATABASE IF EXISTS chinook;' 'CREATE DATABASE chinook;' '\c chinook;'; do \ + test "$$(grep -Fxc "$${expected_line}" "$${chinook_source}" || true)" = 1 || { echo "ERROR: неожиданный формат database-level строки: $${expected_line}" >&2; exit 1; }; \ + done; \ + test "$$(awk 'BEGIN { in_comment = 0; count = 0 } { upper = toupper($$0) } /^[[:space:]]*\/\*/ { in_comment = 1 } !in_comment && (upper ~ /^[[:space:]]*(DROP|CREATE)[[:space:]]+DATABASE/ || upper ~ /^[[:space:]]*\\(C|CONNECT)([[:space:]]|$$)/) { count++ } /\*\// { in_comment = 0 } END { print count }' "$${chinook_source}")" = 3 || { echo "ERROR: Chinook PostgreSQL содержит неожиданные database-level statements" >&2; exit 1; }; \ cp "$${download_dir}/pagila-schema.sql" "$${ready_dir}/010_pagila_schema.sql"; \ cp "$${download_dir}/pagila-data.sql" "$${ready_dir}/020_pagila_data.sql"; \ + { \ + echo '-- Chinook Database MIT license notice (upstream LICENSE.md):'; \ + sed 's/^/-- /' "$${download_dir}/LICENSE.normalized.md"; \ + echo; \ + awk '$$0 != "DROP DATABASE IF EXISTS chinook;" && $$0 != "CREATE DATABASE chinook;" && $$0 != "\\c chinook;"' "$${chinook_source}"; \ + } > "$${ready_dir}/030_chinook.sql"; \ + if awk 'BEGIN { in_comment = 0; found = 0 } { upper = toupper($$0) } /^[[:space:]]*\/\*/ { in_comment = 1 } !in_comment && (upper ~ /^[[:space:]]*(DROP|CREATE)[[:space:]]+DATABASE/ || upper ~ /^[[:space:]]*\\(C|CONNECT)([[:space:]]|$$)/) { found = 1 } /\*\// { in_comment = 0 } END { exit(found ? 0 : 1) }' "$${ready_dir}/030_chinook.sql"; then \ + echo "ERROR: готовый Chinook PostgreSQL содержит database-level setup" >&2; \ + exit 1; \ + fi; \ + while IFS= read -r license_line || [[ -n "$${license_line}" ]]; do \ + grep -Fxq -- "-- $${license_line}" "$${ready_dir}/030_chinook.sql" || { echo "ERROR: MIT notice перенесён не полностью" >&2; exit 1; }; \ + done < "$${download_dir}/LICENSE.normalized.md"; \ mkdir -p "$$(dirname "$${target_dir}")"; \ if [[ "$$(stat -c %d "$${ready_dir}")" != "$$(stat -c %d "$$(dirname "$${target_dir}")")" ]]; then \ echo "ERROR: .tmp и POSTGRES_SAMPLES_DIR должны находиться на одной файловой системе для атомарной публикации" >&2; \ @@ -325,7 +431,7 @@ samples-postgres: check-env fi; \ mv "$${ready_dir}" "$${target_dir}"; \ rm -rf "$${previous_dir}"; \ - echo "✅ Pagila подготовлена в $${target_dir} из ревизии $(PAGILA_REF)."; \ + echo "✅ Pagila и Chinook подготовлены в $${target_dir} из закреплённых ревизий."; \ echo "Пустой POSTGRES_DATA_DIR: make up-postgres"; \ echo "Уже инициализированный POSTGRES_DATA_DIR: make reinit-postgres CONFIRM=1" diff --git a/README.md b/README.md index a3c9cf8..a4ada5a 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ Upstream Adminer уже выпускает 5.4.4, но официальный Do собирает собственный образ только ради расхождения версий. В MySQL и PostgreSQL всегда создаётся небольшая база `demo`. Для MySQL -опционально доступны World и Sakila, для PostgreSQL — Pagila, PostgreSQL-порт -Sakila. +опционально доступны Sakila и Chinook, для PostgreSQL — Pagila и та же +Chinook. Это позволяет сравнивать запросы к одинаковой учебной модели в двух +СУБД. ## Быстрый старт @@ -114,8 +115,8 @@ PostgreSQL (postgres) Страница входа содержит локальную подсказку: выбрать MySQL или PostgreSQL, использовать значения `DB_USER`/`DB_PASSWORD` из `.docker.env` и базу `demo`. -После отдельной подготовки samples доступны также World и Sakila для MySQL и -Pagila для PostgreSQL. +После отдельной подготовки samples доступны также Sakila и Chinook для MySQL, +Pagila и Chinook для PostgreSQL. Вход в MySQL: @@ -123,7 +124,7 @@ Pagila для PostgreSQL. Server: MySQL (mysql) Username: значение DB_USER Password: значение DB_PASSWORD -Database: demo, world, sakila или пустое поле +Database: demo, sakila, chinook или пустое поле ``` Вход в PostgreSQL: @@ -132,7 +133,7 @@ Database: demo, world, sakila или пустое поле Server: PostgreSQL (postgres) Username: значение DB_USER Password: значение DB_PASSWORD -Database: demo или pagila, если Pagila установлена +Database: demo, pagila или chinook, если optional sample установлен ``` Имена `mysql` и `postgres` применяются только внутри Docker-сети. @@ -150,7 +151,7 @@ Host: 127.0.0.1 Port: значение MYSQL_PORT User: значение DB_USER Password: значение DB_PASSWORD -Database: demo +Database: demo, sakila или chinook, если optional sample установлен ``` PostgreSQL: @@ -160,7 +161,7 @@ Host: 127.0.0.1 Port: значение POSTGRES_PORT User: значение DB_USER Password: значение DB_PASSWORD -Database: demo или pagila, если Pagila установлена +Database: demo, pagila или chinook, если optional sample установлен ``` CLI внутри контейнеров не требует размещать пароль в shell history: @@ -177,14 +178,15 @@ make postgres-user MySQL: - `demo` — обязательная база с таблицей `demo.demo_users`; -- `world` — опциональная официальная учебная база; -- `sakila` — опциональная официальная учебная база. +- `sakila` — опциональная официальная учебная база; +- `chinook` — опциональная база с музыкальным каталогом и продажами. PostgreSQL: -- `demo` — обязательная база с таблицей `public.demo_users`. +- `demo` — обязательная база с таблицей `public.demo_users`; - `pagila` — опциональный PostgreSQL-порт Sakila с фильмами, актёрами, - клиентами и прокатом. + клиентами и прокатом; +- `chinook` — та же модель музыкального каталога и продаж, что в MySQL. Обе таблицы `demo_users` имеют одинаковую смысловую структуру: @@ -215,7 +217,7 @@ MySQL использует `TIMESTAMP`, PostgreSQL — `timestamptz`. В обе ## Optional samples MySQL -Скачать официальные архивы World и Sakila и подготовить локальные SQL-файлы: +Скачать Chinook и официальный архив Sakila и подготовить локальные SQL-файлы: ```bash make samples-mysql @@ -239,22 +241,25 @@ MySQL выполняет init-файлы только при первом зап `MYSQL_DATA_DIR`. Загрузка samples не изменяет данные и не перезапускает контейнеры автоматически. -Файлы сохраняются детерминированно: +`make samples-mysql` больше не скачивает World. Файлы сохраняются +детерминированно: ```text samples/mysql/ -├── 010_world.sql +├── .gitkeep +├── 010_chinook.sql ├── 020_sakila_schema.sql └── 021_sakila_data.sql ``` Эти загруженные SQL-файлы считаются локально сгенерированными и исключены из Git. `initdb/mysql/050_load_optional_samples.sh` пропускает отсутствующие -samples и прекращает init с ошибкой при неполной паре schema/data Sakila. +samples, безопасно пропускает уже полную `chinook` и прекращает init с ошибкой +при неполной/неожиданной `chinook` или неполной паре schema/data Sakila. -## Optional sample PostgreSQL: Pagila +## Optional samples PostgreSQL: Pagila и Chinook -Подготовить Pagila отдельной явной командой: +Подготовить Pagila и Chinook отдельной явной командой: ```bash make samples-postgres @@ -279,10 +284,11 @@ make samples-postgres make reinit-postgres CONFIRM=1 ``` -`make samples-postgres` только скачивает и проверяет SQL: команда не запускает -контейнеры, не удаляет данные и не выполняет reinit. Официальный PostgreSQL -entrypoint читает `/docker-entrypoint-initdb.d` лишь при инициализации пустого -`POSTGRES_DATA_DIR`, поэтому добавление файлов не меняет существующую базу. +`make samples-postgres` только скачивает и проверяет SQL обеих баз: команда не +запускает контейнеры, не удаляет данные и не выполняет reinit. Официальный +PostgreSQL entrypoint читает `/docker-entrypoint-initdb.d` лишь при +инициализации пустого `POSTGRES_DATA_DIR`, поэтому добавление файлов не меняет +существующую базу. Используется upstream [`devrimgunduz/pagila`](https://github.com/devrimgunduz/pagila), закреплённый @@ -292,22 +298,50 @@ entrypoint читает `/docker-entrypoint-initdb.d` лишь при иници альтернативный insert-файл не используется. Pagila распространяется по PostgreSQL License. -Проверенная ревизия использует схему `public`, стандартные PL/pgSQL-функции и -`COPY FROM stdin`; дополнительных extensions, пакетов или собственного Docker -image не требуется. Подготовленные файлы сохраняются детерминированно: +Проверенная ревизия Pagila использует схему `public`, стандартные +PL/pgSQL-функции и `COPY FROM stdin`; дополнительных extensions, пакетов или +собственного Docker image не требуется. Подготовленные файлы сохраняются +детерминированно: ```text samples/postgres/ ├── 010_pagila_schema.sql -└── 020_pagila_data.sql +├── 020_pagila_data.sql +└── 030_chinook.sql ``` -Оба файла локальные и исключены из Git. Отсутствие пары безопасно пропускается, -а наличие только одного файла останавливает чистую инициализацию с ошибкой. -Повторный загрузчик пропускает уже полную Pagila с ожидаемым владельцем, но не -удаляет и не исправляет автоматически неполную базу или неверное владение: -для этого требуется явный `make reinit-postgres CONFIRM=1`. Штатные команды -очистки data-каталогов не удаляют `samples/postgres`. +Все файлы локальные и исключены из Git. Отсутствие пары Pagila безопасно +пропускается, а наличие только одного файла останавливает чистую инициализацию +с ошибкой. Chinook обрабатывается независимо: она также optional. Повторный +загрузчик отдельно пропускает уже полные Pagila и Chinook с ожидаемым +владельцем, но не удаляет и не исправляет автоматически неполную базу или +неверное владение. Для этого требуется явный +`make reinit-postgres CONFIRM=1`. Штатные команды очистки data-каталогов не +удаляют `samples/postgres`. + +## Источник и безопасная подготовка Chinook + +Оба варианта Chinook берутся только из официального upstream +[`lerocha/chinook-database`](https://github.com/lerocha/chinook-database) на +immutable commit +[`4a944a942426e1f3263fe539155fb7ef92b04b4a`](https://github.com/lerocha/chinook-database/commit/4a944a942426e1f3263fe539155fb7ef92b04b4a), +соответствующем release `v1.4.5`. Chinook распространяется по MIT license; +полный copyright и permission notice из закреплённого `LICENSE.md` добавляется +SQL-комментариями в каждую подготовленную локальную копию. + +Upstream SQL нельзя выполнять напрямую: он содержит `DROP DATABASE`, +`CREATE DATABASE` и выбор базы. Команды подготовки проверяют Git blob SHA, +версию, целевую СУБД, ключевые таблицы и точный формат трёх setup-строк, затем +удаляют только эти известные строки. Готовый SQL повторно проверяется на +отсутствие database-level setup и публикуется атомарно вместе с остальными +sample-файлами. Он загружается только в заранее выбранную базу `chinook`. + +Chinook выбрана вместо MySQL World, потому что upstream явно указывает MIT +license и предоставляет одинаковые MySQL/PostgreSQL datasets. Существующая +база `world` автоматически не удаляется. Чтобы убрать старую `world` и +получить `chinook` в уже инициализированном MySQL, сначала подготовьте samples, +затем осознанно выполните `make reinit-mysql CONFIRM=1`; эта команда удалит +данные только MySQL. ## Инициализация и порядок файлов @@ -371,7 +405,10 @@ Docker logs. `DB_USER`, а schema и data загружаются от его имени. Закреплённый upstream dump содержит `OWNER TO postgres`; загрузчик безопасно заменяет эти фиксированные owner-выражения на quoted psql-переменную `DB_USER`, не меняя локальные -SQL-файлы и не повышая права роли. +SQL-файлы и не повышая права роли. PostgreSQL Chinook также создаётся с +владельцем `DB_USER`; этой роли принадлежат схема `public`, таблицы и все +созданные в ней последовательности, представления, функции и пользовательские +типы. Пароли не хардкодятся и берутся только из environment контейнеров. ## Проверки @@ -385,16 +422,20 @@ make check-postgres-access MySQL-проверка требует `demo.demo_users`, все пять обязательных email, проверяет temporary read/write и пробный откатываемый `INSERT` в `demo_users`. Она также проверяет доступ ко всем существующим пользовательским базам и -`world.city`/`sakila.actor` только при наличии соответствующего sample. +`sakila.actor` только при наличии sample. Для optional Chinook отдельно +проверяются таблицы с точным регистром `Artist`, `Album`, `Track`, `Customer`, +`Invoice`, данные, join и откатываемая запись. PostgreSQL-проверка подключается как `DB_USER` по TCP к работающему серверу, проверяет владение базой и `demo_users`, все пять обязательных email, создаёт временную таблицу, записывает и читает строку, выполняет откатываемый `INSERT` в `demo_users` и подтверждает отсутствие всех административных атрибутов роли. -Наличие Pagila определяется по фактической базе, а не по sample-файлам. Если -она установлена, дополнительно проверяются владелец, таблицы `actor`, `film`, -`customer`, `rental`, наличие данных, читающий join, временный объект и -откатываемый `INSERT` без остаточных данных. +Наличие Pagila и Chinook определяется независимо по фактическим базам, а не по +sample-файлам. Для каждой установленной базы дополнительно проверяются +владелец, ожидаемые таблицы и владельцы объектов, данные, читающий join, +временный объект и откатываемый `INSERT` без остаточных данных. Поэтому +поддерживаются все варианты: только `demo`, `demo + pagila`, +`demo + chinook`, `demo + pagila + chinook`. Для полного стенда: @@ -405,6 +446,21 @@ make check Команда проверяет Compose-конфигурацию и фактический доступ `DB_USER` к обеим СУБД. +## Troubleshooting optional Chinook + +Если загрузчик сообщает, что `chinook` уже существует, но неполна или имеет +неожиданного владельца, он намеренно ничего не удаляет и не пытается исправить +базу поверх существующих объектов. Проверьте, что sample подготовлен текущей +командой `make samples-mysql` или `make samples-postgres`, сохраните нужные +данные, затем при необходимости явно выполните reinit соответствующей СУБД с +`CONFIRM=1`. Reinit удаляет data-каталог выбранной СУБД; обычные `make up*` +этого не делают. + +Если подготовленный `010_chinook.sql` или `030_chinook.sql` отклонён до +загрузки, не запускайте raw upstream SQL вручную. Повторите подготовку и +проверьте сеть; несовпадение Git blob SHA или формата setup-строк считается +ошибкой безопасности. + ## Остановка и очистка данных Остановить все сервисы без удаления bind-mounted данных: @@ -445,8 +501,8 @@ make reinit-all CONFIRM=1 | `make init` | Создать `.docker.env`, data/init/samples-каталоги и проверить скрипты | | `make pull` | Скачать три образа | | `make config` | Проверить итоговую Compose-конфигурацию | -| `make samples-mysql` | Подготовить optional World и Sakila без запуска контейнеров | -| `make samples-postgres` | Подготовить optional Pagila без запуска контейнеров | +| `make samples-mysql` | Подготовить optional Chinook и Sakila без запуска контейнеров | +| `make samples-postgres` | Подготовить optional Pagila и Chinook без запуска контейнеров | | `make status` | Показать MySQL, PostgreSQL и профильный Adminer | | `make logs` | Смотреть общие логи | | `make log postgres` | Смотреть лог выбранного сервиса (`SERVICE=postgres` также поддерживается) | diff --git a/adminer/plugins-enabled/002-login-help.php b/adminer/plugins-enabled/002-login-help.php index 98fa21d..9ade00a 100644 --- a/adminer/plugins-enabled/002-login-help.php +++ b/adminer/plugins-enabled/002-login-help.php @@ -9,8 +9,8 @@ public function loginForm(): void Как войти

Выберите MySQL (mysql) или PostgreSQL (postgres).

Имя пользователя и пароль — значения DB_USER и DB_PASSWORD из файла .docker.env.

-

Для PostgreSQL всегда доступна demo, а после отдельной подготовки Pagila и чистой инициализации — pagila.

-

Для MySQL также доступны world и sakila, если установлены optional samples.

+

Для PostgreSQL всегда доступна demo, а после отдельной подготовки и чистой инициализации — optional pagila и chinook.

+

Для MySQL всегда доступна demo, а после отдельной подготовки и чистой инициализации — optional sakila и chinook.

Административные пользователи для обычной учебной работы не требуются.

diff --git a/initdb/mysql/050_load_optional_samples.sh b/initdb/mysql/050_load_optional_samples.sh index 138a0a1..768bda9 100755 --- a/initdb/mysql/050_load_optional_samples.sh +++ b/initdb/mysql/050_load_optional_samples.sh @@ -4,7 +4,7 @@ set -Eeuo pipefail : "${MYSQL_ROOT_PASSWORD:?environment variable MYSQL_ROOT_PASSWORD is required}" samples_dir=/opt/mysql-samples -world_file="${samples_dir}/010_world.sql" +chinook_file="${samples_dir}/010_chinook.sql" sakila_schema_file="${samples_dir}/020_sakila_schema.sql" sakila_data_file="${samples_dir}/021_sakila_data.sql" @@ -12,20 +12,92 @@ mysql_root() { MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql \ --protocol=socket \ --user=root \ + --batch \ + --skip-column-names \ "$@" } if [[ ! -d "${samples_dir}" ]]; then - echo "Optional MySQL samples directory is not mounted; skipping World and Sakila." + echo "Optional MySQL samples directory is not mounted; skipping Chinook and Sakila." exit 0 fi -if [[ -f "${world_file}" ]]; then - echo "Loading optional MySQL sample: World..." - mysql_root < "${world_file}" - echo "Loaded optional MySQL sample: World." +chinook_is_complete() { + local key_table_count key_tables_have_data join_has_data + + key_table_count=$(mysql_root --execute=" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'chinook' + AND table_type = 'BASE TABLE' + AND BINARY table_name IN ('Artist', 'Album', 'Track', 'Customer', 'Invoice'); + ") + [[ "${key_table_count}" == "5" ]] || return 1 + + key_tables_have_data=$(mysql_root --execute=" + SELECT EXISTS (SELECT 1 FROM chinook.Artist) + AND EXISTS (SELECT 1 FROM chinook.Album) + AND EXISTS (SELECT 1 FROM chinook.Track) + AND EXISTS (SELECT 1 FROM chinook.Customer) + AND EXISTS (SELECT 1 FROM chinook.Invoice); + ") + [[ "${key_tables_have_data}" == "1" ]] || return 1 + + join_has_data=$(mysql_root --execute=" + SELECT EXISTS ( + SELECT 1 + FROM chinook.Artist AS artist + JOIN chinook.Album AS album ON album.ArtistId = artist.ArtistId + JOIN chinook.Track AS track ON track.AlbumId = album.AlbumId + ); + ") + [[ "${join_has_data}" == "1" ]] +} + +chinook_file_has_database_setup() { + awk ' + BEGIN { in_comment = 0; found = 0 } + { upper = toupper($0) } + /^[[:space:]]*\/\*/ { in_comment = 1 } + !in_comment && upper ~ /^[[:space:]]*((DROP|CREATE)[[:space:]]+DATABASE|USE[[:space:]])/ { found = 1 } + /\*\// { in_comment = 0 } + END { exit(found ? 0 : 1) } + ' "${chinook_file}" +} + +if [[ -f "${chinook_file}" ]]; then + if chinook_file_has_database_setup; then + echo "ERROR: prepared Chinook MySQL SQL contains forbidden database-level statements" >&2 + exit 1 + fi + + chinook_exists=$(mysql_root --execute=" + SELECT COUNT(*) + FROM information_schema.schemata + WHERE schema_name = 'chinook'; + ") + + if [[ "${chinook_exists}" == "1" ]]; then + if chinook_is_complete; then + echo "Optional MySQL sample Chinook is already complete; skipping reload." + else + echo "ERROR: database chinook already exists but is incomplete or unexpected" >&2 + echo "Recreate MySQL explicitly with: make reinit-mysql CONFIRM=1" >&2 + exit 1 + fi + else + echo "Creating optional MySQL database chinook..." + mysql_root --execute='CREATE DATABASE `chinook` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;' + echo "Loading optional MySQL sample: Chinook..." + mysql_root --database=chinook < "${chinook_file}" + if ! chinook_is_complete; then + echo "ERROR: Chinook load finished but required tables, data, or join verification failed" >&2 + exit 1 + fi + echo "Loaded and verified optional MySQL sample Chinook." + fi else - echo "Optional MySQL sample World is not present; skipping it." + echo "Optional MySQL sample Chinook is not present; skipping it." fi if [[ -f "${sakila_schema_file}" && ! -f "${sakila_data_file}" ]]; then diff --git a/initdb/mysql/099_check_training_access.sh b/initdb/mysql/099_check_training_access.sh index ca9c3b2..1dd9767 100755 --- a/initdb/mysql/099_check_training_access.sh +++ b/initdb/mysql/099_check_training_access.sh @@ -105,11 +105,54 @@ SQL echo "Verified required MySQL table, all five seed emails and user-row INSERT access." -if [[ " ${expected_databases[*]} " =~ [[:space:]]world[[:space:]] ]]; then - mysql_training --execute="SELECT COUNT(*) FROM world.city;" >/dev/null - echo "Found and verified optional MySQL sample world.city." +if [[ " ${expected_databases[*]} " =~ [[:space:]]chinook[[:space:]] ]]; then + chinook_key_table_count=$(mysql_training --execute=" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'chinook' + AND table_type = 'BASE TABLE' + AND BINARY table_name IN ('Artist', 'Album', 'Track', 'Customer', 'Invoice'); + ") + if [[ "${chinook_key_table_count}" != "5" ]]; then + echo "ERROR: Chinook does not contain all required case-sensitive tables" >&2 + exit 1 + fi + + chinook_key_tables_have_data=$(mysql_training --execute=" + SELECT EXISTS (SELECT 1 FROM chinook.Artist) + AND EXISTS (SELECT 1 FROM chinook.Album) + AND EXISTS (SELECT 1 FROM chinook.Track) + AND EXISTS (SELECT 1 FROM chinook.Customer) + AND EXISTS (SELECT 1 FROM chinook.Invoice); + ") + if [[ "${chinook_key_tables_have_data}" != "1" ]]; then + echo "ERROR: one or more required Chinook tables contain no data" >&2 + exit 1 + fi + + chinook_join_has_data=$(mysql_training --execute=" + SELECT EXISTS ( + SELECT 1 + FROM chinook.Artist AS artist + JOIN chinook.Album AS album ON album.ArtistId = artist.ArtistId + JOIN chinook.Track AS track ON track.AlbumId = album.AlbumId + ); + ") + if [[ "${chinook_join_has_data}" != "1" ]]; then + echo "ERROR: Chinook Artist-Album-Track join returned no rows" >&2 + exit 1 + fi + + mysql_training <<'SQL' +START TRANSACTION; +INSERT INTO chinook.Artist (ArtistId, Name) +VALUES (-2147483648, 'SQL Lab access check'); +SELECT Name FROM chinook.Artist WHERE ArtistId = -2147483648; +ROLLBACK; +SQL + echo "Found and verified optional MySQL sample Chinook tables, data, join and reversible write access." else - echo "Optional MySQL sample World is not installed; skipped." + echo "Optional MySQL sample Chinook is not installed; skipped." fi if [[ " ${expected_databases[*]} " =~ [[:space:]]sakila[[:space:]] ]]; then diff --git a/initdb/postgres/050_load_optional_samples.sh b/initdb/postgres/050_load_optional_samples.sh index 76413df..67219eb 100755 --- a/initdb/postgres/050_load_optional_samples.sh +++ b/initdb/postgres/050_load_optional_samples.sh @@ -10,24 +10,11 @@ for variable_name in "${required_variables[@]}"; do done samples_dir=/opt/postgres-samples -schema_file="${samples_dir}/010_pagila_schema.sql" -data_file="${samples_dir}/020_pagila_data.sql" +pagila_schema_file="${samples_dir}/010_pagila_schema.sql" +pagila_data_file="${samples_dir}/020_pagila_data.sql" +chinook_file="${samples_dir}/030_chinook.sql" pagila_database=pagila - -if [[ ! -f "${schema_file}" && ! -f "${data_file}" ]]; then - echo "Optional PostgreSQL sample Pagila is not present; skipping it." - exit 0 -fi - -if [[ -f "${schema_file}" && ! -f "${data_file}" ]]; then - echo "ERROR: Pagila schema is present but Pagila data is missing; prepare the complete pair with make samples-postgres" >&2 - exit 1 -fi - -if [[ ! -f "${schema_file}" && -f "${data_file}" ]]; then - echo "ERROR: Pagila data is present but Pagila schema is missing; prepare the complete pair with make samples-postgres" >&2 - exit 1 -fi +chinook_database=chinook admin_psql() { psql \ @@ -38,68 +25,48 @@ admin_psql() { } training_psql() { + local database_name=${1} + shift PGPASSWORD="${DB_PASSWORD}" psql \ --username="${DB_USER}" \ - --dbname="${pagila_database}" \ + --dbname="${database_name}" \ --set=ON_ERROR_STOP=1 \ --no-psqlrc \ "$@" } -database_exists=$(admin_psql \ - --dbname="${POSTGRES_DB}" \ - --tuples-only \ - --no-align \ - --set=database_name="${pagila_database}" <<'SQL' +database_exists() { + local database_name=${1} + admin_psql \ + --dbname="${POSTGRES_DB}" \ + --tuples-only \ + --no-align \ + --set=database_name="${database_name}" <<'SQL' SELECT EXISTS ( SELECT FROM pg_catalog.pg_database WHERE datname = :'database_name' ); SQL -) - -pagila_is_complete() { - local database_owner key_table_count key_tables_have_data public_objects_owned +} - database_owner=$(admin_psql \ +database_owner() { + local database_name=${1} + admin_psql \ --dbname="${POSTGRES_DB}" \ --tuples-only \ --no-align \ - --set=database_name="${pagila_database}" <<'SQL' + --set=database_name="${database_name}" <<'SQL' SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_catalog.pg_database WHERE datname = :'database_name'; SQL - ) - [[ "${database_owner}" == "${DB_USER}" ]] || return 1 +} - key_table_count=$(admin_psql \ - --dbname="${pagila_database}" \ - --tuples-only \ - --no-align \ - --command=" - SELECT COUNT(*) - FROM pg_catalog.pg_class AS class - JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace - WHERE namespace.nspname = 'public' - AND class.relkind IN ('r', 'p') - AND class.relname IN ('actor', 'film', 'customer', 'rental'); - ") - [[ "${key_table_count}" == "4" ]] || return 1 - - key_tables_have_data=$(admin_psql \ - --dbname="${pagila_database}" \ - --tuples-only \ - --no-align \ - --command=" - SELECT EXISTS (SELECT FROM public.actor) - AND EXISTS (SELECT FROM public.film) - AND EXISTS (SELECT FROM public.customer) - AND EXISTS (SELECT FROM public.rental); - ") - [[ "${key_tables_have_data}" == "t" ]] || return 1 +public_objects_owned_by_training_role() { + local database_name=${1} + local public_objects_owned public_objects_owned=$(admin_psql \ - --dbname="${pagila_database}" \ + --dbname="${database_name}" \ --tuples-only \ --no-align \ --set=db_user="${DB_USER}" <<'SQL' @@ -139,48 +106,209 @@ SQL [[ "${public_objects_owned}" == "t" ]] } -if [[ "${database_exists}" == "t" ]]; then - if pagila_is_complete; then - echo "Optional PostgreSQL sample Pagila is already complete and owned by ${DB_USER}; skipping reload." - exit 0 - fi - - echo "ERROR: database pagila already exists but is incomplete or has unexpected ownership" >&2 - echo "Recreate PostgreSQL explicitly with: make reinit-postgres CONFIRM=1" >&2 - exit 1 -fi - -owner_statement_count=$(grep -Fc 'OWNER TO postgres;' "${schema_file}" || true) -if [[ "${owner_statement_count}" == "0" ]]; then - echo "ERROR: Pagila schema has an unexpected ownership format" >&2 - exit 1 -fi -if grep 'OWNER TO ' "${schema_file}" | grep -Fv 'OWNER TO postgres;' >/dev/null; then - echo "ERROR: Pagila schema contains an unsupported owner other than postgres" >&2 - exit 1 -fi - -admin_psql \ - --dbname="${POSTGRES_DB}" \ - --set=database_name="${pagila_database}" \ - --set=db_user="${DB_USER}" <<'SQL' +create_training_database() { + local database_name=${1} + admin_psql \ + --dbname="${POSTGRES_DB}" \ + --set=database_name="${database_name}" \ + --set=db_user="${DB_USER}" <<'SQL' SELECT format('CREATE DATABASE %I OWNER %I', :'database_name', :'db_user') WHERE NOT EXISTS ( SELECT FROM pg_catalog.pg_database WHERE datname = :'database_name' ) \gexec +SQL +} + +pagila_is_complete() { + local key_table_count key_tables_have_data + + [[ "$(database_owner "${pagila_database}")" == "${DB_USER}" ]] || return 1 + + key_table_count=$(admin_psql \ + --dbname="${pagila_database}" \ + --tuples-only \ + --no-align \ + --command=" + SELECT COUNT(*) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relkind IN ('r', 'p') + AND class.relname IN ('actor', 'film', 'customer', 'rental'); + ") + [[ "${key_table_count}" == "4" ]] || return 1 + + key_tables_have_data=$(admin_psql \ + --dbname="${pagila_database}" \ + --tuples-only \ + --no-align \ + --command=" + SELECT EXISTS (SELECT FROM public.actor) + AND EXISTS (SELECT FROM public.film) + AND EXISTS (SELECT FROM public.customer) + AND EXISTS (SELECT FROM public.rental); + ") + [[ "${key_tables_have_data}" == "t" ]] || return 1 + + public_objects_owned_by_training_role "${pagila_database}" +} + +chinook_is_complete() { + local key_table_count key_tables_have_data join_has_data + + [[ "$(database_owner "${chinook_database}")" == "${DB_USER}" ]] || return 1 + + key_table_count=$(admin_psql \ + --dbname="${chinook_database}" \ + --tuples-only \ + --no-align \ + --command=" + SELECT COUNT(*) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relkind IN ('r', 'p') + AND class.relname IN ('artist', 'album', 'track', 'customer', 'invoice'); + ") + [[ "${key_table_count}" == "5" ]] || return 1 + + key_tables_have_data=$(admin_psql \ + --dbname="${chinook_database}" \ + --tuples-only \ + --no-align \ + --command=" + SELECT EXISTS (SELECT FROM public.artist) + AND EXISTS (SELECT FROM public.album) + AND EXISTS (SELECT FROM public.track) + AND EXISTS (SELECT FROM public.customer) + AND EXISTS (SELECT FROM public.invoice); + ") + [[ "${key_tables_have_data}" == "t" ]] || return 1 + + join_has_data=$(admin_psql \ + --dbname="${chinook_database}" \ + --tuples-only \ + --no-align \ + --command=" + SELECT EXISTS ( + SELECT + FROM public.artist AS artist + JOIN public.album AS album USING (artist_id) + JOIN public.track AS track USING (album_id) + ); + ") + [[ "${join_has_data}" == "t" ]] || return 1 + + public_objects_owned_by_training_role "${chinook_database}" +} + +chinook_file_has_database_setup() { + awk ' + BEGIN { in_comment = 0; found = 0 } + { upper = toupper($0) } + /^[[:space:]]*\/\*/ { in_comment = 1 } + !in_comment && (upper ~ /^[[:space:]]*(DROP|CREATE)[[:space:]]+DATABASE/ || upper ~ /^[[:space:]]*\\(C|CONNECT)([[:space:]]|$)/) { found = 1 } + /\*\// { in_comment = 0 } + END { exit(found ? 0 : 1) } + ' "${chinook_file}" +} + +load_pagila() { + if [[ ! -f "${pagila_schema_file}" && ! -f "${pagila_data_file}" ]]; then + echo "Optional PostgreSQL sample Pagila is not present; skipping it." + return + fi + + if [[ -f "${pagila_schema_file}" && ! -f "${pagila_data_file}" ]]; then + echo "ERROR: Pagila schema is present but Pagila data is missing; prepare the complete pair with make samples-postgres" >&2 + exit 1 + fi + + if [[ ! -f "${pagila_schema_file}" && -f "${pagila_data_file}" ]]; then + echo "ERROR: Pagila data is present but Pagila schema is missing; prepare the complete pair with make samples-postgres" >&2 + exit 1 + fi + + if [[ "$(database_exists "${pagila_database}")" == "t" ]]; then + if pagila_is_complete; then + echo "Optional PostgreSQL sample Pagila is already complete and owned by ${DB_USER}; skipping reload." + return + fi + + echo "ERROR: database pagila already exists but is incomplete or has unexpected ownership" >&2 + echo "Recreate PostgreSQL explicitly with: make reinit-postgres CONFIRM=1" >&2 + exit 1 + fi + + owner_statement_count=$(grep -Fc 'OWNER TO postgres;' "${pagila_schema_file}" || true) + if [[ "${owner_statement_count}" == "0" ]]; then + echo "ERROR: Pagila schema has an unexpected ownership format" >&2 + exit 1 + fi + if grep 'OWNER TO ' "${pagila_schema_file}" | grep -Fv 'OWNER TO postgres;' >/dev/null; then + echo "ERROR: Pagila schema contains an unsupported owner other than postgres" >&2 + exit 1 + fi + + create_training_database "${pagila_database}" + + echo "Loading optional PostgreSQL sample: Pagila schema as ${DB_USER}..." + sed 's/OWNER TO postgres;/OWNER TO :"db_user";/g' "${pagila_schema_file}" | \ + training_psql "${pagila_database}" --set=db_user="${DB_USER}" + + echo "Loading optional PostgreSQL sample: Pagila data as ${DB_USER}..." + training_psql "${pagila_database}" < "${pagila_data_file}" + + if ! pagila_is_complete; then + echo "ERROR: Pagila load finished but required tables, data, or DB_USER ownership verification failed" >&2 + exit 1 + fi + + echo "Loaded and verified optional PostgreSQL sample Pagila as ${DB_USER}." +} + +load_chinook() { + if [[ ! -f "${chinook_file}" ]]; then + echo "Optional PostgreSQL sample Chinook is not present; skipping it." + return + fi + + if chinook_file_has_database_setup; then + echo "ERROR: prepared Chinook PostgreSQL SQL contains forbidden database-level statements" >&2 + exit 1 + fi + + if [[ "$(database_exists "${chinook_database}")" == "t" ]]; then + if chinook_is_complete; then + echo "Optional PostgreSQL sample Chinook is already complete and owned by ${DB_USER}; skipping reload." + return + fi + + echo "ERROR: database chinook already exists but is incomplete or has unexpected ownership" >&2 + echo "Recreate PostgreSQL explicitly with: make reinit-postgres CONFIRM=1" >&2 + exit 1 + fi + + create_training_database "${chinook_database}" + admin_psql \ + --dbname="${chinook_database}" \ + --set=db_user="${DB_USER}" <<'SQL' +SELECT format('ALTER SCHEMA public OWNER TO %I', :'db_user') +\gexec SQL -echo "Loading optional PostgreSQL sample: Pagila schema as ${DB_USER}..." -sed 's/OWNER TO postgres;/OWNER TO :"db_user";/g' "${schema_file}" | \ - training_psql --set=db_user="${DB_USER}" + echo "Loading optional PostgreSQL sample: Chinook as ${DB_USER}..." + training_psql "${chinook_database}" < "${chinook_file}" -echo "Loading optional PostgreSQL sample: Pagila data as ${DB_USER}..." -training_psql < "${data_file}" + if ! chinook_is_complete; then + echo "ERROR: Chinook load finished but required tables, data, join, or DB_USER ownership verification failed" >&2 + exit 1 + fi -if ! pagila_is_complete; then - echo "ERROR: Pagila load finished but required tables, data, or DB_USER ownership verification failed" >&2 - exit 1 -fi + echo "Loaded and verified optional PostgreSQL sample Chinook as ${DB_USER}." +} -echo "Loaded and verified optional PostgreSQL sample Pagila as ${DB_USER}." +load_pagila +load_chinook +echo "Optional PostgreSQL sample loading completed." diff --git a/initdb/postgres/099_check_training_access.sh b/initdb/postgres/099_check_training_access.sh index 90f17ed..437c15b 100755 --- a/initdb/postgres/099_check_training_access.sh +++ b/initdb/postgres/099_check_training_access.sh @@ -91,71 +91,66 @@ echo "Verified PostgreSQL user-row INSERT access and absence of administrative r pagila_exists=$(psql_training "${POSTGRES_DB}" --tuples-only --no-align \ --command="SELECT EXISTS (SELECT FROM pg_catalog.pg_database WHERE datname = 'pagila');") -if [[ "${pagila_exists}" != "t" ]]; then - echo "Optional PostgreSQL sample Pagila is not installed; skipped." - echo "All PostgreSQL training-user access checks passed." - exit 0 -fi - -pagila_database_is_owned=$(psql_training "${POSTGRES_DB}" --tuples-only --no-align \ - --command="SELECT pg_get_userbyid(datdba) = current_user FROM pg_catalog.pg_database WHERE datname = 'pagila';") -if [[ "${pagila_database_is_owned}" != "t" ]]; then - echo "ERROR: PostgreSQL database pagila is not owned by DB_USER" >&2 - exit 1 -fi +if [[ "${pagila_exists}" == "t" ]]; then + pagila_database_is_owned=$(psql_training "${POSTGRES_DB}" --tuples-only --no-align \ + --command="SELECT pg_get_userbyid(datdba) = current_user FROM pg_catalog.pg_database WHERE datname = 'pagila';") + if [[ "${pagila_database_is_owned}" != "t" ]]; then + echo "ERROR: PostgreSQL database pagila is not owned by DB_USER" >&2 + exit 1 + fi -pagila_key_table_count=$(psql_training pagila --tuples-only --no-align --command=" - SELECT COUNT(*) - FROM pg_catalog.pg_class AS class - JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace - WHERE namespace.nspname = 'public' - AND class.relkind IN ('r', 'p') - AND class.relname IN ('actor', 'film', 'customer', 'rental'); -") -if [[ "${pagila_key_table_count}" != "4" ]]; then - echo "ERROR: Pagila does not contain all required tables: actor, film, customer, rental" >&2 - exit 1 -fi + pagila_key_table_count=$(psql_training pagila --tuples-only --no-align --command=" + SELECT COUNT(*) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relkind IN ('r', 'p') + AND class.relname IN ('actor', 'film', 'customer', 'rental'); + ") + if [[ "${pagila_key_table_count}" != "4" ]]; then + echo "ERROR: Pagila does not contain all required tables: actor, film, customer, rental" >&2 + exit 1 + fi -pagila_key_tables_owned=$(psql_training pagila --tuples-only --no-align --command=" - SELECT COUNT(*) = 4 AND bool_and(pg_get_userbyid(class.relowner) = current_user) - FROM pg_catalog.pg_class AS class - JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace - WHERE namespace.nspname = 'public' - AND class.relkind IN ('r', 'p') - AND class.relname IN ('actor', 'film', 'customer', 'rental'); -") -if [[ "${pagila_key_tables_owned}" != "t" ]]; then - echo "ERROR: one or more required Pagila tables are not owned by DB_USER" >&2 - exit 1 -fi + pagila_key_tables_owned=$(psql_training pagila --tuples-only --no-align --command=" + SELECT COUNT(*) = 4 AND bool_and(pg_get_userbyid(class.relowner) = current_user) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relkind IN ('r', 'p') + AND class.relname IN ('actor', 'film', 'customer', 'rental'); + ") + if [[ "${pagila_key_tables_owned}" != "t" ]]; then + echo "ERROR: one or more required Pagila tables are not owned by DB_USER" >&2 + exit 1 + fi -pagila_key_tables_have_data=$(psql_training pagila --tuples-only --no-align --command=" - SELECT EXISTS (SELECT FROM public.actor) - AND EXISTS (SELECT FROM public.film) - AND EXISTS (SELECT FROM public.customer) - AND EXISTS (SELECT FROM public.rental); -") -if [[ "${pagila_key_tables_have_data}" != "t" ]]; then - echo "ERROR: one or more required Pagila tables contain no data" >&2 - exit 1 -fi + pagila_key_tables_have_data=$(psql_training pagila --tuples-only --no-align --command=" + SELECT EXISTS (SELECT FROM public.actor) + AND EXISTS (SELECT FROM public.film) + AND EXISTS (SELECT FROM public.customer) + AND EXISTS (SELECT FROM public.rental); + ") + if [[ "${pagila_key_tables_have_data}" != "t" ]]; then + echo "ERROR: one or more required Pagila tables contain no data" >&2 + exit 1 + fi -pagila_read_probe=$(psql_training pagila --tuples-only --no-align --command=" - SELECT actor.actor_id || ':' || film.film_id - FROM public.actor AS actor - JOIN public.film_actor AS film_actor USING (actor_id) - JOIN public.film AS film USING (film_id) - WHERE film.title IS NOT NULL - ORDER BY actor.actor_id, film.film_id - LIMIT 1; -") -if [[ -z "${pagila_read_probe}" ]]; then - echo "ERROR: Pagila join read probe returned no rows" >&2 - exit 1 -fi + pagila_read_probe=$(psql_training pagila --tuples-only --no-align --command=" + SELECT actor.actor_id || ':' || film.film_id + FROM public.actor AS actor + JOIN public.film_actor AS film_actor USING (actor_id) + JOIN public.film AS film USING (film_id) + WHERE film.title IS NOT NULL + ORDER BY actor.actor_id, film.film_id + LIMIT 1; + ") + if [[ -z "${pagila_read_probe}" ]]; then + echo "ERROR: Pagila join read probe returned no rows" >&2 + exit 1 + fi -psql_training pagila <<'SQL' + psql_training pagila <<'SQL' CREATE TEMPORARY TABLE __sql_lab_pagila_access_check ( id integer PRIMARY KEY, @@ -173,5 +168,114 @@ RETURNING actor_id; ROLLBACK; SQL -echo "Verified Pagila ownership, required populated tables, join read and reversible write access." + echo "Verified Pagila ownership, required populated tables, join read and reversible write access." +else + echo "Optional PostgreSQL sample Pagila is not installed; skipped." +fi + +chinook_exists=$(psql_training "${POSTGRES_DB}" --tuples-only --no-align \ + --command="SELECT EXISTS (SELECT FROM pg_catalog.pg_database WHERE datname = 'chinook');") +if [[ "${chinook_exists}" == "t" ]]; then + chinook_database_is_owned=$(psql_training "${POSTGRES_DB}" --tuples-only --no-align \ + --command="SELECT pg_get_userbyid(datdba) = current_user FROM pg_catalog.pg_database WHERE datname = 'chinook';") + if [[ "${chinook_database_is_owned}" != "t" ]]; then + echo "ERROR: PostgreSQL database chinook is not owned by DB_USER" >&2 + exit 1 + fi + + chinook_key_table_count=$(psql_training chinook --tuples-only --no-align --command=" + SELECT COUNT(*) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relkind IN ('r', 'p') + AND class.relname IN ('artist', 'album', 'track', 'customer', 'invoice'); + ") + if [[ "${chinook_key_table_count}" != "5" ]]; then + echo "ERROR: Chinook does not contain all required tables: artist, album, track, customer, invoice" >&2 + exit 1 + fi + + chinook_public_objects_owned=$(psql_training chinook --tuples-only --no-align --command=" + SELECT + pg_catalog.pg_get_userbyid(namespace.nspowner) = current_user + AND NOT EXISTS ( + SELECT + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS object_namespace ON object_namespace.oid = class.relnamespace + WHERE object_namespace.nspname = 'public' + AND class.relkind IN ('r', 'p', 'S', 'v', 'm', 'f') + AND pg_catalog.pg_get_userbyid(class.relowner) <> current_user + ) + AND NOT EXISTS ( + SELECT + FROM pg_catalog.pg_proc AS procedure + JOIN pg_catalog.pg_namespace AS object_namespace ON object_namespace.oid = procedure.pronamespace + WHERE object_namespace.nspname = 'public' + AND pg_catalog.pg_get_userbyid(procedure.proowner) <> current_user + ) + AND NOT EXISTS ( + SELECT + FROM pg_catalog.pg_type AS type + JOIN pg_catalog.pg_namespace AS object_namespace ON object_namespace.oid = type.typnamespace + WHERE object_namespace.nspname = 'public' + AND type.typtype IN ('c', 'd', 'e') + AND pg_catalog.pg_get_userbyid(type.typowner) <> current_user + ) + FROM pg_catalog.pg_namespace AS namespace + WHERE namespace.nspname = 'public'; + ") + if [[ "${chinook_public_objects_owned}" != "t" ]]; then + echo "ERROR: Chinook public schema or one or more objects are not owned by DB_USER" >&2 + exit 1 + fi + + chinook_key_tables_have_data=$(psql_training chinook --tuples-only --no-align --command=" + SELECT EXISTS (SELECT FROM public.artist) + AND EXISTS (SELECT FROM public.album) + AND EXISTS (SELECT FROM public.track) + AND EXISTS (SELECT FROM public.customer) + AND EXISTS (SELECT FROM public.invoice); + ") + if [[ "${chinook_key_tables_have_data}" != "t" ]]; then + echo "ERROR: one or more required Chinook tables contain no data" >&2 + exit 1 + fi + + chinook_read_probe=$(psql_training chinook --tuples-only --no-align --command=" + SELECT artist.artist_id || ':' || album.album_id || ':' || track.track_id + FROM public.artist AS artist + JOIN public.album AS album USING (artist_id) + JOIN public.track AS track USING (album_id) + ORDER BY artist.artist_id, album.album_id, track.track_id + LIMIT 1; + ") + if [[ -z "${chinook_read_probe}" ]]; then + echo "ERROR: Chinook artist-album-track join returned no rows" >&2 + exit 1 + fi + + psql_training chinook <<'SQL' +CREATE TEMPORARY TABLE __sql_lab_chinook_access_check +( + id integer PRIMARY KEY, + value varchar(32) NOT NULL +); + +INSERT INTO __sql_lab_chinook_access_check (id, value) VALUES (1, 'ok'); +SELECT value FROM __sql_lab_chinook_access_check WHERE id = 1; +DROP TABLE __sql_lab_chinook_access_check; + +BEGIN; +INSERT INTO public.artist (artist_id, name) +VALUES (-2147483648, 'SQL Lab access check') +RETURNING artist_id; +ROLLBACK; +SQL + + echo "Verified Chinook ownership, required populated tables, join read and reversible write access." +else + echo "Optional PostgreSQL sample Chinook is not installed; skipped." +fi + echo "All PostgreSQL training-user access checks passed."