diff --git a/.env.example b/.env.example index 9b6928f..53a3df6 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,11 @@ MYSQL_USER=recruitment MYSQL_PASSWORD=CHANGE_ME_APP_PASSWORD MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD +# Read-only SQL mount for automatic first-time setup; never included in the image. +# On a server, use an absolute private path OUTSIDE the checkout/document root. +# The file must exist on the Docker host, even when the database is initialized. +DB_INIT_SQL_PATH=./DB/recruitment_local_2026-10-14.sql + # HTTPS must be provided by your existing reverse proxy. # Localhost is safe for a proxy running directly on the Docker host. APP_BIND_IP=127.0.0.1 diff --git a/README.md b/README.md index be7f7a0..95f249b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Panduan akun, backup, dan runtime ada di [dev/README.md](dev/README.md). Hentika ## Server -Untuk deployment Docker Compose melalui Komodo (PHP/Apache + MySQL persisten, di belakang reverse proxy HTTPS), ikuti [panduan deployment](docs/deployment.md). Konfigurasi tersedia di `compose.yaml`, `Dockerfile`, dan `.env.example`; dump SQL diimpor secara eksplisit, bukan setiap redeploy. +Untuk deployment Docker Compose melalui Komodo (PHP/Apache + MySQL persisten, di belakang reverse proxy HTTPS), ikuti [panduan deployment](docs/deployment.md). Konfigurasi tersedia di `compose.yaml`, `Dockerfile`, dan `.env.example`. Set `DB_INIT_SQL_PATH` ke dump SQL tepercaya di host Docker; startup otomatis mengimpor hanya ke database kosong dan menjalankan migrasi sebelum aplikasi dimulai. Database yang sudah diinisialisasi tidak diimpor ulang. Untuk deployment PHP tanpa Docker, salin `includes/conn.example.php` ke `includes/conn.php`, lalu isi konfigurasi database. Impor database recruitment dan jalankan migrasi yang diperlukan di `dashboard/migrations/`. Panduan admin: [dashboard/README.md](dashboard/README.md). diff --git a/compose.yaml b/compose.yaml index f5a8146..fc32ba0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,14 +2,14 @@ name: ${COMPOSE_PROJECT_NAME:-recruitment-hc} services: app: - build: + build: &app-build context: . dockerfile: Dockerfile restart: unless-stopped init: true ports: - "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-8086}:80" - environment: + environment: &app-environment MYSQL_HOST: db MYSQL_PORT: "3306" MYSQL_DATABASE: ${MYSQL_DATABASE:-recruitment} @@ -18,8 +18,8 @@ services: REC_PASS_SCORE: ${REC_PASS_SCORE:-300} TZ: Asia/Jakarta depends_on: - db: - condition: service_healthy + migrate: + condition: service_completed_successfully volumes: - php_sessions:/var/lib/php/sessions healthcheck: @@ -62,6 +62,41 @@ services: max-size: "10m" max-file: "3" + # One-shot services: never run the destructive dump as an app entrypoint. + db-init: + image: mysql:8.4 + restart: "no" + entrypoint: ["sh", "/bootstrap/init-db.sh"] + environment: *app-environment + depends_on: + db: + condition: service_healthy + volumes: + - type: bind + source: ./docker/init-db.sh + target: /bootstrap/init-db.sh + read_only: true + bind: + create_host_path: false + - type: bind + source: ${DB_INIT_SQL_PATH:-./DB/recruitment_local_2026-10-14.sql} + target: /bootstrap/initial.sql + read_only: true + bind: + create_host_path: false + + migrate: + build: *app-build + restart: "no" + command: ["php", "/usr/local/share/recruitment/migrate.php"] + environment: *app-environment + depends_on: + db-init: + condition: service_completed_successfully + # This service exits after migration; it is not an Apache server. + healthcheck: + disable: true + phpmyadmin: image: phpmyadmin:5.2-apache restart: unless-stopped diff --git a/dev/test-db-init.py b/dev/test-db-init.py new file mode 100644 index 0000000..9e5745f --- /dev/null +++ b/dev/test-db-init.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Exercise destructive-import guards without connecting to any real database.""" +import json +import os +import secrets +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "docker/init-db.sh" +BASE = ["in_daper", "in_soal", "in_jawab"] +ALL = BASE + ["rec_admin", "rec_exam_attempt", "rec_exam_event", "rec_candidate_login_guard"] + +FAKE_MYSQL = r'''#!/usr/bin/env python3 +import json +import os +import re +import sys +from pathlib import Path + +path = Path(os.environ["FAKE_MYSQL_STATE"]) +state = json.loads(path.read_text()) +args = sys.argv[1:] +query = args[args.index("-e") + 1] if "-e" in args else None +if query is None: + sys.stdin.read() + state["imports"] = state.get("imports", 0) + 1 + state["tables"] += state.get("import_tables", []) + path.write_text(json.dumps(state)) + if state.get("fail_import"): + print("Synthetic import failure", file=sys.stderr) + sys.exit(1) +elif "information_schema.tables" in query: + tables = state["tables"] + if "table_name = 'rec_bootstrap_state'" in query: + print(int("rec_bootstrap_state" in tables)) + elif "table_name IN" in query: + names = re.findall(r"'([^']+)'", query.split("table_name IN", 1)[1]) + print(sum(name in tables for name in names)) + else: + print(len(tables)) +elif query.startswith("SELECT status"): + print(state.get("status", "")) +elif query.startswith("CREATE TABLE rec_bootstrap_state"): + if "rec_bootstrap_state" in state["tables"]: + sys.exit(1) + state["tables"].append("rec_bootstrap_state") + state["status"] = "importing" + path.write_text(json.dumps(state)) +elif query.startswith("UPDATE rec_bootstrap_state"): + state["status"] = "imported" + path.write_text(json.dumps(state)) +else: + print("Unexpected query: " + query, file=sys.stderr) + sys.exit(1) +''' + + +class InitializationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + client = self.root / "mysql" + client.write_text(FAKE_MYSQL) + client.chmod(0o700) + self.state_path = self.root / "state.json" + self.dump = self.root / "initial.sql" + self.dump.write_text("-- Synthetic SQL; never loads project personal data.\n") + self.env = dict(os.environ, PATH=f"{self.root}:{os.environ['PATH']}", + MYSQL_HOST="test-db", MYSQL_USER="test-user", + MYSQL_DATABASE="test-db", MYSQL_PASSWORD=secrets.token_hex(16), + FAKE_MYSQL_STATE=str(self.state_path), DB_INIT_SQL_FILE=str(self.dump)) + + def state(self): + try: + return json.loads(self.state_path.read_text()) + except (OSError, ValueError) as error: + self.fail(f"Invalid mock database state: {error}") + + def configure(self, tables=None, **options): + self.state_path.write_text(json.dumps(dict(tables=tables or [], import_tables=ALL, **options))) + + def run_init(self): + return subprocess.run(["sh", str(SCRIPT)], env=self.env, capture_output=True, text=True) + + def test_empty_database_imports_once(self): + self.configure() + first = self.run_init() + self.assertEqual(first.returncode, 0, first.stderr) + self.assertEqual(self.state()["status"], "imported") + second = self.run_init() + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(self.state()["imports"], 1) + + def test_existing_complete_database_skips_import(self): + self.configure(ALL) + result = self.run_init() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("imports", self.state()) + self.assertEqual(self.state()["tables"], ALL) + + def test_partial_database_refuses_import(self): + self.configure(BASE) + self.assertNotEqual(self.run_init().returncode, 0) + self.assertNotIn("imports", self.state()) + + def test_unrelated_database_refuses_import(self): + self.configure(["unrelated_table"]) + self.assertNotEqual(self.run_init().returncode, 0) + self.assertNotIn("imports", self.state()) + + def test_failed_import_blocks_automatic_retry(self): + self.configure(fail_import=True) + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["status"], "importing") + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["imports"], 1) + + def test_missing_dump_leaves_database_untouched(self): + self.configure() + self.dump.unlink() + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["tables"], []) + + def test_empty_dump_leaves_database_untouched(self): + self.configure() + self.dump.write_text("") + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["tables"], []) + + def test_dump_cannot_replace_the_safety_marker(self): + self.configure() + self.dump.write_text("DROP TABLE IF EXISTS `REC_BOOTSTRAP_STATE`;\n") + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["tables"], []) + self.assertNotIn("imports", self.state()) + + def test_dump_missing_base_tables_is_not_marked_imported(self): + self.configure() + state = self.state() + state["import_tables"] = ["in_daper"] + self.state_path.write_text(json.dumps(state)) + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["status"], "importing") + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["imports"], 1) + + def test_imported_marker_with_missing_tables_is_refused(self): + self.configure(["rec_bootstrap_state", "in_daper"], status="imported") + self.assertNotEqual(self.run_init().returncode, 0) + self.assertNotIn("imports", self.state()) + + def test_marker_with_missing_status_is_refused(self): + self.configure(["rec_bootstrap_state"]) + self.assertNotEqual(self.run_init().returncode, 0) + self.assertNotIn("imports", self.state()) + + def test_missing_password_is_refused_before_access(self): + self.configure() + del self.env["MYSQL_PASSWORD"] + self.assertNotEqual(self.run_init().returncode, 0) + self.assertEqual(self.state()["tables"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/docker/init-db.sh b/docker/init-db.sh new file mode 100644 index 0000000..7cb68a2 --- /dev/null +++ b/docker/init-db.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Runs in the MySQL client container, never in the application's web root. +set -eu + +: "${MYSQL_HOST:?Missing MYSQL_HOST}" +: "${MYSQL_DATABASE:?Missing MYSQL_DATABASE}" +: "${MYSQL_USER:?Missing MYSQL_USER}" +: "${MYSQL_PASSWORD:?Missing MYSQL_PASSWORD}" +export MYSQL_PWD="$MYSQL_PASSWORD" + +mysql_run() { + mysql --protocol=TCP --host="$MYSQL_HOST" --port="${MYSQL_PORT:-3306}" \ + --user="$MYSQL_USER" --database="$MYSQL_DATABASE" \ + --batch --skip-column-names "$@" +} + +fail() { + printf '%s\n' "Database initialization refused: $*" >&2 + exit 1 +} + +require_tables() { + count=$(mysql_run -e "SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' + AND table_name IN ($1)") + [ "$count" = "$2" ] || fail "Required recruitment tables are missing. Review the database manually; no automatic reimport will be attempted." +} + +marker=$(mysql_run -e "SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'rec_bootstrap_state'") + +if [ "$marker" = 1 ]; then + status=$(mysql_run -e "SELECT status FROM rec_bootstrap_state WHERE id = 1") + [ "$status" = imported ] || fail "A previous import did not finish. SQL imports are not transactional; inspect or restore this database before retrying." + require_tables "'in_daper', 'in_soal', 'in_jawab'" 3 + printf '%s\n' 'Initial SQL was already imported; preserving existing data.' + exit 0 +fi + +tables=$(mysql_run -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()") +if [ "$tables" != 0 ]; then + # Adopt a previously initialized deployment, but reject a bare/partial schema. + require_tables "'in_daper', 'in_soal', 'in_jawab', 'rec_admin', 'rec_exam_attempt', 'rec_exam_event', 'rec_candidate_login_guard'" 7 + printf '%s\n' 'Existing recruitment database detected; skipping SQL import.' + exit 0 +fi + +sql_file=${DB_INIT_SQL_FILE:-/bootstrap/initial.sql} +[ -f "$sql_file" ] && [ -r "$sql_file" ] && [ -s "$sql_file" ] \ + || fail "The initial SQL file is missing, unreadable, or empty. Set DB_INIT_SQL_PATH to a trusted dump on the Docker host." + +# The dump must not replace our safety marker with a previously imported one. +# Exclude this setup-only table when creating backups (see deployment guide). +if grep -iFq 'rec_bootstrap_state' "$sql_file"; then + fail "The dump references the reserved rec_bootstrap_state table. Re-export without that table before importing." +fi + +# Persist the in-progress marker BEFORE any DROP/CREATE statements in the dump. +# Do not use IF NOT EXISTS: concurrent first imports must not both proceed. +mysql_run -e "CREATE TABLE rec_bootstrap_state ( + id TINYINT UNSIGNED NOT NULL PRIMARY KEY, + status VARCHAR(20) NOT NULL +) ENGINE=InnoDB; +INSERT INTO rec_bootstrap_state (id, status) VALUES (1, 'importing');" + +printf '%s\n' 'Importing initial SQL into the empty database...' +mysql_run < "$sql_file" +require_tables "'in_daper', 'in_soal', 'in_jawab'" 3 +mysql_run -e "UPDATE rec_bootstrap_state SET status = 'imported' WHERE id = 1" +printf '%s\n' 'Initial SQL import complete.' diff --git a/docker/migrate.php b/docker/migrate.php index dc8b685..8249f71 100644 --- a/docker/migrate.php +++ b/docker/migrate.php @@ -26,8 +26,13 @@ try { } while ($conn->more_results() && $conn->next_result()); fwrite(STDOUT, "Applied: $file\n"); } + // Fail the one-shot service before starting Apache if setup is incomplete. + foreach (['rec_admin', 'rec_exam_attempt', 'rec_exam_event', 'rec_candidate_login_guard'] as $table) { + $conn->query("SELECT 1 FROM `$table` LIMIT 0"); + } + $conn->query('SELECT email, nama_de, nama_be, idkar, password_hash FROM rec_admin LIMIT 0'); fwrite(STDOUT, "Database ready. Existing accounts and test data were preserved.\n"); } catch (Throwable $error) { - fwrite(STDERR, 'Migration failed: ' . $error->getMessage() . "\nImport the base database first; see docs/deployment.md.\n"); + fwrite(STDERR, 'Migration failed: ' . $error->getMessage() . "\nReview setup logs and docs/deployment.md before retrying.\n"); exit(1); } diff --git a/docs/deployment.md b/docs/deployment.md index 37c717c..a42fbe5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -3,7 +3,8 @@ This stack runs PHP 8.3/Apache, MySQL 8.4 LTS, and phpMyAdmin 5.2. No Node build, registry, or separate Komodo Build resource is required. The app image is built on your Docker host. MySQL has no published port. Database, application sessions, -and phpMyAdmin sessions use persistent named volumes. +and phpMyAdmin sessions use persistent named volumes. Two one-shot services import +an initial SQL dump safely and apply migrations before Apache starts. To build/publish the app in Gitea Actions instead of on the server, see [the image build guide](image-build.md), including registry secrets and Komodo @@ -32,9 +33,10 @@ secrets. Generate each password with `openssl rand -hex 32`. Use Komodo secrets if available; do not commit the populated environment to Git. Leave the environment file path at `.env`: Komodo writes it and passes it to Compose. -Deploy the Stack. **The app may initially be unhealthy** until you explicitly -import the base database and run migrations below. Do not enable a deploy -`--wait` flag on this first launch: it cannot become ready before that setup. +Before deploying, transfer your trusted initial SQL dump to the Docker host and +set `DB_INIT_SQL_PATH` as described below. Deploy the Stack: startup waits for +MySQL, runs `db-init`, runs `migrate`, then starts the app. The one-shot services +should finish with exit code 0; they do not remain running. For a files-on-host Stack instead, put the repository on the server, select **Files on host**, set Run directory to its absolute path, and use the same @@ -42,52 +44,70 @@ Compose/build/environment settings. Official reference: [Komodo Compose Stacks](https://komo.do/docs/deploy/compose). -## 2. Import the database once - -On the Docker server, change to the Stack's checked-out **run directory**. The -commands below assume the project name `recruitment-hc` and Komodo's default -`.env` file; if you choose another project name/env path, adjust **every** command -accordingly. Never run commands against a different project by accident. - -Wait for `db` to be healthy: - -```sh -docker compose --env-file .env -p recruitment-hc ps -``` +## 2. Automatic database initialization Obtain a trusted recruitment SQL export containing `in_daper`, `in_soal`, and -`in_jawab`. Transfer it privately to a path **outside the checkout/document root**, -for example `/srv/recruitment-private/initial.sql`, with restricted permissions. -The repository currently also tracks an export under `DB/`; it may contain -personal data and existing accounts. Keep the repository private, review/sanitize -any export used in production, and remove unwanted demo/test access before opening -the site. Neither `DB/` nor any export is included in the image or auto-imported. +`in_jawab`. Transfer it privately to the **Docker host**, outside the +checkout/document root, for example `/srv/recruitment-private/initial.sql`. +Restrict access to the file and set this in the Stack Environment (or `.env`): -**Only import into a new, empty database.** Dumps can contain `DROP TABLE` and -existing accounts/passwords. Check first; this should return **no table names**: - -```sh -docker compose --env-file .env -p recruitment-hc exec -T db sh -c \ - 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE" -e "SHOW TABLES"' +```dotenv +DB_INIT_SQL_PATH=/srv/recruitment-private/initial.sql ``` -If it is empty, import and apply the migrations: +For local Docker use, the default is +`./DB/recruitment_local_2026-10-14.sql`. That dump contains personal data and +existing accounts, not just schema. Review/sanitize production exports and remove +unwanted demo/test access before opening the site. Dumps are not shipped in the +app image or fetched from Git. The file is mounted read-only into `db-init`; +it must exist on the Docker host even on later deployments. A missing path fails +startup instead of silently creating a directory. Dumps must not reference the +reserved `rec_bootstrap_state` safety table; exclude it from exports/backups using +the command in section 5. This prevents a restored marker from masking a failed +import. + +Normal deployment handles setup automatically: + +1. `db` becomes healthy. +2. `db-init` imports the dump **only if the database contains no tables or views**. + A persistent `rec_bootstrap_state` table records `importing` before the import + and `imported` only after success and validation of the three base tables. +3. On subsequent deployments, an `imported` marker and the base tables skip the + dump entirely. A pre-existing database without a marker is accepted only if + all seven recruitment tables are present. Partial/unexpected databases are + refused, not overwritten. This check cannot validate the contents of a dump + imported outside this setup; review such databases yourself before deployment. +4. `migrate` applies the idempotent admin/exam migrations and validates readiness. +5. `app` starts only after both one-shot services exit successfully. + +On the Docker server, change to the Stack's checked-out **run directory**. The +commands below assume project name `recruitment-hc` and `.env`; adjust every +command if your Stack uses different settings. ```sh -docker compose --env-file .env -p recruitment-hc exec -T db sh -c \ - 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE"' \ - < /srv/recruitment-private/initial.sql - -docker compose --env-file .env -p recruitment-hc exec -T app \ - php /usr/local/share/recruitment/migrate.php +docker compose --env-file .env -p recruitment-hc ps -a +docker compose --env-file .env -p recruitment-hc logs db-init migrate ``` -The migration command requires the three base tables, creates the admin and exam -protection tables, and handles the historical admin identity schema when needed. -It can be rerun without resetting users or test progress. Migrations are explicit, -not run automatically on every container restart. Back up before applying them to -an existing database. Future migrations must be added to the migration runner as -part of their release. +The import guard has a database-free regression suite: +`python3 dev/test-db-init.py`. + +**Failed import:** SQL DDL is not transactional. An `importing` marker deliberately +blocks retries, including when only some tables were restored. Inspect the logs +and affected database, then restore a trusted backup or use a separately verified +empty database/volume. Do not delete the marker or run a dump over partial data +blindly. Never delete the production volume merely to retry setup. + +Migrations can be rerun without resetting users or test progress. They run during +Compose deployment, not on an individual Apache container restart. Back up before +deploying changes against an existing database. Add future migrations to the +migration runner as part of their release. If a migration fails, fix the cause and +rerun setup without reimporting: + +```sh +docker compose --env-file .env -p recruitment-hc run --rm migrate +docker compose --env-file .env -p recruitment-hc up -d +``` If you need a new admin account, run this from an **interactive SSH terminal**: @@ -169,17 +189,17 @@ Never expose phpMyAdmin directly on an unrestricted public HTTP port. Add these new settings from `.env.example` to the Komodo Stack Environment and redeploy. `PHPMYADMIN_PORT` defaults to `8087`; `PHPMYADMIN_UPLOAD_LIMIT` defaults to `64M`. For browser imports, adjust your reverse proxy's upload size/timeouts as -needed; large dumps are better imported using the CLI command in section 2. -Browser imports can also overwrite data: only import into a verified empty -database, then run the migration command. No database initialization is automated. +needed; large dumps are better handled by the setup service in section 2. +Browser imports can also overwrite data. Prefer the automatic `db-init` service +for first-time setup; do not import another dump over an initialized database. ## 4. Verify ```sh -docker compose --env-file .env -p recruitment-hc ps +docker compose --env-file .env -p recruitment-hc ps -a curl --fail http://127.0.0.1:8086/healthz curl --fail http://127.0.0.1:8087/ > /dev/null -docker compose --env-file .env -p recruitment-hc logs --tail=100 app db phpmyadmin +docker compose --env-file .env -p recruitment-hc logs --tail=100 app db db-init migrate phpmyadmin ``` Adjust the curl address if you changed the bind IP or port. `/healthz` returns @@ -211,19 +231,20 @@ snapshot for InnoDB. Include any active test data when planning maintenance. umask 077 mkdir -p /srv/recruitment-private/backups docker compose --env-file .env -p recruitment-hc exec -T db sh -c \ - 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -u root --single-transaction --routines --triggers --no-tablespaces "$MYSQL_DATABASE"' \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysqldump -u root --single-transaction --routines --triggers --no-tablespaces --ignore-table="$MYSQL_DATABASE.rec_bootstrap_state" "$MYSQL_DATABASE"' \ > "/srv/recruitment-private/backups/recruitment-$(date +%Y%m%d-%H%M%S).sql" ``` Check the command's exit status and backup contents before relying on the file. SQL backups contain personal data and password hashes; restrict and protect them. -Restore using the import command in section 2 **only after selecting a new empty -database or explicitly approving replacement of the target data**; migrate after -restoring if needed. +To restore into a new empty stack, set `DB_INIT_SQL_PATH` to the trusted backup +and deploy; initialization and migrations run automatically. Restoring over an +existing database requires a separate, explicitly approved recovery procedure; +normal startup never replaces its tables. To update, back up, pull the new commit in Komodo and redeploy with **Run build** -enabled. The database volume is reused; imports are never repeated automatically. -Apply migration changes explicitly when a release requires them. Rolling back an +enabled. The database volume is reused; successful imports are never repeated. +The migration service runs before the updated app starts. Rolling back an image does not undo schema changes; use a tested database restore if necessary. Image tags track compatible patch releases; consider pinning tested image digests in production for reproducible upgrades. @@ -246,11 +267,13 @@ From the repository root on the server: ```sh cp .env.example .env chmod 600 .env -# Edit .env and replace both password placeholders before proceeding. +# Edit .env: replace both passwords and set DB_INIT_SQL_PATH to your trusted dump. +# The SQL file must already exist on this Docker host. docker compose --env-file .env -p recruitment-hc config --quiet docker compose --env-file .env -p recruitment-hc up -d --build ``` -Then follow sections 2–5. Docker uses its own environment-backed +Setup is automatic. Inspect `db-init`/`migrate` logs, create an admin as needed +(section 2), then follow sections 3–5. Docker uses its own environment-backed `includes/conn.php` baked into the image; it never reads or overwrites the host's private `includes/conn.php`. Standalone PHP/local preview behavior is unchanged. diff --git a/docs/image-build.md b/docs/image-build.md index afa7873..e247eff 100644 --- a/docs/image-build.md +++ b/docs/image-build.md @@ -8,8 +8,8 @@ architecture** (typically `linux/amd64`) and publishes `:latest` to: gitea.cptckp.com/nurinda/recruitment-hc ``` -Only the PHP/Apache app is built; MySQL and phpMyAdmin retain their official -images. Database exports and host credentials are excluded by `.dockerignore`. +Only the PHP/Apache image is built (used by both `app` and `migrate`); MySQL, +`db-init`, and phpMyAdmin retain their official images. Database exports and host credentials are excluded by `.dockerignore`. ## Setup @@ -69,6 +69,8 @@ Stack run directory, for example `compose.image.yaml`: services: app: image: gitea.cptckp.com/nurinda/recruitment-hc:latest + migrate: + image: gitea.cptckp.com/nurinda/recruitment-hc:latest ``` Prefer replacing `:latest` with the tested image digest (`@sha256:...` instead @@ -81,10 +83,12 @@ file paths, in that order. Then: - Configure Komodo's Registry provider/account for `gitea.cptckp.com`; for private packages, use a token with **Packages: Read** on an account allowed to pull them. Keep this separate from the CI write token. -- Keep the Compose project name and volumes unchanged. Imports/migrations remain - explicit; follow [the deployment guide](deployment.md). +- Use the same tested image/digest for `app` and `migrate`. +- Keep the Compose project name and volumes unchanged. Set `DB_INIT_SQL_PATH` to + a trusted dump on the Docker host. Initialization/migrations run automatically; + follow [the deployment guide](deployment.md). -Compose retains the base file's `build` declaration in this simple override. Use +Compose retains both services' base `build` declarations in this simple override. Use `docker compose ... up -d --no-build` for manual deployments of the CI image (after registry login and `docker compose ... pull`); do **not** use `--build`. Komodo's Auto pull supplies the image before `up`, with Run build disabled.