Server: MySQL support (portable schema + driver-aware upsert)
Some VPS configs can't get pdo_sqlite at all — Ubuntu 20.04 with Ondrej Sury's PHP 8.3 builds doesn't ship php8.3-sqlite3, and OS upgrade isn't always an option. Make MySQL the documented default while keeping SQLite working where it's available. - Schema (0001_initial.sql): TEXT → VARCHAR(N), INTEGER → BIGINT, keys declared with explicit PRIMARY KEY (...) syntax. Drops the previously unused request_log table (it had AUTO_INCREMENT, which spells differently on each engine and nothing wrote to it anyway). Both engines accept the new column types and indexes are IF NOT EXISTS for retry-safety. - Migrations.php: guard commit()/rollBack() with inTransaction(). MySQL implicitly commits any open transaction the moment it sees a DDL statement, so by the time we explicitly commit() the transaction is already gone and PDO throws "There is no active transaction". Same schema in PHP CREATE TABLE migrations also moved to VARCHAR/BIGINT. - Store::upsertStep: driver-detect via PDO::ATTR_DRIVER_NAME and emit ON DUPLICATE KEY UPDATE for MySQL, ON CONFLICT (...) DO UPDATE for SQLite/PostgreSQL. VALUES(col) (vs new.col aliasing) for MySQL 5.7 compatibility. - Db.php: when DSN is mysql:, SET NAMES utf8mb4 + sql_mode strict on every session so we get sane behaviour regardless of server defaults. SQLite branch (PRAGMA foreign_keys/journal_mode/synchronous) unchanged. - config.php.example: MySQL DSN is now the default + an inline SQLite alternative block. - DEPLOY.md: new "Database — MySQL or SQLite" section explaining when to pick which and showing the CREATE DATABASE / CREATE USER / GRANT statements. Install snippet split so SQLite-only steps (mkdir data, chmod 770) are clearly optional. Verified end-to-end on a live MySQL 8.0.34 box: POST creates session (201), PUT step inserts (200) and updates via the upsert branch (200), GET returns the round-tripped state, /sites lists distinct site_keys. SQLite path still re-applies the migration idempotently locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,68 @@ Example using `/home/www/healthcheck`:
|
||||
> `sudo chmod 755 /home/www` once is usually enough. `/var/www` doesn't have
|
||||
> this problem (always world-readable by default).
|
||||
|
||||
## Database — MySQL or SQLite
|
||||
|
||||
The code runs against either MySQL 8.0+ or SQLite 3.24+. The `config.php.example`
|
||||
defaults to **MySQL** because the `php-sqlite3` extension is unmaintained
|
||||
on some older distributions (Ubuntu 20.04 + Ondrej Sury's PHP 8.3 build is
|
||||
the case that bit us — the SQLite extension is no longer packaged for that
|
||||
combination, and you can't upgrade the OS without disturbing other live
|
||||
services).
|
||||
|
||||
### MySQL setup
|
||||
|
||||
As root on the DB server (root via socket auth works on a stock Ubuntu MySQL):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE att_hc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'att_hc'@'localhost' IDENTIFIED BY 'STRONG_RANDOM_PASSWORD';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, ALTER, REFERENCES
|
||||
ON att_hc.* TO 'att_hc'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
The grant is scoped to `att_hc.*` only — the user can't see or touch other
|
||||
databases on a shared box. Generate the password with:
|
||||
|
||||
```sh
|
||||
php -r 'echo bin2hex(random_bytes(18)), PHP_EOL;'
|
||||
```
|
||||
|
||||
Then in `config.php`:
|
||||
|
||||
```php
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4',
|
||||
'db_user' => 'att_hc',
|
||||
'db_pass' => 'STRONG_RANDOM_PASSWORD',
|
||||
```
|
||||
|
||||
The schema is created on the first authenticated request (the migration
|
||||
runner runs CREATE TABLE IF NOT EXISTS for everything). You can pre-trigger
|
||||
it by hitting any authenticated endpoint, e.g.:
|
||||
|
||||
```sh
|
||||
curl -s -H "Authorization: Bearer <api_key>" \
|
||||
https://healthcheck.example.com/sites?limit=1
|
||||
```
|
||||
|
||||
After which `SHOW TABLES IN att_hc;` should list `healthchecks`,
|
||||
`step_updates`, `migrations`.
|
||||
|
||||
### SQLite setup (if `pdo_sqlite` is available)
|
||||
|
||||
Set in `config.php`:
|
||||
|
||||
```php
|
||||
'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite',
|
||||
'db_user' => null,
|
||||
'db_pass' => null,
|
||||
```
|
||||
|
||||
The DB file is created automatically inside `data/` on first request, so
|
||||
that directory needs to be writable by the web user. See the install
|
||||
snippet below for the `mkdir + chmod 770` step.
|
||||
|
||||
## First-time install
|
||||
|
||||
```sh
|
||||
@@ -56,12 +118,15 @@ rsync -avz --exclude='data/' --exclude='config.php' \
|
||||
cd /home/www/healthcheck
|
||||
cp config.php.example config.php
|
||||
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate api_key
|
||||
$EDITOR config.php # paste it in
|
||||
$EDITOR config.php # paste it in + DB creds
|
||||
|
||||
# Only needed for the SQLite backend — MySQL skips this:
|
||||
mkdir -p data
|
||||
sudo chown -R www-data:www-data data/ config.php
|
||||
sudo chmod 640 config.php
|
||||
sudo chown -R www-data:www-data data/
|
||||
sudo chmod 770 data/
|
||||
|
||||
sudo chown www-data:www-data config.php
|
||||
sudo chmod 640 config.php
|
||||
```
|
||||
|
||||
## Apache vhost (example)
|
||||
|
||||
@@ -8,11 +8,24 @@ return [
|
||||
// Shared secret. Plugin sends this as `Authorization: Bearer <key>`.
|
||||
'api_key' => 'REPLACE_WITH_A_LONG_RANDOM_STRING',
|
||||
|
||||
// PDO DSN. SQLite default — file path is resolved relative to server/.
|
||||
// For MySQL: 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4'
|
||||
'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite',
|
||||
'db_user' => null,
|
||||
'db_pass' => null,
|
||||
// PDO DSN.
|
||||
//
|
||||
// MySQL (recommended when pdo_sqlite isn't available — older Ubuntu boxes
|
||||
// tend to be in this state):
|
||||
// 'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4'
|
||||
// 'db_user' => 'att_hc'
|
||||
// 'db_pass' => '<password>'
|
||||
//
|
||||
// See DEPLOY.md → "MySQL setup" for the CREATE DATABASE / CREATE USER /
|
||||
// GRANT statements.
|
||||
//
|
||||
// SQLite (simplest when the box has php8.x-sqlite3 installed):
|
||||
// 'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite'
|
||||
// 'db_user' => null
|
||||
// 'db_pass' => null
|
||||
'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4',
|
||||
'db_user' => 'att_hc',
|
||||
'db_pass' => 'REPLACE_WITH_DB_PASSWORD',
|
||||
|
||||
// Server version, surfaced on GET /. Bump on deploy.
|
||||
'version' => '0.1.0',
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
-- Portable across SQLite and MySQL 8.0+:
|
||||
-- - VARCHAR(N) is honoured by MySQL, treated as TEXT by SQLite.
|
||||
-- - BIGINT covers post-2038 unix seconds; SQLite stores as INTEGER affinity.
|
||||
-- - No AUTO_INCREMENT in this file — we don't need it for the current tables.
|
||||
-- (An audit table that needs an autoincrement id can come in a later
|
||||
-- migration with per-driver dispatch in Migrations.php.)
|
||||
--
|
||||
-- Re-running is safe: Migrations.php only applies each .sql file once
|
||||
-- (tracked in the `migrations` table created in PHP).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS healthchecks (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_key TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER NULL,
|
||||
technician_id INTEGER NULL,
|
||||
reporting_url TEXT NOT NULL,
|
||||
wp_version TEXT NULL,
|
||||
php_version TEXT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
id VARCHAR(64) NOT NULL,
|
||||
site_key VARCHAR(255) NOT NULL,
|
||||
started_at BIGINT NOT NULL,
|
||||
finished_at BIGINT NULL,
|
||||
technician_id BIGINT NULL,
|
||||
reporting_url VARCHAR(512) NOT NULL,
|
||||
wp_version VARCHAR(32) NULL,
|
||||
php_version VARCHAR(32) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (site_key, started_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS healthchecks_site_started_idx
|
||||
ON healthchecks (site_key, started_at DESC);
|
||||
ON healthchecks (site_key, started_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS healthchecks_site_finished_idx
|
||||
ON healthchecks (site_key, finished_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS step_updates (
|
||||
healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE,
|
||||
step_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
healthcheck_id VARCHAR(64) NOT NULL,
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
autocheck_json TEXT NULL,
|
||||
reporting_url TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (healthcheck_id, step_id)
|
||||
reporting_url VARCHAR(512) NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (healthcheck_id, step_id),
|
||||
CONSTRAINT fk_step_updates_hc FOREIGN KEY (healthcheck_id)
|
||||
REFERENCES healthchecks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS step_updates_step_updated_idx
|
||||
ON step_updates (step_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
ip TEXT NULL,
|
||||
bytes_in INTEGER NULL,
|
||||
bytes_out INTEGER NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts DESC);
|
||||
ON step_updates (step_id, updated_at);
|
||||
|
||||
@@ -38,6 +38,12 @@ final class Db {
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
$pdo->exec('PRAGMA synchronous = NORMAL');
|
||||
} elseif (str_starts_with($dsn, 'mysql:')) {
|
||||
// utf8mb4 should already be in the DSN, but force it on the
|
||||
// session so we get 4-byte UTF-8 even if the server default
|
||||
// is something narrower (older boxes default to utf8mb3).
|
||||
$pdo->exec("SET NAMES utf8mb4");
|
||||
$pdo->exec("SET SESSION sql_mode = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION'");
|
||||
}
|
||||
|
||||
self::$pdo = $pdo;
|
||||
|
||||
@@ -11,9 +11,11 @@ final class Migrations {
|
||||
* Idempotent: tracks applied filenames in the `migrations` table.
|
||||
*/
|
||||
public static function ensureRan(PDO $pdo): void {
|
||||
// VARCHAR(255) instead of TEXT — MySQL won't index TEXT without a
|
||||
// prefix length. SQLite ignores the length and stores as TEXT affinity.
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL
|
||||
)');
|
||||
|
||||
$dir = __DIR__ . '/../migrations';
|
||||
@@ -36,14 +38,20 @@ final class Migrations {
|
||||
throw new \RuntimeException('failed to read migration: ' . $name);
|
||||
}
|
||||
|
||||
// MySQL implicitly commits any open transaction the moment it sees
|
||||
// a DDL statement (CREATE TABLE, CREATE INDEX, ALTER, …). That means
|
||||
// commit()/rollBack() may have nothing left to act on once $sql has
|
||||
// been exec()'d. Guard both sides with inTransaction(). SQLite
|
||||
// doesn't have the implicit-commit behaviour, so on SQLite the
|
||||
// transaction still wraps the whole migration atomically.
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
$stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (?, ?)');
|
||||
$stmt->execute([$name, time()]);
|
||||
$pdo->commit();
|
||||
if ($pdo->inTransaction()) $pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw new \RuntimeException('migration failed: ' . $name . ' — ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,15 +44,36 @@ final class Store {
|
||||
}
|
||||
|
||||
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
||||
$stmt = Db::pdo()->prepare('INSERT INTO step_updates
|
||||
$pdo = Db::pdo();
|
||||
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
|
||||
$base = 'INSERT INTO step_updates
|
||||
(healthcheck_id, step_id, status, notes, autocheck_json, reporting_url, updated_at)
|
||||
VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)
|
||||
VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)';
|
||||
|
||||
if ($driver === 'mysql') {
|
||||
// MySQL: VALUES(col) in ON DUPLICATE KEY UPDATE is deprecated in 8.0.20+
|
||||
// but still works; the new spelling needs an alias on the row. Stick
|
||||
// with VALUES() for broader compatibility back to MySQL 5.7.
|
||||
$sql = $base . '
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
notes = VALUES(notes),
|
||||
autocheck_json = VALUES(autocheck_json),
|
||||
reporting_url = VALUES(reporting_url),
|
||||
updated_at = VALUES(updated_at)';
|
||||
} else {
|
||||
// SQLite (and PostgreSQL, by happy accident).
|
||||
$sql = $base . '
|
||||
ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
notes = excluded.notes,
|
||||
autocheck_json = excluded.autocheck_json,
|
||||
reporting_url = excluded.reporting_url,
|
||||
updated_at = excluded.updated_at');
|
||||
updated_at = excluded.updated_at';
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
':hc' => $healthcheckId,
|
||||
':sid' => $stepId,
|
||||
@@ -63,7 +84,7 @@ final class Store {
|
||||
':updated_at' => time(),
|
||||
]);
|
||||
// Bump parent's updated_at so list views sort sensibly.
|
||||
Db::pdo()->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
|
||||
->execute([time(), $healthcheckId]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user