Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 285 additions & 46 deletions .docker/README.md → .dev/README.md

Large diffs are not rendered by default.

41 changes: 25 additions & 16 deletions .docker/ci.sh → .dev/ci.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# Runs what CI runs, before you push instead of after.
#
# .docker/ci.sh every check
# .docker/ci.sh --full style check over the whole tree, not just changes
# .docker/ci.sh --fix apply the style fixes rather than reporting them
# .dev/ci.sh every check
# .dev/ci.sh --full style check over the whole tree, not just changes
# .dev/ci.sh --fix apply the style fixes rather than reporting them
# .dev/ci.sh --docker run the checks in the web container
#
# The workflows this mirrors are php.yml (sign-off, the file integrity checks,
# phplint) and php-cs-fixer.yml. phpunit.yml is included when the branch has a
Expand All @@ -12,9 +13,10 @@
# Every check runs even after one fails, because finding out about the second
# problem on the next push is the thing this script exists to stop.
#
# One difference worth knowing: CI lints and tests on PHP 8.4 *and* 8.5, and the
# web container is whichever PHP_VERSION built it (8.4 by default). To cover the
# other one, rebuild against it:
# One difference worth knowing: CI lints and tests on PHP 8.4 *and* 8.5, while
# this runs on one of them -- whichever php is on PATH, or whichever PHP_VERSION
# built the web container. To cover the other one, point PHP_BIN at it, or
# rebuild the image against it:
#
# PHP_VERSION=8.5 docker compose up -d --build web
#
Expand All @@ -23,14 +25,17 @@ set -uo pipefail

. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"

parse_runner_args "$@"

FULL=0
FIX=0

while [ $# -gt 0 ]; do
case "$1" in
--full) FULL=1; shift ;;
--fix) FIX=1; shift ;;
-h|--help) sed -n '2,21p' "${BASH_SOURCE[0]}"; exit 0 ;;
--docker|--local) shift ;;
-h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
Expand All @@ -39,19 +44,23 @@ done
# check does not stop the ones after it. That means cd has to be checked.
cd "$BOARD_DIR" || die "cannot enter $BOARD_DIR"

docker compose ps --status running --services 2>/dev/null | grep -qx web \
|| die 'the web container is not running -- docker compose up -d'
if is_docker; then
docker compose ps --status running --services 2>/dev/null | grep -qx web \
|| die 'the web container is not running -- docker compose up -d'
else
require_local_deps
fi

FAILED=''

# $1 label, rest: the command to run in the web container.
# $1 label, rest: the command to run where the forum lives.
check() {
local label="$1"
shift

printf '\n[smf-dev] --- %s ---\n' "$label"

if docker compose exec -T web "$@"; then
if run_cmd "$@"; then
return 0
fi

Expand All @@ -72,8 +81,8 @@ check 'file integrity' sh -c '
echo "all four integrity checks passed"
'

check "syntax ($(docker compose exec -T web php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;' 2>/dev/null))" \
vendor/bin/phplint --no-progress --exclude .git --exclude vendor .
check "syntax ($(run_cmd php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;' 2>/dev/null))" \
php vendor/bin/phplint --no-progress --exclude .git --exclude vendor .

# ------------------------------------------------------------ php-cs-fixer.yml
# CI checks only the files a pull request changed, and switches to the whole
Expand All @@ -90,7 +99,7 @@ else
fi

if [ "$FULL" -eq 1 ]; then
check 'code style (whole tree)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}"
check 'code style (whole tree)' php vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}"
else
# Same intersection CI builds, from the files this branch actually touches:
# committed since release-3.0, staged, unstaged, and - the one CI never has
Expand All @@ -107,13 +116,13 @@ else
printf '\n[smf-dev] --- code style --- no changed PHP files\n'
else
# shellcheck disable=SC2086
check 'code style (changed files)' vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" --path-mode=intersection $CHANGED
check 'code style (changed files)' php vendor/bin/php-cs-fixer "$FIXER_MODE" "${FIXER_ARGS[@]}" --path-mode=intersection $CHANGED
fi
fi

