server: bake the newest Android client into every image (operator call)
Reverses the placement decision made an hour ago. That one put the APK only on the data volume, reasoning that ~55 MiB should not be charged to installs that never touch Android. The operator's call is that ending the manual copy is worth the megabytes, and it is their deployment. CI now fetches the newest published client into the build context immediately before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a `docker compose pull` delivers a new server and a new client together. **Always the rolling `dev` release — the newest build there is.** A versioned image therefore carries the newest client rather than one pinned to that version. Deliberate: the two negotiate a sync protocol version before linking, so a mismatch is caught by the handshake, and pinning would buy nothing the handshake does not already provide. **Fetched by the JOB, never by the Dockerfile.** The release is private, and a token used inside a build ends up in the context or a layer. **It cannot fail the image build.** No release yet, a network blip, a first-ever build — all of them log a warning and produce an image with no client, which is a state the server already supports. Half a pair is cleaned up rather than shipped: a sidecar without its APK is worse than neither, because the server would be describing something it cannot serve. **The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy second, so an operator who deliberately drops a build in gets that build — and a BROKEN drop-in falls through to the image's copy rather than taking the feature offline, which is what makes the copy-order advice survivable instead of load-bearing. Three tests cover the precedence, including that last case. The baked copy lives inside the package, not under DATA_DIR: that path is a volume mount, and anything the image wrote there would disappear behind it the moment one is attached. `client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI step never ran; the artifacts themselves are gitignored, since a 55 MiB binary does not belong in git history and is re-fetched on every build anyway.
This commit is contained in:
@@ -136,6 +136,43 @@ jobs:
|
|||||||
docker system prune -af || true
|
docker system prune -af || true
|
||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
|
|
||||||
|
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||||
|
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||||
|
# along with the new server.
|
||||||
|
#
|
||||||
|
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||||
|
# image therefore carries the newest client rather than one pinned to that
|
||||||
|
# version; the two negotiate a sync protocol version before linking, so
|
||||||
|
# "newest" is safe in a way "matching" would not buy anything over.
|
||||||
|
#
|
||||||
|
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
|
||||||
|
# token used inside a build lands in the context or a layer.
|
||||||
|
#
|
||||||
|
# NEVER fails the build. An image with no Android client advertises none and
|
||||||
|
# hides the download — a supported state, and the only one available before
|
||||||
|
# the first Android build has ever published.
|
||||||
|
- name: Fetch the Android client to bake in
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
mkdir -p client
|
||||||
|
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
||||||
|
ok=1
|
||||||
|
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||||
|
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||||
|
done
|
||||||
|
if [ "$ok" = 1 ]; then
|
||||||
|
echo "Baking in:"
|
||||||
|
cat client/thoughtsync-android.json
|
||||||
|
ls -l client/thoughtsync.apk
|
||||||
|
else
|
||||||
|
# Both or neither. Half a pair is worse than none: the server would
|
||||||
|
# read a sidecar describing an APK that isn't there, or an APK it
|
||||||
|
# cannot state a version for.
|
||||||
|
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||||
|
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
|||||||
@@ -205,3 +205,9 @@ android/local.properties
|
|||||||
*.keystore
|
*.keystore
|
||||||
*.p12
|
*.p12
|
||||||
*.b64
|
*.b64
|
||||||
|
|
||||||
|
# The Android client CI bakes into the server image. Fetched fresh on every image
|
||||||
|
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
|
||||||
|
# the Dockerfile's COPY always has a directory to copy.
|
||||||
|
client/thoughtsync.apk
|
||||||
|
client/thoughtsync-android.json
|
||||||
|
|||||||
+13
@@ -24,6 +24,19 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
|||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
|
# The Android client this server hands out. CI fetches the newest published build
|
||||||
|
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||||
|
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||||
|
# delivers a new one with no file copying by hand.
|
||||||
|
#
|
||||||
|
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||||
|
# token used inside a build ends up in the build context or a layer.
|
||||||
|
#
|
||||||
|
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||||
|
# that step never ran. An image with no APK is a supported state — the server
|
||||||
|
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||||
|
COPY client/ src/thoughtsync/client/
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CI drops the Android client here on every image build, and the Dockerfile copies
|
||||||
|
the directory into the image (see ci.yml "Fetch the Android client to bake in").
|
||||||
|
|
||||||
|
This file exists so the directory does too. `COPY client/ ...` fails outright on a
|
||||||
|
missing source, which would break every local `docker build` on a tree that has
|
||||||
|
never run that CI step — and an image with no Android client is a supported
|
||||||
|
state, not an error.
|
||||||
|
|
||||||
|
The artifacts themselves are gitignored: a 55 MiB binary does not belong in git
|
||||||
|
history, and it is fetched fresh anyway.
|
||||||
@@ -11,28 +11,42 @@ It also keeps the two in step: client and server negotiate a sync protocol versi
|
|||||||
before linking, so a server that serves the client cannot hand out a phone it
|
before linking, so a server that serves the client cannot hand out a phone it
|
||||||
cannot talk to.
|
cannot talk to.
|
||||||
|
|
||||||
## Where it goes
|
## Where it comes from
|
||||||
|
|
||||||
Two files, both required, in `/var/thoughtsync/client/`:
|
**Normally: nowhere. It is already in the image.**
|
||||||
|
|
||||||
|
CI fetches the newest published Android build into every server image it builds,
|
||||||
|
so `:dev`, `:latest` and `:<version>` all ship a client. `docker compose pull &&
|
||||||
|
docker compose up -d` delivers a new server and a new client together, and there
|
||||||
|
is nothing to copy.
|
||||||
|
|
||||||
|
A versioned image therefore carries the *newest* client rather than one pinned to
|
||||||
|
that version. That is deliberate: the two negotiate a sync protocol version
|
||||||
|
before they link, so a mismatch is caught by the handshake rather than by
|
||||||
|
pinning.
|
||||||
|
|
||||||
|
## Overriding it
|
||||||
|
|
||||||
|
If you want a specific build — testing something, or holding back — drop it in
|
||||||
|
`/var/thoughtsync/client/` and it wins over the image's copy.
|
||||||
|
|
||||||
|
Two files, both required:
|
||||||
|
|
||||||
| File | What it is |
|
| File | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `thoughtsync.apk` | the client |
|
| `thoughtsync.apk` | the client |
|
||||||
| `thoughtsync-android.json` | `{version_name, version_code, size, sha256}` |
|
| `thoughtsync-android.json` | `{version_name, version_code, size, sha256}` |
|
||||||
|
|
||||||
The sidecar exists because an APK keeps its version in a binary manifest that needs
|
The sidecar exists because an APK keeps its version in a binary manifest that
|
||||||
the Android build tools to read. CI writes it beside the APK, where the real values
|
needs the Android build tools to read. CI writes it beside the APK, where the
|
||||||
are already known.
|
real values are already known.
|
||||||
|
|
||||||
`/var/thoughtsync` is the same volume that holds attachments (`Config.DATA_DIR`),
|
`/var/thoughtsync` is the same volume that holds attachments (`Config.DATA_DIR`),
|
||||||
so a build dropped there survives container recreation. Nothing is baked into the
|
so a build dropped there survives container recreation — and survives an image
|
||||||
image: the APK is ~55 MiB and an install that never touches Android should not
|
upgrade, which is the point of an override.
|
||||||
carry it.
|
|
||||||
|
|
||||||
## Putting a build there
|
|
||||||
|
|
||||||
Both files are published to the rolling `dev` release on every green Android
|
Both files are published to the rolling `dev` release on every green Android
|
||||||
build. From the machine running the server:
|
build:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
REPO=https://git.fabledsword.com/bvandeusen/thoughtsync
|
REPO=https://git.fabledsword.com/bvandeusen/thoughtsync
|
||||||
@@ -43,14 +57,14 @@ for f in thoughtsync.apk thoughtsync-android.json; do
|
|||||||
-o "/tmp/$f" "$REPO/releases/download/dev/$f"
|
-o "/tmp/$f" "$REPO/releases/download/dev/$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Into the app container's volume. Copy the sidecar LAST: the server treats a
|
# Copy the sidecar LAST. A sidecar that does not match the APK beside it is not a
|
||||||
# sidecar that does not match the APK beside it as "no client at all", so a
|
# client, so a half-finished copy falls back to the image's build rather than
|
||||||
# half-finished copy advertises nothing rather than advertising a lie.
|
# advertising a lie.
|
||||||
docker compose cp /tmp/thoughtsync.apk app:/var/thoughtsync/client/
|
docker compose cp /tmp/thoughtsync.apk app:/var/thoughtsync/client/
|
||||||
docker compose cp /tmp/thoughtsync-android.json app:/var/thoughtsync/client/
|
docker compose cp /tmp/thoughtsync-android.json app:/var/thoughtsync/client/
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker compose cp` creates `/var/thoughtsync/client/` if it does not exist.
|
To go back to whatever the image ships, delete both files.
|
||||||
|
|
||||||
## Checking it took
|
## Checking it took
|
||||||
|
|
||||||
@@ -64,14 +78,18 @@ offering a button that fails.
|
|||||||
|
|
||||||
## What happens if you get it wrong
|
## What happens if you get it wrong
|
||||||
|
|
||||||
- **Only the APK, no sidecar** — the server reports no client. It cannot state a
|
All of these describe an override in `/var/thoughtsync/client/`. A broken
|
||||||
version it has no way to read.
|
override does not take the feature away — it falls through to the build the image
|
||||||
- **Mismatched pair** (new APK, old sidecar) — the server reports no client,
|
shipped with, which is the whole reason precedence runs in that direction.
|
||||||
because the recorded size does not match the file. It will not serve one build
|
|
||||||
while describing another.
|
- **Only the APK, no sidecar** — not a client. The server cannot state a version
|
||||||
- **Neither** — the server reports no client, the UI hides the card, and
|
it has no way to read.
|
||||||
`/api/client/android` returns 404. This is the ordinary state of a server whose
|
- **Mismatched pair** (new APK, old sidecar) — not a client. It will not serve
|
||||||
owner does not use Android, and nothing about it is an error.
|
one build while describing another.
|
||||||
|
- **Neither, and no baked copy either** — the server reports no client, the UI
|
||||||
|
hides the card, and `/api/client/android` returns 404. That is the state of any
|
||||||
|
image built before the first Android build ever published, and nothing about it
|
||||||
|
is an error.
|
||||||
|
|
||||||
## Signing, and why replacing the APK is safe
|
## Signing, and why replacing the APK is safe
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ the client cannot hand out a phone it is unable to talk to.
|
|||||||
|
|
||||||
## Where the file comes from
|
## Where the file comes from
|
||||||
|
|
||||||
`DATA_DIR/client/` — the same volume that already holds attachments, so an
|
Two places, checked in that order:
|
||||||
operator drops a build there once and container recreation does not lose it.
|
|
||||||
Deliberately NOT baked into the image: that would charge ~55 MiB to every
|
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
|
||||||
self-hoster, including everyone who never touches Android.
|
operator who wants a SPECIFIC build drops it there and it wins.
|
||||||
|
2. the copy baked into the image at build time — CI fetches the newest published
|
||||||
|
Android build into every image, so `:dev`, `:latest` and `:<version>` all
|
||||||
|
carry a client and `docker compose pull` delivers a new one with nothing
|
||||||
|
copied by hand.
|
||||||
|
|
||||||
|
The precedence is the point: the image is the default, and a person who wants to
|
||||||
|
override it should not have to fight it. The baked copy sits inside the package
|
||||||
|
rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the
|
||||||
|
image wrote there would be hidden the moment one is attached.
|
||||||
|
|
||||||
Two files, and both must be present:
|
Two files, and both must be present:
|
||||||
|
|
||||||
@@ -51,21 +60,23 @@ MANIFEST_NAME = "thoughtsync-android.json"
|
|||||||
DOWNLOAD_PATH = "/api/client/android/download"
|
DOWNLOAD_PATH = "/api/client/android/download"
|
||||||
APK_MIMETYPE = "application/vnd.android.package-archive"
|
APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
|
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
|
||||||
|
# is a volume mount, and a file the image wrote there would vanish behind it.
|
||||||
|
BAKED_ROOT = Path(__file__).resolve().parent / "client"
|
||||||
|
|
||||||
bp = Blueprint("client_dist", __name__)
|
bp = Blueprint("client_dist", __name__)
|
||||||
|
|
||||||
|
|
||||||
def android_release() -> dict | None:
|
def _read(root: Path) -> dict | None:
|
||||||
"""What Android build this server holds, or None if it holds none.
|
"""The build in one directory, or None.
|
||||||
|
|
||||||
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
|
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
|
||||||
sidecar that describes a different file are all the same answer to the only
|
sidecar that describes a different file are all the same answer to the only
|
||||||
question being asked — "is there a client here I can honestly offer?" — and
|
question being asked — "is there a client here I can honestly offer?" — and
|
||||||
that answer is no.
|
that answer is no.
|
||||||
"""
|
"""
|
||||||
root = Path(Config.client_root())
|
|
||||||
apk = root / APK_NAME
|
|
||||||
try:
|
try:
|
||||||
size = apk.stat().st_size
|
size = (root / APK_NAME).stat().st_size
|
||||||
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||||
version = str(meta["version_name"])
|
version = str(meta["version_name"])
|
||||||
code = int(meta["version_code"])
|
code = int(meta["version_code"])
|
||||||
@@ -94,6 +105,27 @@ def android_release() -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve() -> tuple[Path, dict] | None:
|
||||||
|
"""Which directory this server serves from, and what is in it.
|
||||||
|
|
||||||
|
The operator's drop-in beats the baked copy — someone who deliberately put a
|
||||||
|
build on the volume wants that build, not whatever the image happened to ship
|
||||||
|
with. A directory holding a broken or half-copied pair does NOT shadow the
|
||||||
|
image: it simply is not a client, so the search moves on.
|
||||||
|
"""
|
||||||
|
for root in (Path(Config.client_root()), BAKED_ROOT):
|
||||||
|
release = _read(root)
|
||||||
|
if release is not None:
|
||||||
|
return root, release
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def android_release() -> dict | None:
|
||||||
|
"""What Android build this server holds, or None if it holds none."""
|
||||||
|
resolved = _resolve()
|
||||||
|
return resolved[1] if resolved else None
|
||||||
|
|
||||||
|
|
||||||
def advertisement() -> dict:
|
def advertisement() -> dict:
|
||||||
"""The `/api/config` fragment describing this server's Android client.
|
"""The `/api/config` fragment describing this server's Android client.
|
||||||
|
|
||||||
@@ -124,11 +156,13 @@ async def android_download():
|
|||||||
above is public because a client has to be able to ask "is there something
|
above is public because a client has to be able to ask "is there something
|
||||||
newer?" cheaply, but the bytes are not for anyone who can reach the port.
|
newer?" cheaply, but the bytes are not for anyone who can reach the port.
|
||||||
"""
|
"""
|
||||||
if android_release() is None:
|
resolved = _resolve()
|
||||||
|
if resolved is None:
|
||||||
return jsonify({"error": "this server has no Android client"}), 404
|
return jsonify({"error": "this server has no Android client"}), 404
|
||||||
response = await send_from_directory(
|
# From the SAME directory the advertisement came from, or a drop-in appearing
|
||||||
Path(Config.client_root()), APK_NAME, mimetype=APK_MIMETYPE
|
# between the two calls would serve bytes the metadata does not describe.
|
||||||
)
|
root, _ = resolved
|
||||||
|
response = await send_from_directory(root, APK_NAME, mimetype=APK_MIMETYPE)
|
||||||
# Without this some browsers try to render it, and Android's download handler
|
# Without this some browsers try to render it, and Android's download handler
|
||||||
# wants a filename to hand to the package installer.
|
# wants a filename to hand to the package installer.
|
||||||
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
|
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from thoughtsync import client_dist
|
||||||
from thoughtsync.app import create_app
|
from thoughtsync.app import create_app
|
||||||
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
|
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
|
||||||
from thoughtsync.config import Config
|
from thoughtsync.config import Config
|
||||||
@@ -18,14 +20,27 @@ from thoughtsync.config import Config
|
|||||||
PAYLOAD = b"not really an apk, but the server only ever stats it"
|
PAYLOAD = b"not really an apk, but the server only ever stats it"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _empty_baked_client(tmp_path, monkeypatch):
|
||||||
|
"""Point the baked-in copy at an empty directory.
|
||||||
|
|
||||||
|
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
|
||||||
|
would pass anyway — but only by accident of where they are run. A built image
|
||||||
|
has a real APK there, and a test that silently depends on which tree it is in
|
||||||
|
is one that will eventually lie.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app():
|
def app():
|
||||||
return create_app()
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
def place_client(payload: bytes = PAYLOAD, **overrides) -> dict:
|
def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict:
|
||||||
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
|
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
|
||||||
root = Config.client_root()
|
root = root if root is not None else Config.client_root()
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
(root / APK_NAME).write_bytes(payload)
|
(root / APK_NAME).write_bytes(payload)
|
||||||
meta = {
|
meta = {
|
||||||
@@ -110,3 +125,30 @@ async def test_the_bytes_need_authentication_even_though_the_version_does_not(ap
|
|||||||
place_client()
|
place_client()
|
||||||
resp = await app.test_client().get("/api/client/android/download")
|
resp = await app.test_client().get("/api/client/android/download")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
|
||||||
|
"""The ordinary case for a self-hoster who just pulled the image."""
|
||||||
|
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||||
|
assert android_release()["version_code"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
|
||||||
|
"""Someone who deliberately put a build on the volume wants that build."""
|
||||||
|
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||||
|
place_client(version_name="0.1.99", version_code=99)
|
||||||
|
advertised = android_release()
|
||||||
|
# Lower version and all — precedence is about intent, not about newness. An
|
||||||
|
# operator pinning an older client is doing it on purpose.
|
||||||
|
assert advertised["version_code"] == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
|
||||||
|
"""A half-finished copy onto the volume must not take the app offline.
|
||||||
|
|
||||||
|
This is the failure the copy-order advice in docs/android-distribution.md is
|
||||||
|
about, and the server should ride it out rather than go dark.
|
||||||
|
"""
|
||||||
|
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
|
||||||
|
place_client(size=999_999) # sidecar describing a different build
|
||||||
|
assert android_release()["version_code"] == 300
|
||||||
|
|||||||
Reference in New Issue
Block a user