Skip to content
Merged
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
15 changes: 12 additions & 3 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
}
}
21 changes: 20 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
56 changes: 56 additions & 0 deletions deploy/migrate_web_layout.py
Original file line number Diff line number Diff line change
@@ -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(".")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dot-prefixed web assets in the snapshot

When an existing flat deployment contains publicly served dot-prefixed content such as .well-known/assetlinks.json, this filter silently omits the entire entry even though the previous /srv/web root served it. After Caddy switches to current, requests for those files fall through to /index.html, breaking domain verification or app-link association; copy dot-prefixed public assets and exclude only specific migration-control or private artifacts.

Useful? React with 👍 / 👎.


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()
6 changes: 4 additions & 2 deletions deploy/validate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
43 changes: 43 additions & 0 deletions tools/tests/test_web_layout.py
Original file line number Diff line number Diff line change
@@ -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())
Loading