# ---------------------------------------------------------------- phpunit.yml
if [ -f phpunit.xml.dist ]; then
check 'tests' vendor/bin/phpunit --no-coverage --colors=always
check 'tests' php vendor/bin/phpunit --no-coverage --colors=always
else
printf '\n[smf-dev] --- tests --- no phpunit.xml.dist on this branch, skipping\n'
fi
Expand Down
22 changes: 15 additions & 7 deletions .docker/compare-upgrade.sh → .dev/compare-upgrade.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# Upgrades a 2.1 database to 3.0, installs 3.0 from scratch, and reports where
# the two schemas disagree.
#
# .docker/compare-upgrade.sh --engine mysql --baseline ../SMF-2.1/.docker/baseline/artifacts/2.1.7-1/small/mysql.sql
# .docker/compare-upgrade.sh --engine postgresql --baseline ../SMF-2.1/.docker/baseline/artifacts/2.1.7-1/small/postgres.sql
# .dev/compare-upgrade.sh --engine mysql --baseline ../SMF-2.1/.docker/baseline/artifacts/2.1.7-1/small/mysql.sql
# .dev/compare-upgrade.sh --engine postgresql --baseline ../SMF-2.1/.docker/baseline/artifacts/2.1.7-1/small/postgres.sql
#
# The installer builds the schema from Sources/Db/Schema/v3_0/ in one go. The
# upgrader arrives at the same place through a hundred-odd migrations applied
Expand All @@ -24,11 +24,19 @@
# Runs on the host. Expect five to ten minutes per engine.
set -euo pipefail

# Orchestrating containers is all this can mean, so it says so rather than
# inheriting the default and failing further in. Read by lib.sh, which is
# sourced below and which a linter reading this file alone cannot see.
# shellcheck disable=SC2034
SMF_RUNNER=docker

. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"

require_docker

ENGINE=''
BASELINE=''
OUT="$DOCKER_DIR/compare"
OUT="$DEV_DIR/compare"

