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:
@@ -13,10 +13,19 @@ the client cannot hand out a phone it is unable to talk to.
|
||||
|
||||
## Where the file comes from
|
||||
|
||||
`DATA_DIR/client/` — the same volume that already holds attachments, so an
|
||||
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
|
||||
self-hoster, including everyone who never touches Android.
|
||||
Two places, checked in that order:
|
||||
|
||||
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
|
||||
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:
|
||||
|
||||
@@ -51,21 +60,23 @@ MANIFEST_NAME = "thoughtsync-android.json"
|
||||
DOWNLOAD_PATH = "/api/client/android/download"
|
||||
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__)
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None if it holds none.
|
||||
def _read(root: Path) -> dict | None:
|
||||
"""The build in one directory, or None.
|
||||
|
||||
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
|
||||
question being asked — "is there a client here I can honestly offer?" — and
|
||||
that answer is no.
|
||||
"""
|
||||
root = Path(Config.client_root())
|
||||
apk = root / APK_NAME
|
||||
try:
|
||||
size = apk.stat().st_size
|
||||
size = (root / APK_NAME).stat().st_size
|
||||
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
version = str(meta["version_name"])
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
response = await send_from_directory(
|
||||
Path(Config.client_root()), APK_NAME, mimetype=APK_MIMETYPE
|
||||
)
|
||||
# From the SAME directory the advertisement came from, or a drop-in appearing
|
||||
# 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
|
||||
# wants a filename to hand to the package installer.
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
|
||||
|
||||
Reference in New Issue
Block a user