From 25912a59c28e68153e612aec0a2ba80a3640b22e Mon Sep 17 00:00:00 2001 From: Brendan Smith Date: Tue, 1 Sep 2026 12:00:31 -0400 Subject: [PATCH 1/3] Default PORT for portless DATABASE_URLs and surface real psql errors urlparse().port is None when a DATABASE_URL omits the port, which rendered as the literal string "None" in ~/.pg_service.conf and ~/.my.cnf and broke every connection. Default to 5432/3306 in each parse_database_url.py so the generated config keeps its shape. Also stop swallowing the real psql error in the postgres wait_for_db retry loop -- it now surfaces the last stderr output once retries are exhausted instead of always reporting a generic timeout, so a misparsed URL diagnoses itself. Add regression coverage in both tests.sh scripts asserting PORT defaults correctly for a portless URL and that a live connection succeeds against the default port, since the existing DATABASE_URLs in docker-compose.test.yml always specified an explicit port and never exercised this path. Fixes #4 --- mysql/bin/parse_database_url.py | 2 +- mysql/tests/tests.sh | 10 ++++++++++ postgres/bin/entrypoint.sh | 7 ++++++- postgres/bin/parse_database_url.py | 2 +- postgres/tests/tests.sh | 8 ++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/mysql/bin/parse_database_url.py b/mysql/bin/parse_database_url.py index 4a5f78e..fc50525 100755 --- a/mysql/bin/parse_database_url.py +++ b/mysql/bin/parse_database_url.py @@ -9,7 +9,7 @@ 'USER': parsed.username, 'MYSQL_PWD': parsed.password, 'HOST': parsed.hostname, - 'PORT': parsed.port, + 'PORT': parsed.port or 3306, 'NAME': parsed.path.strip('/') } for k, v in env.items(): diff --git a/mysql/tests/tests.sh b/mysql/tests/tests.sh index 78bb45a..8e3e060 100755 --- a/mysql/tests/tests.sh +++ b/mysql/tests/tests.sh @@ -25,6 +25,16 @@ mysql test --execute "CREATE TABLE tbl (id INT AUTO_INCREMENT PRIMARY KEY, name mysql test --execute "INSERT INTO tbl (name) VALUES ('name1')" mysql test --execute "INSERT INTO tbl (name) VALUES ('name2')" +printf "\n###### Testing portless DATABASE_URL defaults PORT to 3306...\n" +PARSED_PORTLESS=$(DATABASE_URL="mysql://test:password@db/test" parse_database_url.py) +echo "$PARSED_PORTLESS" | grep "PORT=3306" > /dev/null +echo "✅ PORT defaulted to 3306 for a portless DATABASE_URL" + +printf "\n###### Testing connection succeeds with a portless DATABASE_URL...\n" +eval "$PARSED_PORTLESS" +mysql --host="$HOST" --port="$PORT" --user="$USER" --password="$MYSQL_PWD" "$NAME" --execute "SELECT 1" > /dev/null +echo "✅ mysql connected successfully using the parsed portless DATABASE_URL" + printf "\n###### Starting tests...\n" dump-to-s3.sh "s3://$BUCKET/dump.sql.gz" test printf "\n###### Verify dump file exists...\n" diff --git a/postgres/bin/entrypoint.sh b/postgres/bin/entrypoint.sh index 9d6d214..ffe5732 100755 --- a/postgres/bin/entrypoint.sh +++ b/postgres/bin/entrypoint.sh @@ -7,15 +7,20 @@ export PGSSLMODE=require wait_for_db() { local retries=30 local sleep_time=2 + local last_error="" echo "Waiting for PostgreSQL server to be ready..." - until psql "$DATABASE_URL" -c '\q' 2>/dev/null || [ "$retries" -eq 0 ]; do + until last_error=$(psql "$DATABASE_URL" -c '\q' 2>&1 >/dev/null) || [ "$retries" -eq 0 ]; do echo "PostgreSQL is unavailable - ($((retries--)) retries left)..." sleep "$sleep_time" done if [ "$retries" -eq 0 ]; then echo "ERROR: PostgreSQL server did not respond." + if [ -n "$last_error" ]; then + echo "Last error from psql:" + echo "$last_error" + fi exit 1 fi } diff --git a/postgres/bin/parse_database_url.py b/postgres/bin/parse_database_url.py index 221425b..418be9e 100755 --- a/postgres/bin/parse_database_url.py +++ b/postgres/bin/parse_database_url.py @@ -8,7 +8,7 @@ 'USER': parsed.username, 'PGPASSWORD': parsed.password, 'HOST': parsed.hostname, - 'PORT': parsed.port, + 'PORT': parsed.port or 5432, 'NAME': parsed.path.strip('/') } for k, v in env.items(): diff --git a/postgres/tests/tests.sh b/postgres/tests/tests.sh index 516e4b0..578845c 100755 --- a/postgres/tests/tests.sh +++ b/postgres/tests/tests.sh @@ -25,6 +25,14 @@ psql test -c "CREATE TABLE tbl (id SERIAL PRIMARY KEY, name CHAR(255) NOT NULL)" psql test -c "INSERT INTO tbl (name) VALUES ('name1')" psql test -c "INSERT INTO tbl (name) VALUES ('name2')" +printf "\n###### Testing portless DATABASE_URL defaults PORT to 5432...\n" +DATABASE_URL="postgres://test:password@db/test" parse_database_url.py | grep "PORT=5432" > /dev/null +echo "✅ PORT defaulted to 5432 for a portless DATABASE_URL" + +printf "\n###### Testing connection succeeds with a portless DATABASE_URL...\n" +psql "postgres://test:password@db/test" -c '\q' +echo "✅ psql connected successfully using a portless DATABASE_URL" + printf "\n###### Testing SERVER_VERSION override...\n" export SERVER_VERSION=17 DUMP_LOG=$(dump-to-s3.sh "s3://$BUCKET/explicit.dump" test 2>&1) From bc7c3f0d058039e1bd78120fbbb21c8d7d3e852b Mon Sep 17 00:00:00 2001 From: Brendan Smith Date: Tue, 1 Sep 2026 14:38:27 -0400 Subject: [PATCH 2/3] Pin test localstack image to last community-edition tag localstack/localstack:latest switched to a unified AWS image in March 2026 that requires a paid LOCALSTACK_AUTH_TOKEN to boot at all, even for plain S3, which breaks make test-postgres/test-mysql and CI with "Could not connect to the endpoint URL". Pin to 4.14.0, the last tag before that migration that still starts without a license. Revisit once the suite can target a supported, token-free community image. --- mysql/docker-compose.test.yml | 7 ++++++- postgres/docker-compose.test.yml | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/mysql/docker-compose.test.yml b/mysql/docker-compose.test.yml index 1e49d3a..d59a7b1 100644 --- a/mysql/docker-compose.test.yml +++ b/mysql/docker-compose.test.yml @@ -7,7 +7,12 @@ services: volumes: - db_data:/var/lib/mysql s3: - image: localstack/localstack + # Pinned: localstack/localstack:latest moved to a unified image in March + # 2026 that requires a paid LOCALSTACK_AUTH_TOKEN to boot at all, even + # for plain S3. 4.14.0 is the last community-edition tag that starts + # without a license. Revisit this pin once the suite can run against a + # supported, token-free community image again. + image: localstack/localstack:4.14.0 platform: linux/amd64 environment: SERVICES: s3 diff --git a/postgres/docker-compose.test.yml b/postgres/docker-compose.test.yml index 82dd2db..3f17e16 100644 --- a/postgres/docker-compose.test.yml +++ b/postgres/docker-compose.test.yml @@ -7,7 +7,12 @@ services: environment: POSTGRES_PASSWORD: password s3: - image: localstack/localstack + # Pinned: localstack/localstack:latest moved to a unified image in March + # 2026 that requires a paid LOCALSTACK_AUTH_TOKEN to boot at all, even + # for plain S3. 4.14.0 is the last community-edition tag that starts + # without a license. Revisit this pin once the suite can run against a + # supported, token-free community image again. + image: localstack/localstack:4.14.0 platform: linux/amd64 environment: SERVICES: s3 From fd6f1ddd278615b48a606e82ec994fc227141d49 Mon Sep 17 00:00:00 2001 From: Brendan Smith Date: Wed, 2 Sep 2026 12:46:31 -0400 Subject: [PATCH 3/3] Add database to mysql's ~/.my.cnf so a bare mysql picks the right DB The generated MySQL client config only carried host/port/user, so a bare `mysql` (no --database, no positional dbname) had no default database -- unlike psql, which already gets dbname from ~/.pg_service.conf. This blocks moving `apppack db shell` off a hardcoded --database=, which breaks for externally-managed MySQL databases where the app name and the DATABASE_URL's database name diverge. Add database=$NAME, but scoped to the [mysql] section rather than [client]: mysqladmin and mysqldump also read [client] and reject `database` as an unknown option, so putting it there broke the health check and dump/load flow. load-from-s3.sh's own DROP/CREATE cycle also needed --no-defaults + explicit connection flags, since the app DB user has no privileges outside of $NAME and $NAME doesn't exist for the moment between the drop and the create. For managed AppPack databases $NAME already equals the app name (the same value the CLI passes via --database today), so this is a no-op for that path. Add a tests.sh assertion that a bare `mysql` connects to the database named in DATABASE_URL, matching the exact behavior the CLI will depend on. --- mysql/bin/entrypoint.sh | 5 ++++- mysql/bin/load-from-s3.sh | 10 ++++++++-- mysql/tests/tests.sh | 20 ++++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/mysql/bin/entrypoint.sh b/mysql/bin/entrypoint.sh index 569c094..fd852dd 100755 --- a/mysql/bin/entrypoint.sh +++ b/mysql/bin/entrypoint.sh @@ -9,7 +9,10 @@ else # shellcheck disable=SC2046 export $(parse_database_url.py | xargs) # Setup .my.cnf so `mysql` just does the right thing - /bin/echo -e "[client]\nhost=$HOST\nport=$PORT\nuser=$USER" > ~/.my.cnf + # `database` only goes in [mysql] (read by the interactive mysql client); + # putting it in [client] breaks mysqladmin/mysqldump, which also read + # [client] but reject `database` as an unknown option. + /bin/echo -e "[client]\nhost=$HOST\nport=$PORT\nuser=$USER\n\n[mysql]\ndatabase=$NAME" > ~/.my.cnf fi exec "$@" diff --git a/mysql/bin/load-from-s3.sh b/mysql/bin/load-from-s3.sh index 4b51ced..96b90d6 100755 --- a/mysql/bin/load-from-s3.sh +++ b/mysql/bin/load-from-s3.sh @@ -12,8 +12,14 @@ echo "Downloading $S3_PATH ..." aws s3 cp --no-progress "$S3_PATH" /tmp/db.sql.gz echo "Drop/create $NAME..." -mysql --execute "DROP DATABASE IF EXISTS "'`'"$NAME"'`' -mysql --execute "CREATE DATABASE "'`'"$NAME"'`' +# ~/.my.cnf now defaults to $NAME as the database (so a bare `mysql` works +# for interactive use), but $NAME doesn't exist for the moment between the +# DROP and CREATE below, and the app DB user typically has no privileges +# on any other schema to fall back to. Bypass the defaults file for these +# two statements so no default database is selected; MYSQL_PWD still +# supplies the password since it's read directly from the environment. +mysql --no-defaults --host="$HOST" --port="$PORT" --user="$USER" --execute "DROP DATABASE IF EXISTS "'`'"$NAME"'`' +mysql --no-defaults --host="$HOST" --port="$PORT" --user="$USER" --execute "CREATE DATABASE "'`'"$NAME"'`' echo "Loading $S3_PATH into $NAME..." set -x diff --git a/mysql/tests/tests.sh b/mysql/tests/tests.sh index 8e3e060..3a6e72a 100755 --- a/mysql/tests/tests.sh +++ b/mysql/tests/tests.sh @@ -15,16 +15,24 @@ done echo "###### Setup test state" aws s3api create-bucket --bucket "$BUCKET" aws s3 rm --recursive "s3://$BUCKET/" -mysql -u root --execute 'DROP DATABASE IF EXISTS `test`' -mysql -u root --execute 'DROP DATABASE IF EXISTS `test-clone`' -mysql -u root --execute "DROP USER IF EXISTS 'test'" -mysql -u root --execute 'CREATE DATABASE `test`' -mysql -u root --execute "CREATE USER 'test'@'%' IDENTIFIED BY 'password'" -mysql -u root --execute 'GRANT ALL PRIVILEGES ON `test`.* TO `test`@`%`' +# `test` doesn't exist yet, and ~/.my.cnf now defaults to it (see [mysql] +# section written by entrypoint.sh), so these bootstrap statements target +# the always-present `mysql` system schema explicitly instead of relying +# on that default. +mysql -u root mysql --execute 'DROP DATABASE IF EXISTS `test`' +mysql -u root mysql --execute 'DROP DATABASE IF EXISTS `test-clone`' +mysql -u root mysql --execute "DROP USER IF EXISTS 'test'" +mysql -u root mysql --execute 'CREATE DATABASE `test`' +mysql -u root mysql --execute "CREATE USER 'test'@'%' IDENTIFIED BY 'password'" +mysql -u root mysql --execute 'GRANT ALL PRIVILEGES ON `test`.* TO `test`@`%`' mysql test --execute "CREATE TABLE tbl (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL)" mysql test --execute "INSERT INTO tbl (name) VALUES ('name1')" mysql test --execute "INSERT INTO tbl (name) VALUES ('name2')" +printf "\n###### Testing bare mysql (no --database) uses database from DATABASE_URL...\n" +mysql --execute "SELECT COUNT(*) FROM tbl" | grep "2" > /dev/null +echo "✅ bare mysql connected to the database named in DATABASE_URL via ~/.my.cnf" + printf "\n###### Testing portless DATABASE_URL defaults PORT to 3306...\n" PARSED_PORTLESS=$(DATABASE_URL="mysql://test:password@db/test" parse_database_url.py) echo "$PARSED_PORTLESS" | grep "PORT=3306" > /dev/null