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_PASSWORD=CHANGE_ME_APP_PASSWORD
|
||||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_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.
|
# HTTPS must be provided by your existing reverse proxy.
|
||||||
# Localhost is safe for a proxy running directly on the Docker host.
|
# Localhost is safe for a proxy running directly on the Docker host.
|
||||||
APP_BIND_IP=127.0.0.1
|
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
|
## 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).
|
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:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build: &app-build
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
ports:
|
ports:
|
||||||
- "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-8086}:80"
|
- "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-8086}:80"
|
||||||
environment:
|
environment: &app-environment
|
||||||
MYSQL_HOST: db
|
MYSQL_HOST: db
|
||||||
MYSQL_PORT: "3306"
|
MYSQL_PORT: "3306"
|
||||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-recruitment}
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-recruitment}
|
||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
REC_PASS_SCORE: ${REC_PASS_SCORE:-300}
|
REC_PASS_SCORE: ${REC_PASS_SCORE:-300}
|
||||||
TZ: Asia/Jakarta
|
TZ: Asia/Jakarta
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
migrate:
|
||||||
condition: service_healthy
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- php_sessions:/var/lib/php/sessions
|
- php_sessions:/var/lib/php/sessions
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -62,6 +62,41 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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:
|
phpmyadmin:
|
||||||
image: phpmyadmin:5.2-apache
|
image: phpmyadmin:5.2-apache
|
||||||
restart: unless-stopped
|
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());
|
} while ($conn->more_results() && $conn->next_result());
|
||||||
fwrite(STDOUT, "Applied: $file\n");
|
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");
|
fwrite(STDOUT, "Database ready. Existing accounts and test data were preserved.\n");
|
||||||
} catch (Throwable $error) {
|
} 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);
|
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,
|
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
|
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,
|
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
|
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
|
[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
|
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.
|
file path at `.env`: Komodo writes it and passes it to Compose.
|
||||||
|
|
||||||
Deploy the Stack. **The app may initially be unhealthy** until you explicitly
|
Before deploying, transfer your trusted initial SQL dump to the Docker host and
|
||||||
import the base database and run migrations below. Do not enable a deploy
|
set `DB_INIT_SQL_PATH` as described below. Deploy the Stack: startup waits for
|
||||||
`--wait` flag on this first launch: it cannot become ready before that setup.
|
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
|
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
|
**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).
|
Official reference: [Komodo Compose Stacks](https://komo.do/docs/deploy/compose).
|
||||||
|
|
||||||
## 2. Import the database once
|
## 2. Automatic database initialization
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Obtain a trusted recruitment SQL export containing `in_daper`, `in_soal`, and
|
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**,
|
`in_jawab`. Transfer it privately to the **Docker host**, outside the
|
||||||
for example `/srv/recruitment-private/initial.sql`, with restricted permissions.
|
checkout/document root, for example `/srv/recruitment-private/initial.sql`.
|
||||||
The repository currently also tracks an export under `DB/`; it may contain
|
Restrict access to the file and set this in the Stack Environment (or `.env`):
|
||||||
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.
|
|
||||||
|
|
||||||
**Only import into a new, empty database.** Dumps can contain `DROP TABLE` and
|
```dotenv
|
||||||
existing accounts/passwords. Check first; this should return **no table names**:
|
DB_INIT_SQL_PATH=/srv/recruitment-private/initial.sql
|
||||||
|
|
||||||
```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"'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```sh
|
||||||
docker compose --env-file .env -p recruitment-hc exec -T db sh -c \
|
docker compose --env-file .env -p recruitment-hc ps -a
|
||||||
'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE"' \
|
docker compose --env-file .env -p recruitment-hc logs db-init migrate
|
||||||
< /srv/recruitment-private/initial.sql
|
|
||||||
|
|
||||||
docker compose --env-file .env -p recruitment-hc exec -T app \
|
|
||||||
php /usr/local/share/recruitment/migrate.php
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The migration command requires the three base tables, creates the admin and exam
|
The import guard has a database-free regression suite:
|
||||||
protection tables, and handles the historical admin identity schema when needed.
|
`python3 dev/test-db-init.py`.
|
||||||
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
|
**Failed import:** SQL DDL is not transactional. An `importing` marker deliberately
|
||||||
an existing database. Future migrations must be added to the migration runner as
|
blocks retries, including when only some tables were restored. Inspect the logs
|
||||||
part of their release.
|
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**:
|
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
|
Add these new settings from `.env.example` to the Komodo Stack Environment and
|
||||||
redeploy. `PHPMYADMIN_PORT` defaults to `8087`; `PHPMYADMIN_UPLOAD_LIMIT` defaults
|
redeploy. `PHPMYADMIN_PORT` defaults to `8087`; `PHPMYADMIN_UPLOAD_LIMIT` defaults
|
||||||
to `64M`. For browser imports, adjust your reverse proxy's upload size/timeouts as
|
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.
|
needed; large dumps are better handled by the setup service in section 2.
|
||||||
Browser imports can also overwrite data: only import into a verified empty
|
Browser imports can also overwrite data. Prefer the automatic `db-init` service
|
||||||
database, then run the migration command. No database initialization is automated.
|
for first-time setup; do not import another dump over an initialized database.
|
||||||
|
|
||||||
## 4. Verify
|
## 4. Verify
|
||||||
|
|
||||||
```sh
|
```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:8086/healthz
|
||||||
curl --fail http://127.0.0.1:8087/ > /dev/null
|
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
|
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
|
umask 077
|
||||||
mkdir -p /srv/recruitment-private/backups
|
mkdir -p /srv/recruitment-private/backups
|
||||||
docker compose --env-file .env -p recruitment-hc exec -T db sh -c \
|
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"
|
> "/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.
|
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.
|
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
|
To restore into a new empty stack, set `DB_INIT_SQL_PATH` to the trusted backup
|
||||||
database or explicitly approving replacement of the target data**; migrate after
|
and deploy; initialization and migrations run automatically. Restoring over an
|
||||||
restoring if needed.
|
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**
|
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.
|
enabled. The database volume is reused; successful imports are never repeated.
|
||||||
Apply migration changes explicitly when a release requires them. Rolling back an
|
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 does not undo schema changes; use a tested database restore if necessary.
|
||||||
Image tags track compatible patch releases; consider pinning tested image digests
|
Image tags track compatible patch releases; consider pinning tested image digests
|
||||||
in production for reproducible upgrades.
|
in production for reproducible upgrades.
|
||||||
@@ -246,11 +267,13 @@ From the repository root on the server:
|
|||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
chmod 600 .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 config --quiet
|
||||||
docker compose --env-file .env -p recruitment-hc up -d --build
|
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
|
`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.
|
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
|
gitea.cptckp.com/nurinda/recruitment-hc
|
||||||
```
|
```
|
||||||
|
|
||||||
Only the PHP/Apache app is built; MySQL and phpMyAdmin retain their official
|
Only the PHP/Apache image is built (used by both `app` and `migrate`); MySQL,
|
||||||
images. Database exports and host credentials are excluded by `.dockerignore`.
|
`db-init`, and phpMyAdmin retain their official images. Database exports and host credentials are excluded by `.dockerignore`.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ Stack run directory, for example `compose.image.yaml`:
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: gitea.cptckp.com/nurinda/recruitment-hc:latest
|
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
|
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
|
- 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.
|
packages, use a token with **Packages: Read** on an account allowed to pull them.
|
||||||
Keep this separate from the CI write token.
|
Keep this separate from the CI write token.
|
||||||
- Keep the Compose project name and volumes unchanged. Imports/migrations remain
|
- Use the same tested image/digest for `app` and `migrate`.
|
||||||
explicit; follow [the deployment guide](deployment.md).
|
- 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
|
`docker compose ... up -d --no-build` for manual deployments of the CI image
|
||||||
(after registry login and `docker compose ... pull`); do **not** use `--build`.
|
(after registry login and `docker compose ... pull`); do **not** use `--build`.
|
||||||
Komodo's Auto pull supplies the image before `up`, with Run build disabled.
|
Komodo's Auto pull supplies the image before `up`, with Run build disabled.
|
||||||
|
|||||||
Reference in new issue
Block a user