From 87259ed599470d8bcfb4f0ca5ce61b528225c5dc Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 02:10:20 +0300 Subject: [PATCH 1/2] fix: apply current publication SQL without a stale file mount --- deploy/README.md | 5 +++++ deploy/deploy.sh | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/deploy/README.md b/deploy/README.md index e0c709e..f60cb80 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -115,6 +115,11 @@ artifact before running `./deploy.sh`. Check Alembic current/head before/after a release and investigate `alembic check` differences. Do not downgrade migrations or assume rolling back an image reverses a data migration. +The publication bootstrap reads the release SQL through standard input. A running +database container can retain an older inode for its individual file bind mount +after configuration replacement; do not execute the mounted file during upgrades. +Verify that `pg_publication_tables` includes every configured sync table. + Before each release, take a PostgreSQL dump of the application database and a consistent media backup. Keep encrypted, off-host backups of the database, media, production environment and JWT keys; perform an actual restore drill. The named diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7a4329a..59fad5b 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -10,5 +10,5 @@ compose up -d --wait database powersync-storage compose stop api powersync web compose run --rm migrate # shellcheck disable=SC2016 -compose exec -T database sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /bootstrap-powersync.sql' +compose exec -T database sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < bootstrap-powersync.sql compose up -d --wait api powersync web From ed5fe9c3527371b67c9d889cf7078aad6f39c60b Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 02:14:30 +0300 Subject: [PATCH 2/2] test: verify deployment streams replaced host SQL --- tools/tests/test_deploy_script.py | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tools/tests/test_deploy_script.py diff --git a/tools/tests/test_deploy_script.py b/tools/tests/test_deploy_script.py new file mode 100644 index 0000000..b5e4970 --- /dev/null +++ b/tools/tests/test_deploy_script.py @@ -0,0 +1,82 @@ +"""Exercise deployment orchestration with a disposable Compose command recorder.""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] + + +class DeploymentScriptTest(unittest.TestCase): + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + shutil.copy(ROOT / "deploy/deploy.sh", self.root / "deploy.sh") + (self.root / "validate_config.py").write_text("pass\n") + self.sql = self.root / "bootstrap-powersync.sql" + self.calls = self.root / "compose-calls.jsonl" + binary = self.root / "bin" + binary.mkdir() + docker = binary / "docker" + + docker.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "args = sys.argv[1:]\n" + "publication = 'exec' in args and 'database' in args\n" + "data = sys.stdin.read() if publication else None\n" + "with open(os.environ['COMPOSE_CALLS'], 'a') as output:\n" + " output.write(json.dumps({'args': args, 'stdin': data}) + '\\n')\n" + "if publication and os.environ.get('PUBLICATION_FAILURE'):\n" + " sys.exit(42)\n" + ) + + docker.chmod(0o755) + + self.environment = { + **os.environ, + "PATH": str(binary) + os.pathsep + os.environ["PATH"], + "COMPOSE_CALLS": str(self.calls), + } + + def run_deployment(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["sh", str(self.root / "deploy.sh")], + env=self.environment, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + ) + + def recorded_calls(self) -> list[dict[str, Any]]: + return [json.loads(line) for line in self.calls.read_text().splitlines()] + + def test_each_upgrade_streams_current_host_sql(self) -> None: + for sql in ("SELECT 'books';\n", "SELECT 'books', 'reading_goals', 'reading_activities';\n"): + replacement = self.root / "bootstrap-next.sql" + replacement.write_text(sql) + replacement.replace(self.sql) + result = self.run_deployment() + self.assertEqual(result.returncode, 0, result.stderr) + publication_calls = [call for call in self.recorded_calls() if call["stdin"] is not None] + self.assertEqual(publication_calls[-1]["stdin"], sql) + + def test_publication_failure_prevents_service_activation(self) -> None: + self.sql.write_text("SELECT 'reading_goals';\n") + self.environment["PUBLICATION_FAILURE"] = "1" + result = self.run_deployment() + self.assertEqual(result.returncode, 42) + calls = self.recorded_calls() + self.assertIsNotNone(calls[-1]["stdin"]) + self.assertFalse(any("up" in call["args"] and "api" in call["args"] for call in calls)) + + +if __name__ == "__main__": + unittest.main()