diff --git a/deploy/Caddyfile b/deploy/Caddyfile index df8ee1d..1815357 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,5 +1,14 @@ :8080 { - root * /srv/web - try_files {path} /index.html - file_server + root * /srv/web/current + header Cache-Control "no-cache" + @metadata path /release.json + handle @metadata { + file_server + } + handle { + try_files {path} /index.html + file_server { + hide .artifact-sha256 + } + } } diff --git a/deploy/README.md b/deploy/README.md index a486c62..e0c709e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -75,7 +75,9 @@ are assembled from them. Do not rotate the JWT private key on ordinary deploys. Configure a real SMTP provider with TLS, verified sender, and its credentials; Mailpit is for local development. Add Google OAuth credentials if testing Google sign-in. Extract the matching client's `web-release` artifact into `web/`, so -`web/index.html` exists. The mobile app does not require visiting the web app for +`web/index.html` exists, then run `python3 migrate_web_layout.py` to initialize +`web/current/index.html`. This preserves the flat files and snapshots the initial +release. Subsequent client releases activate versioned directories atomically. The mobile app does not require visiting the web app for ordinary reading, but registration verification/reset emails use its routes. If the GHCR package is private, authenticate Docker on the VM with a restricted @@ -137,3 +139,20 @@ References: - [Hetzner Cloud firewalls](https://docs.hetzner.com/cloud/firewalls/overview/) - [Docker Compose deployment](https://docs.docker.com/compose/how-tos/production/) - [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https) + +## Updating the web delivery layout + +For an existing deployment, run `python3 migrate_web_layout.py` from `deploy/` +before installing the updated Caddyfile. It leaves the running flat directory +intact. Copy the new Caddyfile into the existing bind-mounted file (preserve its +inode), then validate and reload only the web process: + +```sh +docker compose --env-file production.env -f compose.yml exec -T web caddy validate --config /etc/caddy/Caddyfile +docker compose --env-file production.env -f compose.yml exec -T web caddy reload --config /etc/caddy/Caddyfile +``` + +Keep the parent `web` directory mounted at `/srv/web`. Check `/` and `/login` after +migration. Client delivery credentials and the restricted receiver are documented +in the client's `docs/RELEASING.md`. This migration needs no database changes and +must not run the full server deployment script or restart API/PowerSync services. diff --git a/deploy/migrate_web_layout.py b/deploy/migrate_web_layout.py new file mode 100644 index 0000000..eb30830 --- /dev/null +++ b/deploy/migrate_web_layout.py @@ -0,0 +1,56 @@ +"""Snapshot the existing flat web deployment without deleting or modifying its files.""" + +import argparse +import os +import shutil +import tempfile +from pathlib import Path + + +def migrate(root: Path) -> None: + current = root / "current" + + if current.is_symlink() and (current / "index.html").is_file(): + return + + if current.exists() or current.is_symlink(): + raise ValueError("An invalid current entry already exists; inspect it before migrating") + + if not (root / "index.html").is_file(): + raise ValueError("Extract an initial web release into this directory before migrating") + + sources = [path for path in root.iterdir() if path.name != "releases" and not path.name.startswith(".")] + + if any(path.is_symlink() or any(child.is_symlink() for child in path.rglob("*")) for path in sources): + raise ValueError("The initial web deployment must not contain symlinks") + + releases = root / "releases" + releases.mkdir(exist_ok=True) + snapshot = Path(tempfile.mkdtemp(prefix="initial-", dir=releases)) + + try: + for source in sources: + if source.is_dir(): + shutil.copytree(source, snapshot / source.name) + else: + shutil.copy2(source, snapshot / source.name) + + snapshot.chmod(0o755) + link = root / ".current-next" + link.symlink_to(f"releases/{snapshot.name}") + os.replace(link, current) + except BaseException: + shutil.rmtree(snapshot) + raise + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent / "web") + args = parser.parse_args() + migrate(args.root) + print("Versioned web layout ready; original flat files remain intact") + + +if __name__ == "__main__": + main() diff --git a/deploy/validate_config.py b/deploy/validate_config.py index bd0d28f..82198af 100644 --- a/deploy/validate_config.py +++ b/deploy/validate_config.py @@ -48,8 +48,10 @@ def main() -> None: for name in ("powersync-private.pem", "powersync-public.pem"): if not (directory / "secrets" / name).is_file(): raise ValueError(f"Missing secrets/{name}; see the deployment runbook") - if not (directory / "web/index.html").is_file(): - raise ValueError("Missing web/index.html; extract the client web-release artifact into deploy/web") + if not (directory / "web/current/index.html").is_file(): + raise ValueError( + "Missing web/current/index.html; initialize the versioned web layout in the deployment runbook" + ) print("Deployment configuration validated") diff --git a/tools/tests/test_web_layout.py b/tools/tests/test_web_layout.py new file mode 100644 index 0000000..f612942 --- /dev/null +++ b/tools/tests/test_web_layout.py @@ -0,0 +1,43 @@ +"""The web layout migration preserves the deployed app and is safe to repeat.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "migrate_web_layout", Path(__file__).resolve().parents[2] / "deploy/migrate_web_layout.py" +) +assert spec is not None and spec.loader is not None +layout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(layout) + + +class WebLayoutTest(unittest.TestCase): + def test_snapshot_preserves_original_files_and_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.html").write_text("existing release") + (root / "assets").mkdir() + (root / "assets/book.js").write_text("book") + layout.migrate(root) + target = (root / "current").resolve() + layout.migrate(root) + self.assertEqual((root / "current").resolve(), target) + self.assertEqual((root / "index.html").read_text(), "existing release") + self.assertEqual((root / "current/assets/book.js").read_text(), "book") + + def test_missing_initial_release_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError): + layout.migrate(Path(directory)) + + def test_symlink_content_rejected_without_changes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.html").write_text("existing release") + (root / "escape").symlink_to("/tmp") + + with self.assertRaises(ValueError): + layout.migrate(root) + + self.assertFalse((root / "current").exists())