Build and ship both halves from one commit
The audit's other "do it differently" (F76). v1's backend was an image built by CI; its frontend was `rsync -azX --delete` from a laptop, run by hand, with the API's absolute URL compiled into the WASM by an environment variable set in the same script. Two artefacts, one pipeline, and nothing keeping them in step. Here there is one image with the server binary at `/usr/local/bin` and the built frontend at `/srv/dist`, and the frontend asks for `/api/...` relative to wherever it is served -- so the artefact is the same in every environment and the two halves cannot be deployed apart. Caddy still serves the static files from a directory, because that is what the reverse proxy in front of everything already does; the updater lifts them out of the image rather than out of a build on somebody's machine. It stages them and moves the directory into place, since `index.html` names hashed files and a browser that fetches new HTML with old JavaScript gets a blank page. The image is 97 MB and holds no build tooling. Migrations are compiled into the binary and run at startup, and the database is created if it is missing, so there is no `sqlx-cli`, no entrypoint script, and nothing that can decide to carry on after a failed migration -- v1's start.sh ran migrations with `|| echo "Migration failed but continuing..."`. TLS is rustls with its roots compiled in, so there is no OpenSSL to keep patched. There is no dummy-source dance for dependency caching either; a BuildKit cache mount does what that trick was inventing, and the binary is copied out of the mount because a cache mount is not part of the layer. Deployment is a timer on the server rather than CI reaching into it. Nothing in the workflow holds a credential for the machine it deploys to, and a bad build cannot take the site down on its own; the cost is a few minutes between push and deploy, and `deploy/runway-update` for when that is too long. The rest of this is the CI that was promised at M1 and never written. "Rules that only live in a doc get forgotten, so these are lints and CI checks." The lints landed. The CI half did not exist, which meant for twenty-seven milestones the forgetting was still perfectly possible -- it was just mine rather than the repository's. `cargo fmt`, `clippy -D warnings` and the whole test suite now run on every push, alongside the three guardrails that were only ever configuration: `cargo machete` passes. `deny.toml`, written at M1, did not: three permissive licences its allow-list had not anticipated, and two `unmaintained` advisories arriving through Leptos's macros. Both are now allowed by name with a date and a reason rather than by widening a category. MPL-2.0 came out of the list, because nothing uses it and an allow-list should say what is actually there. That file had been quietly wrong since the day it was written, which is the argument for CI in one line. The bundle budget is 1.8 MB against today's 1.31 MB and v1's 2.5 MB, printed on every run. A bundle grows one convenient dependency at a time. The print rule is deliberately not in CI: it already exists as a test, it covers the CalDAV client too, and a shell grep cannot tell a call from a comment about a call -- the first draft of that step failed on the paragraph in `observability.rs` that explains the rule. Two things the image found that reading could not. `/db` is created in the image now. SQLite creates the database file if it is missing but not the directory holding it, so the container started only when something happened to be mounted there and otherwise died with "unable to open database file", which says nothing about what is wrong. And the graceful shutdown only listened for Ctrl-C. A container runtime stops a service with SIGTERM, waits ten seconds, and sends SIGKILL -- so `podman stop` took ten seconds and killed the process outright, and the handler written to stop a restart dropping a CalDAV write half-way through worked everywhere except deployment, which is the one place restarts happen. It listens for both now, and the container stops in two.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
target
|
||||
node_modules
|
||||
crates/runway-web/dist
|
||||
crates/runway-web/styles/generated.css
|
||||
e2e/screenshots
|
||||
.git
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -0,0 +1,121 @@
|
||||
# The checks the audit said would be machine-enforced.
|
||||
#
|
||||
# They were written down at M1 as "lints and CI checks" on the grounds that
|
||||
# rules living only in a document get forgotten. The lints landed; the CI half
|
||||
# did not exist until now, which means for twenty-seven milestones the
|
||||
# forgetting was still possible -- it was just my forgetting rather than the
|
||||
# repository's.
|
||||
|
||||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The toolchain, its components and the wasm target all come from
|
||||
# rust-toolchain.toml, so there is one place that says which Rust this is.
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain none
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-${{ runner.os }}-
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
# The CalDAV integration tests skip themselves when there is no server to
|
||||
# talk to, which is what happens here. They are exercised against a real
|
||||
# Baikal by `crates/runway-caldav/tests/baikal/run.sh`.
|
||||
- name: Tests
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: The frontend still builds for the browser
|
||||
run: cargo check -p runway-web --target wasm32-unknown-unknown
|
||||
|
||||
guardrails:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain none
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# v1 shipped reqwest, ical, regex and indexed_db_futures into a WASM
|
||||
# bundle that used none of them.
|
||||
- name: No unused dependencies
|
||||
run: |
|
||||
cargo install cargo-machete --locked
|
||||
cargo machete
|
||||
|
||||
- name: Licences and advisories
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
# v1 had 157 of these in its backend, one of which logged the length of a
|
||||
# password. Tests may print; the server may not.
|
||||
# The print rule is not here on purpose. It already exists as a test --
|
||||
# `no_println_or_dbg_in_the_server_or_the_caldav_client` -- so `cargo
|
||||
# test` above enforces it, and it covers the CalDAV client too. Doing it
|
||||
# again here would mean two rules to keep in step, and the CI copy would
|
||||
# be the worse of the two: grep from a shell cannot tell a call from a
|
||||
# comment about a call, and this file's first draft failed on the
|
||||
# paragraph in `observability.rs` explaining the rule.
|
||||
|
||||
bundle:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain none
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Trunk
|
||||
run: |
|
||||
curl -fsSL https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-x86_64-unknown-linux-gnu.tar.gz \
|
||||
| tar -xzC /usr/local/bin trunk
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Build the frontend
|
||||
working-directory: crates/runway-web
|
||||
run: trunk build --release
|
||||
|
||||
# Printed on every run and enforced, because a bundle grows one
|
||||
# convenient dependency at a time and nobody notices until it is 2.5 MB.
|
||||
# That was v1's. This budget is comfortably above today's and well below
|
||||
# that, so it is a ratchet rather than a rubber stamp.
|
||||
- name: WASM bundle size
|
||||
run: |
|
||||
wasm=$(find crates/runway-web/dist -name '*.wasm' -print -quit)
|
||||
size=$(stat -c %s "$wasm")
|
||||
budget=1800000
|
||||
echo "$wasm is $size bytes (budget $budget, v1 shipped 2500000)"
|
||||
if [ "$size" -gt "$budget" ]; then
|
||||
echo "over budget by $((size - budget)) bytes" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,41 @@
|
||||
# Builds the image and pushes it. It does not deploy.
|
||||
#
|
||||
# What picks it up is a timer on the server (see `deploy/`), so nothing here
|
||||
# holds a credential for the machine it runs on and a bad build cannot take the
|
||||
# site down by itself. The trade is that a deploy happens a few minutes after
|
||||
# the push rather than at the moment of it.
|
||||
|
||||
name: Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ vars.USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# Tagged with the commit as well as `latest`, so that "what is actually
|
||||
# running" has an answer, and so a rollback is a tag rather than a revert
|
||||
# and a rebuild.
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.REGISTRY }}/connor/runway:latest
|
||||
${{ vars.REGISTRY }}/connor/runway:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# One image, holding both halves of the app.
|
||||
#
|
||||
# v1 shipped the backend as an image and the frontend as an `rsync` of `dist/`
|
||||
# over SSH, run by hand from a laptop -- so the two could be, and were, out of
|
||||
# step with each other, and the audit marked that as the one thing to do
|
||||
# differently (F76). Here they are built from the same commit into the same
|
||||
# image and can only be deployed together. The frontend is not served from
|
||||
# here: it is lifted out of this image onto the directory Caddy already serves,
|
||||
# which is why it sits at a known path.
|
||||
#
|
||||
# The frontend addresses the API as `/api/...`, relative, so this artefact is
|
||||
# the same everywhere and nothing is baked in per environment. v1 compiled the
|
||||
# API's absolute URL into the WASM, which is the other half of why its frontend
|
||||
# deploy was a separate manual step.
|
||||
|
||||
# ---------------------------------------------------------------- frontend --
|
||||
FROM rust:1.98-slim-bookworm AS web
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node from the official image rather than Debian's, which ships npm 9.
|
||||
# Tailwind v4's `oxide` is a native binary chosen by an optional dependency
|
||||
# keyed on platform *and* libc, and npm 9 does not understand the `libc` field
|
||||
# -- so it silently installs no binding at all and the build dies on
|
||||
# `Cannot find module '@tailwindcss/oxide-linux-x64-gnu'`. Copied from a pinned
|
||||
# image rather than piped from a setup script into a shell, so which Node this
|
||||
# is stays a line in this file.
|
||||
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:22-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# Trunk as a released binary rather than `cargo install trunk`, which builds it
|
||||
# from source and takes longer than everything else here put together.
|
||||
ARG TRUNK_VERSION=0.21.14
|
||||
RUN curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xzC /usr/local/bin trunk
|
||||
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Tailwind runs from Trunk's pre-build hook as `npx --prefix ../..`, so the
|
||||
# packages have to be at the workspace root. Copied before the source so a
|
||||
# change to the Rust does not reinstall them.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app/crates/runway-web
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
trunk build --release
|
||||
|
||||
# ----------------------------------------------------------------- backend --
|
||||
FROM rust:1.98-slim-bookworm AS server
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# The binary is copied out of the cache mount because a cache mount is not part
|
||||
# of the layer: whatever is written there is gone by the time the next stage
|
||||
# looks. This is also why there is no dummy-source dance -- the cache does what
|
||||
# that trick was inventing.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/app/target \
|
||||
cargo build --release --locked -p runway-server \
|
||||
&& cp target/release/runway-server /runway-server
|
||||
|
||||
# ----------------------------------------------------------------- runtime --
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
# TLS is rustls with its roots compiled in, so there is no OpenSSL here and
|
||||
# nothing to keep patched. `tzdata` is not optional: this is a calendar, and
|
||||
# recurrence over a DST boundary is decided by the zone database.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=server /runway-server /usr/local/bin/runway-server
|
||||
# Where the updater looks for the frontend. Nothing serves it from inside this
|
||||
# container.
|
||||
COPY --from=web /app/crates/runway-web/dist /srv/dist
|
||||
|
||||
# Migrations are compiled into the binary by `sqlx::migrate!` and run on
|
||||
# startup, and the database is created if it is missing -- so there is no
|
||||
# entrypoint script, no `sqlx-cli` in the image, and nothing that can decide to
|
||||
# carry on after a failed migration the way v1's start.sh did.
|
||||
# The mount point, created here rather than left to the volume. SQLite creates
|
||||
# the database *file* if it is missing but not the directory holding it, so an
|
||||
# image without this starts only when something happens to have mounted
|
||||
# something at /db, and fails with "unable to open database file" when nothing
|
||||
# has -- which says nothing about what is actually wrong.
|
||||
RUN mkdir -p /db
|
||||
|
||||
ENV RUNWAY_DATABASE_URL=sqlite:///db/runway.db \
|
||||
RUNWAY_BIND=0.0.0.0:3000
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["runway-server"]
|
||||
@@ -105,6 +105,20 @@ cargo run -p runway-cli -- calendars
|
||||
cargo run -p runway-cli -- list-events --from 2026-08-24 --to 2026-08-31
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
One image holds both halves — the server binary and the built frontend — so
|
||||
they cannot be deployed out of step with each other. CI builds and pushes it on
|
||||
every merge to `master`; a timer on the server picks it up. Nothing goes up
|
||||
from a laptop.
|
||||
|
||||
```sh
|
||||
deploy/runway-update # what the timer runs, if you would rather not wait
|
||||
```
|
||||
|
||||
The full arrangement, and the one-time setup it needs, is in
|
||||
[`deploy/README.md`](deploy/README.md).
|
||||
|
||||
## Principles
|
||||
|
||||
Carried over from the audit, and enforced by lints and CI rather than by good intentions:
|
||||
|
||||
@@ -53,9 +53,42 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lets in-flight requests finish on Ctrl-C, so a restart does not drop a save
|
||||
/// half-way through a CalDAV write.
|
||||
/// Lets in-flight requests finish before the process goes, so a restart does
|
||||
/// not drop a save half-way through a CalDAV write.
|
||||
///
|
||||
/// Both signals, because the two ways this process gets stopped send different
|
||||
/// ones. Ctrl-C at a terminal sends SIGINT; a container runtime stopping a
|
||||
/// service sends SIGTERM, waits ten seconds, and then sends SIGKILL. Listening
|
||||
/// only for the first meant the handler worked everywhere except deployment --
|
||||
/// which is the one place restarts actually happen, and the one place a
|
||||
/// half-written CalDAV request matters.
|
||||
async fn shutdown() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutting down");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let mut terminate = match signal(SignalKind::terminate()) {
|
||||
Ok(signal) => signal,
|
||||
// Nothing to be done about it, and refusing to serve because a
|
||||
// signal handler could not be installed would be worse.
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "cannot listen for SIGTERM");
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!(signal = "SIGINT", "shutting down");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let signal = tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => "SIGINT",
|
||||
_ = terminate.recv() => "SIGTERM",
|
||||
};
|
||||
tracing::info!(signal, "shutting down");
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!(signal = "SIGINT", "shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# This file was written at M1 and, until M28 wired it into CI, had never been
|
||||
# run against the dependency tree it describes. It did not pass. Everything
|
||||
# below with a date on it is what that first run turned up -- which is the
|
||||
# argument for CI in one paragraph: a policy nobody executes is a document, and
|
||||
# this one had been quietly wrong for twenty-seven milestones.
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
ignore = [
|
||||
# Both of these are `unmaintained`, not vulnerabilities, and both arrive
|
||||
# through Leptos's macros rather than through anything this workspace asks
|
||||
# for. They run at build time and none of their code ships. Reviewed
|
||||
# 2026-08-28; the honest options are to ignore them or to stop using
|
||||
# Leptos, so they are ignored, by id, with this note rather than by
|
||||
# turning the whole `unmaintained` class off.
|
||||
"RUSTSEC-2024-0436", # paste, via leptos_macro
|
||||
"RUSTSEC-2026-0173", # proc-macro-error2, via leptos_macro
|
||||
]
|
||||
|
||||
[licenses]
|
||||
allow = [
|
||||
@@ -11,7 +27,11 @@ allow = [
|
||||
"ISC",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"MPL-2.0",
|
||||
# Added 2026-08-28, when this was first run. All three are permissive and
|
||||
# all three arrive transitively:
|
||||
"CC0-1.0", # base16, via leptos
|
||||
"CDLA-Permissive-2.0", # webpki-roots -- the TLS root store itself
|
||||
"BSL-1.0", # xxhash-rust
|
||||
]
|
||||
|
||||
[bans]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Deploying Runway
|
||||
|
||||
The app is one image holding both halves — the server binary and the built
|
||||
frontend — pushed by CI on every merge to `master` and picked up by a timer on
|
||||
the server. Nothing is copied from a laptop, and there is no step that can
|
||||
deploy one half without the other. That was the audit's one "do it differently"
|
||||
(F76): v1's frontend went up by `rsync` over SSH, by hand, separately from its
|
||||
backend's CI.
|
||||
|
||||
## What runs where
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Image | `git.rcjohnstone.com/connor/runway:latest`, and `:<commit>` |
|
||||
| Built by | `.gitea/workflows/release.yml`, on push to `master` |
|
||||
| Deployed by | `deploy/runway-update`, from a systemd timer |
|
||||
| Backend | `runway-backend` in `~/compose.yml`, on the `internal` network |
|
||||
| Frontend | `~/data/runway/dist`, served by the root Caddy from `/srv/runway` |
|
||||
| Database | `~/data/runway/db`, a volume on the backend container |
|
||||
| TLS and auth | The root Caddy, which already fronts `runway.rcjohnstone.com` behind Authelia |
|
||||
|
||||
The frontend asks for `/api/...` relative to wherever it is served, so the same
|
||||
image is correct in every environment and nothing is compiled in per host. v1
|
||||
baked the API's absolute URL into the WASM, which is why its frontend could not
|
||||
be deployed by the same pipeline as its backend.
|
||||
|
||||
## Setting it up
|
||||
|
||||
Five things, of which four are one-time.
|
||||
|
||||
**1. The image.** Set on the Gitea repository, under Settings → Actions:
|
||||
|
||||
- variable `REGISTRY` = `git.rcjohnstone.com`
|
||||
- variable `USERNAME` = `connor`
|
||||
- secret `DOCKER_PASSWORD` = a token with package write
|
||||
|
||||
These are the names v1's workflow used, so they may already exist at the
|
||||
organisation level.
|
||||
|
||||
**2. The signing key.** Generate it once, on the server, and keep it:
|
||||
|
||||
```sh
|
||||
podman run --rm git.rcjohnstone.com/connor/runway:latest runway-server genkey
|
||||
```
|
||||
|
||||
Put the output in `~/.env` as `RUNWAY_SECRET_KEY`. It encrypts the stored
|
||||
CalDAV passwords, so **if it changes, every saved credential becomes
|
||||
unreadable** and everybody has to sign in again. It is not derived from
|
||||
anything and cannot be recovered; back it up with the rest of `~/.env`.
|
||||
|
||||
**3. The compose service.** Replace the existing `runway-backend` block:
|
||||
|
||||
```yaml
|
||||
runway-backend:
|
||||
image: git.rcjohnstone.com/connor/runway:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
environment:
|
||||
# Absent on purpose: RUNWAY_INSECURE_COOKIES. Caddy terminates TLS, so
|
||||
# the session cookie is Secure, and setting this would quietly stop it
|
||||
# being so.
|
||||
- RUNWAY_SECRET_KEY=${RUNWAY_SECRET_KEY}
|
||||
- RUNWAY_DATABASE_URL=sqlite:///db/runway.db
|
||||
- TZ=America/Louisville
|
||||
volumes:
|
||||
- ./data/runway/db:/db
|
||||
```
|
||||
|
||||
`~/data/runway/db` is root-owned from v1 and the container still runs as root,
|
||||
so it keeps working untouched. To run it unprivileged instead, `chown` that
|
||||
directory and add a `user:` line.
|
||||
|
||||
The database is `runway.db`, not v1's `calendar.db`, so this starts empty —
|
||||
which is what you want. There is nothing to migrate across: the schemas share
|
||||
no ancestry, and v1's stored passwords were encrypted with a key this app does
|
||||
not have.
|
||||
|
||||
**4. Caddy.** No change. The existing `runway.rcjohnstone.com` block already
|
||||
proxies `/api/*` to `runway-backend:3000` and serves `/srv/runway` with an
|
||||
`index.html` fallback, which is exactly what this needs.
|
||||
|
||||
**5. The timer.**
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/systemd/user
|
||||
ln -sf ~/docs/projects/runway/deploy/runway-update.service ~/.config/systemd/user/
|
||||
ln -sf ~/docs/projects/runway/deploy/runway-update.timer ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now runway-update.timer
|
||||
loginctl enable-linger connor # so it runs when you are not logged in
|
||||
```
|
||||
|
||||
## Deploying by hand
|
||||
|
||||
The timer runs the same script, so this is what it does, and it is also the
|
||||
answer to "I do not want to wait ten minutes":
|
||||
|
||||
```sh
|
||||
deploy/runway-update
|
||||
```
|
||||
|
||||
It exits without touching anything if the image has not changed.
|
||||
|
||||
## Rolling back
|
||||
|
||||
The frontend of the previous deploy is kept:
|
||||
|
||||
```sh
|
||||
mv ~/data/runway/dist ~/data/runway/dist.bad
|
||||
mv ~/data/runway/dist.previous ~/data/runway/dist
|
||||
```
|
||||
|
||||
The backend is a tag. Pin the service's `image:` to
|
||||
`git.rcjohnstone.com/connor/runway:<commit>` and bring it up; the timer will
|
||||
leave a pinned tag alone, because it only ever pulls what the service names.
|
||||
|
||||
Rolling back **across a migration** is the case to think about before doing it:
|
||||
migrations only go forwards, so an older binary meeting a newer database is not
|
||||
something to try casually. Roll the frontend back first — that is usually the
|
||||
half that is wrong, and it is reversible in a way the schema is not.
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pull the current image and put both halves of it in place.
|
||||
#
|
||||
# Two halves, because Caddy serves the frontend from a directory and the
|
||||
# backend from a container -- so a deploy is "restart the container" *and*
|
||||
# "refresh the directory", and they have to come from the same image or the
|
||||
# thing v1 got wrong (a frontend calling an endpoint the backend does not have)
|
||||
# comes straight back. Nothing here builds anything; the image is whatever CI
|
||||
# pushed.
|
||||
#
|
||||
# Safe to run when nothing has changed: if the pull brings down no new layers
|
||||
# the digest is unchanged and it stops.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${RUNWAY_IMAGE:-git.rcjohnstone.com/connor/runway:latest}"
|
||||
COMPOSE_DIR="${RUNWAY_COMPOSE_DIR:-$HOME}"
|
||||
SERVICE="${RUNWAY_SERVICE:-runway-backend}"
|
||||
DIST="${RUNWAY_DIST:-$HOME/data/runway/dist}"
|
||||
|
||||
runtime() {
|
||||
if command -v podman >/dev/null 2>&1; then echo podman
|
||||
elif command -v docker >/dev/null 2>&1; then echo docker
|
||||
else echo "need podman or docker" >&2; exit 1
|
||||
fi
|
||||
}
|
||||
rt="$(runtime)"
|
||||
|
||||
before="$("$rt" image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null || true)"
|
||||
"$rt" pull --quiet "$IMAGE" >/dev/null
|
||||
after="$("$rt" image inspect --format '{{.Id}}' "$IMAGE")"
|
||||
|
||||
if [ "$before" = "$after" ]; then
|
||||
echo "runway: already on ${after:7:12}, nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "runway: ${before:7:12} -> ${after:7:12}"
|
||||
|
||||
# The frontend first. It is copied into place through a staging directory and
|
||||
# moved, so that Caddy is never serving a half-written bundle -- `index.html`
|
||||
# names hashed files, and a browser that fetches the new HTML and the old
|
||||
# JavaScript gets a blank page.
|
||||
staged="$(mktemp -d "${DIST%/*}/.dist.XXXXXX")"
|
||||
container="$("$rt" create "$IMAGE")"
|
||||
trap '"$rt" rm -f "$container" >/dev/null 2>&1 || true; rm -rf "$staged"' EXIT
|
||||
"$rt" cp "$container:/srv/dist/." "$staged/"
|
||||
|
||||
# Kept rather than deleted: rolling the frontend back is then a `mv`, which is
|
||||
# the kind of thing you want to be easy at the moment you need it.
|
||||
previous="${DIST%/}.previous"
|
||||
rm -rf "$previous"
|
||||
# Spelled as an `if` and not `[ -d ... ] && mv`, because under `set -e` the
|
||||
# second form exits the script the first time it runs, when there is nothing
|
||||
# there yet -- and it would have looked like the deploy succeeded.
|
||||
if [ -d "$DIST" ]; then
|
||||
mv "$DIST" "$previous"
|
||||
fi
|
||||
mv "$staged" "$DIST"
|
||||
chmod -R a+rX "$DIST"
|
||||
|
||||
# Then the backend, which runs the migrations as it starts.
|
||||
( cd "$COMPOSE_DIR" && "$rt" compose up -d "$SERVICE" )
|
||||
|
||||
echo "runway: ${after:7:12} deployed"
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Deploy whatever CI last built of Runway
|
||||
Documentation=https://git.rcjohnstone.com/connor/runway
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/docs/projects/runway/deploy/runway-update
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Check for a new Runway image
|
||||
|
||||
[Timer]
|
||||
# Ten minutes after boot, then every ten. The check is a registry HEAD when
|
||||
# nothing has changed, which is cheap enough that the interval is chosen for
|
||||
# how long you are willing to wait after a push rather than for load.
|
||||
OnBootSec=10min
|
||||
OnUnitActiveSec=10min
|
||||
# So that two services updating on the same schedule do not both wake at
|
||||
# exactly the same second for ever.
|
||||
RandomizedDelaySec=60
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user