This commit is contained in:
1 parent
125eab3cd4
commit
045d3a8fd3
8 files changed
+375
-65
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+39
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.'
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
+77
-54
@@ -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.
|
||||
+9
-5
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user