while [ $# -gt 0 ]; do
case "$1" in
Expand Down Expand Up @@ -65,7 +73,7 @@ esac
snapshot() {
local smf_type="$1" label="$2" file="$3"

docker compose exec -T web php .docker/schema-tool.php dump \
docker compose exec -T web php .dev/schema-tool.php dump \
--engine "$smf_type" \
--db "$DB_NAME" \
--prefix "$DB_PREFIX" \
Expand Down Expand Up @@ -106,7 +114,7 @@ compare_one() {

# ---------------------------------------------------------- the upgrade
log "${smf_type}: emptying the database"
"$DOCKER_DIR/reset.sh" --engine "$smf_type" >/dev/null
"$DEV_DIR/reset.sh" --docker --engine "$smf_type" >/dev/null

log "${smf_type}: loading ${BASELINE##*/}"
load_baseline "$smf_type"
Expand Down Expand Up @@ -163,14 +171,14 @@ compare_one() {
# --force because there is an installed forum now, and install-forum.sh
# leaves one alone unless told otherwise.
log "${smf_type}: installing from scratch"
"$DOCKER_DIR/install-forum.sh" --engine "$smf_type" --force >/dev/null
"$DEV_DIR/install-forum.sh" --docker --engine "$smf_type" --force >/dev/null

snapshot "$smf_type" fresh "$OUT/fresh-${smf_type}.json"

# ------------------------------------------------------------ the report
local status=0

docker compose exec -T web php .docker/schema-tool.php diff \
docker compose exec -T web php .dev/schema-tool.php diff \
"${OUT_REL}/fresh-${smf_type}.json" \
"${OUT_REL}/upgraded-${smf_type}.json" \
> "$OUT/report-${smf_type}.txt" || status=$?
Expand Down
169 changes: 169 additions & 0 deletions .dev/db.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

/**
* Runs one statement against a development database, for the .dev scripts.
*
* Under docker they reach the database through the mysql and psql clients that
* are already inside the container. There is nothing to reach for locally, and
* asking people to install a command line client in order to run the tests
* would be a new dependency for something SMF can already do: mysqli and pgsql
* are what the forum itself connects with, so any machine that can serve SMF
* can do this.
*
* php .dev/db.php --engine mysql --sql 'SELECT 1'
* php .dev/db.php --engine postgresql --sql 'DROP SCHEMA ...' --database ''
*
* Rows come back one per line, columns separated by tabs and nothing quoted,
* which is what `mysql -N -B` and `psql -tAX` produce and what the callers
* already parse. Anything that goes wrong is a message on stderr and a non-zero
* exit, so an `|| true` in the caller still means what it says.
*
* Talks to the database directly rather than through SMF: nothing here loads
* Settings.php or boots the forum, because the callers need this to work before
* there is a forum to boot. The database is a throwaway development one, the
* credentials arrive from the calling script, and the SQL is written in those
* scripts rather than anywhere a user can reach.
*/

declare(strict_types=1);

$options = getopt('', ['engine:', 'sql:', 'server:', 'port:', 'database:', 'user:', 'password:']);

foreach (['engine', 'sql'] as $required) {
if (!isset($options[$required])) {
fwrite(STDERR, "db.php: --{$required} is required\n");

exit(2);
}
}

$engine = in_array($options['engine'], ['postgresql', 'postgres', 'pgsql'], true) ? 'postgresql' : 'mysql';
$server = (string) ($options['server'] ?? '127.0.0.1');
$port = (int) ($options['port'] ?? ($engine === 'mysql' ? 3306 : 5432));
$database = (string) ($options['database'] ?? 'smf');
$user = (string) ($options['user'] ?? 'smf');
$password = (string) ($options['password'] ?? 'smf');
$sql = (string) $options['sql'];

/**
* Says the extension is missing in terms of what to install, then gives up.
*
* @param string $extension The extension that is not loaded.
*/
function missing(string $extension): never
{
fwrite(STDERR, "db.php: the {$extension} extension is not loaded in " . PHP_BINARY . "\n");
fwrite(STDERR, " SMF needs it to talk to this engine at all. Install it, or\n");
fwrite(STDERR, " pass --docker to use the compose stack instead.\n");

exit(3);
}

/**
* Prints rows the way the command line clients do: tab separated, unquoted.
*
* @param array $rows Rows of scalar values.
*/
function emit(array $rows): void
{
foreach ($rows as $row) {
echo implode("\t", array_map(static fn ($value) => (string) $value, $row)), "\n";
}
}

if ($engine === 'mysql') {
if (!extension_loaded('mysqli')) {
missing('mysqli');
}

// Off, so a failure arrives as a return value to report rather than as an
// exception with a stack trace nobody reading a shell script wants.
mysqli_report(MYSQLI_REPORT_OFF);

// An empty database name is how the callers connect with no database yet,
// which is what dropping and recreating one needs.
$connection = @new mysqli($server, $user, $password, $database, $port);

if ($connection->connect_errno !== 0) {
fwrite(STDERR, "db.php: could not connect to mysql at {$server}:{$port} as {$user}: " . $connection->connect_error . "\n");

exit(1);
}

// The callers send several statements at once when they reset a database,
// so this is multi_query rather than query.
if ($connection->multi_query($sql) === false) {
fwrite(STDERR, 'db.php: ' . $connection->error . "\n");

exit(1);
}

while (true) {
$result = $connection->store_result();

if ($result instanceof mysqli_result) {
emit($result->fetch_all(MYSQLI_NUM));
$result->free();
}

// An error can surface on any statement after the first, where
// multi_query() has already said yes. It arrives two ways: as an errno
// on the current statement, or as next_result() refusing to move on.
// Testing only the first, or folding next_result() into a loop
// condition, is how a batch that failed halfway reports success.
if ($connection->errno !== 0) {
fwrite(STDERR, 'db.php: ' . $connection->error . "\n");

exit(1);
}

if (!$connection->more_results()) {
break;
}

if (!$connection->next_result()) {
fwrite(STDERR, 'db.php: ' . $connection->error . "\n");

exit(1);
}
}

exit(0);
}

if (!function_exists('pg_connect')) {
missing('pgsql');
}

$dsn = sprintf(
"host='%s' port=%d dbname='%s' user='%s' password='%s'",
addslashes($server),
$port,
addslashes($database),
addslashes($user),
addslashes($password),
);

$connection = @pg_connect($dsn);

if ($connection === false) {
fwrite(STDERR, "db.php: could not connect to postgresql at {$server}:{$port} as {$user}\n");

exit(1);
}

$result = @pg_query($connection, $sql);

if ($result === false) {
fwrite(STDERR, 'db.php: ' . pg_last_error($connection) . "\n");

exit(1);
}

// pg_num_fields() is 0 for a statement that returns no rows, such as the DDL
// the reset uses, and fetch_all() on that emits a warning rather than nothing.
if (pg_num_fields($result) > 0) {
emit(pg_fetch_all($result, PGSQL_NUM));
}

exit(0);
Loading