diff --git a/Dockerfile b/Dockerfile index e24b8b1..755073d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -97,11 +97,20 @@ WORKDIR /var/www/html RUN composer update RUN composer require phpmailer/phpmailer:^6.9 COPY ./src ./ +COPY ./db/migrations ./db/migrations RUN chmod 755 *; # Qr code storage lives outside the document root so files can only be reached through # the authenticated qrcode_image.php / qrcode_zip_download.php endpoints. RUN mkdir -p /var/www/qrcode-storage/zip && chmod -R 777 /var/www/qrcode-storage +# Applies any not-yet-applied db/migrations/*.sql on every container start (see +# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only +# on a brand new volume, so without this an existing install's schema silently falls +# behind the code on every `git pull` + restart. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + EXPOSE 80 CMD ["php", "-S", "0.0.0.0:80"] diff --git a/Dockerfile.fpm b/Dockerfile.fpm index f8d373a..6d5c352 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -51,7 +51,9 @@ RUN cp -R ./php-qrcode/src /var/www/html/ WORKDIR /var/www/html RUN composer update +RUN composer require phpmailer/phpmailer:^6.9 COPY ./src ./ +COPY ./db/migrations ./db/migrations RUN chown -R www-data:www-data /var/www/html \ && find /var/www/html -type f -exec chmod 644 {} \; \ && find /var/www/html -type d -exec chmod 755 {} \; @@ -62,5 +64,13 @@ RUN mkdir -p /var/www/qrcode-storage/zip \ && chown -R www-data:www-data /var/www/qrcode-storage \ && chmod -R 775 /var/www/qrcode-storage +# Applies any not-yet-applied db/migrations/*.sql on every container start (see +# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only +# on a brand new volume, so without this an existing install's schema silently falls +# behind the code on every `git pull` + restart. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + EXPOSE 9000 CMD ["php-fpm"] diff --git a/README.md b/README.md index 328bb03..f3b0ac4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/). # What is included - PHP 8.4 application source -- Database schema + migrations +- Database schema + migrations (applied automatically on every container start, + so `git pull` + restart is enough to bring an existing install up to date) - Docker Compose files (dev and production) - CSS/JS assets diff --git a/docker-compose.yml b/docker-compose.yml index aa7961b..78485af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,11 +16,9 @@ services: DATABASE_PREFIX: "${DATABASE_PREFIX:-}" DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}" ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}" - # This file is also what vm420 runs in production (not docker-compose.prod.yml - - # see the "prod" naming is misleading, this is the actually-deployed one). Defaults - # here are dev-convenience only (mailhog, no auth) - a real deployment's .env must - # override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD explicitly, same as - # DATABASE_PASSWORD above already requires. + # Defaults here are dev-convenience only (mailhog, no auth) - a real deployment's + # .env must override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD + # explicitly, same as DATABASE_PASSWORD above already requires. MAIL_HOST: "${MAIL_HOST:-mailhog}" MAIL_PORT: "${MAIL_PORT:-1025}" MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-}" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..dd407bb --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +php /var/www/html/scripts/migrate.php + +exec docker-php-entrypoint "$@" diff --git a/src/scripts/migrate.php b/src/scripts/migrate.php new file mode 100644 index 0000000..ff0fc97 --- /dev/null +++ b/src/scripts/migrate.php @@ -0,0 +1,72 @@ +connect_errno) { + fwrite(STDERR, "migrate.php: could not connect to database: {$mysqli->connect_error}\n"); + exit(1); +} +$mysqli->set_charset(DATABASE_CHARSET); + +$mysqli->query( + 'CREATE TABLE IF NOT EXISTS schema_migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8' +); + +$files = glob(__DIR__ . '/../db/migrations/*.sql'); +sort($files, SORT_STRING); + +$applied = []; +$result = $mysqli->query('SELECT filename FROM schema_migrations'); +while ($row = $result->fetch_assoc()) { + $applied[$row['filename']] = true; +} + +$ran = 0; +foreach ($files as $file) { + $filename = basename($file); + if (isset($applied[$filename])) { + continue; + } + + echo "Applying migration: $filename\n"; + + if (!$mysqli->multi_query(file_get_contents($file))) { + fwrite(STDERR, "migrate.php: failed to apply $filename: {$mysqli->error}\n"); + exit(1); + } + // multi_query queues result sets asynchronously - drain them all before the next + // query, and check for a mid-batch error on each one. + do { + if ($res = $mysqli->store_result()) { + $res->free(); + } + if ($mysqli->errno) { + fwrite(STDERR, "migrate.php: error while applying $filename: {$mysqli->error}\n"); + exit(1); + } + } while ($mysqli->more_results() && $mysqli->next_result()); + + $stmt = $mysqli->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, NOW())'); + $stmt->bind_param('s', $filename); + $stmt->execute(); + $stmt->close(); + + $ran++; +} + +echo $ran === 0 ? "No pending migrations.\n" : "Applied $ran migration(s).\n"; + +$mysqli->close();