M2c-pi: Pi bundle (infra/pi), cert-sync, Drive backup upload, Gitea workflow
The owner copies infra/pi/ to the Kvitt Pi and runs it there (D-100); nothing
on the Mac connects to the Pis. compose stack of SPEC §10.2 with images
${KVITT_REGISTRY}/kvitt-{server,cert-sync,backup}, Caddyfile §10.5.2 verbatim,
.env.example with the OQ-3 values as CHANGE_ME placeholders, install.sh,
deploy.sh (backup before every deploy), bootstrap.sh, backup-now.sh,
install-apk.sh. cert-sync image and sync.sh (§10.5.1, tar stream for both
authorized_keys variants); backup.sh uploads with rclone copy --immutable and
deletes local files once both are confirmed on Drive (D-002); both sidecars
schedule with supercronic because busybox crond runs no jobs as non-root;
backup runs as uid 10001. .gitea/workflows/build-images.yaml from the owner's
example (not yet run); infra/build-images.sh and infra/publish-apk.sh (R8
mapping kept, version per §10.6) on the Mac; README checklist and RESTORE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Kvitt image build on Gitea Actions (SPEC §10.3, D-104). NOT YET RUN (written 2026-10-18 from the owner's working
|
||||
# example workflow and the Gitea Actions documentation; the first run is an owner step, infra/README.md).
|
||||
#
|
||||
# Runner: act_runner with the label `host` on the owner's Raspberry Pi (Alpine-based host, docker builds on the
|
||||
# Pi itself). Pushes three linux/arm64 images to the Gitea container registry of the same instance (gitea.lan,
|
||||
# plain HTTP), each tagged with the version vMAJOR.MINOR.PATCH and `latest`, then pushes the git tag of that version.
|
||||
# On the Kvitt Pi: /opt/kvitt/deploy.sh <version>.
|
||||
#
|
||||
# Secret: `kvitt_cicd`, a repository secret holding a Gitea access token (scopes write:package, and
|
||||
# write:repository for the tag push) of the user that pushes (owner step, infra/README.md).
|
||||
#
|
||||
# RISK: the runner is a Raspberry Pi 3 with 1 GB RAM and the server image compiles about 232 Rust crates
|
||||
# (aws-lc-sys's C code included). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile must
|
||||
# declare `ARG CARGO_BUILD_JOBS` in its build stage for it to take effect (contract request in the M2c-pi report;
|
||||
# until then buildx warns that the argument is unused and cargo uses every core). The Dockerfile already keeps
|
||||
# the cargo registry and the target directory in BuildKit cache mounts, which persist in the reused builder
|
||||
# `local-http-builder`, so later builds are incremental. First-build duration on the Pi: unknown.
|
||||
# Fallback: infra/build-images.sh on the Mac.
|
||||
name: Build Kvitt images
|
||||
env:
|
||||
MAJOR: 1
|
||||
MINOR: 0
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "server/**"
|
||||
- "infra/cert-sync/**"
|
||||
- "infra/backup/**"
|
||||
- ".gitea/workflows/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Install Git and Docker CLI
|
||||
run: apk add --no-cache git docker-cli docker-cli-buildx
|
||||
- name: Log in to Gitea Container Registry
|
||||
run: echo "${{ secrets.kvitt_cicd }}" | docker login gitea.lan -u ${{ github.actor }} --password-stdin
|
||||
- name: Create Custom Buildx Builder for HTTP
|
||||
run: |
|
||||
# 1. Write a configuration rule forcing plain HTTP for the local registry
|
||||
echo '[registry."gitea.lan"]' > buildkitd.toml
|
||||
echo ' http = true' >> buildkitd.toml
|
||||
# 2. Create the builder once and reuse it, so its BuildKit cache (cargo registry, target) survives runs
|
||||
if docker buildx inspect local-http-builder >/dev/null 2>&1; then
|
||||
docker buildx use local-http-builder
|
||||
else
|
||||
docker buildx create --use --name local-http-builder --config buildkitd.toml
|
||||
fi
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Tells the action to use the local 'git' binary installed above
|
||||
set-safe-directory: 'true'
|
||||
fetch-depth: 0
|
||||
- name: Determine Version
|
||||
run: |
|
||||
LATEST_TAG=$(git tag -l "v${{ env.MAJOR }}.${{ env.MINOR }}.*" | sort -V | tail -n 1)
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.0"
|
||||
else
|
||||
# Strip the 'v' and get the patch version
|
||||
PATCH=$(echo "$LATEST_TAG" | sed 's/v//' | cut -d'.' -f3)
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
VERSION="v${{ env.MAJOR }}.${{ env.MINOR }}.$NEW_PATCH"
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- name: Build cert-sync image
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-t gitea.lan/jacobh2/kvitt-cert-sync:${{ env.VERSION }} \
|
||||
-t gitea.lan/jacobh2/kvitt-cert-sync:latest \
|
||||
./infra/cert-sync \
|
||||
--push
|
||||
- name: Build backup image
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-t gitea.lan/jacobh2/kvitt-backup:${{ env.VERSION }} \
|
||||
-t gitea.lan/jacobh2/kvitt-backup:latest \
|
||||
./infra/backup \
|
||||
--push
|
||||
- name: Build server image
|
||||
run: |
|
||||
# Context server/: server/Dockerfile copies the Cargo workspace from its own directory.
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--build-arg CARGO_BUILD_JOBS=1 \
|
||||
-f ./server/Dockerfile \
|
||||
-t gitea.lan/jacobh2/kvitt-server:${{ env.VERSION }} \
|
||||
-t gitea.lan/jacobh2/kvitt-server:latest \
|
||||
./server \
|
||||
--push
|
||||
- name: Tag and Push Version
|
||||
run: |
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
+311
-8
@@ -1,9 +1,9 @@
|
||||
# infra: Kvitt infrastructure
|
||||
|
||||
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001) and the
|
||||
**Firebase setup** (SPEC §10.7). The Pi stack (`infra/compose.yaml`, Caddy with the Let's Encrypt certificate,
|
||||
cert-sync, the Gitea registry, Drive backups, deploy and APK publishing) is built by M2c-pi, which adds its own
|
||||
sections here.
|
||||
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001), the
|
||||
**Firebase setup** (SPEC §10.7) and the **Pi stack** (`infra/pi/`, milestone M2c-pi, D-100): a self-contained
|
||||
bundle the owner copies to the Kvitt Pi and runs there. No agent and no Mac script connects to the Pis; the owner
|
||||
runs every Pi command from the checklist in "Pi: what the owner does".
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -17,10 +17,16 @@ sections here.
|
||||
| `dev/backup.sh` | Runs the backup job once and checks the snapshot with `PRAGMA integrity_check` |
|
||||
| `dev/copy-google-services.sh` | Copies `secrets/google-services.json` to `android/app/google-services.json` |
|
||||
| `dev/lib.sh` | Helpers sourced by the scripts |
|
||||
| `backup/Dockerfile`, `backup/backup.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack) |
|
||||
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`. Never committed |
|
||||
| `backup/Dockerfile`, `backup/backup.sh`, `backup/healthcheck.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion |
|
||||
| `cert-sync/Dockerfile`, `cert-sync/sync.sh`, `cert-sync/entrypoint.sh` | Certificate sync image of SPEC §10.5.1 |
|
||||
| `pi/` | **The Pi bundle** (copied to the Kvitt Pi): `compose.yaml`, `Caddyfile`, `.env.example`, `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`; git-ignored `pi/.env`, `pi/secrets/`, `pi/out/` |
|
||||
| `build-images.sh` | Mac fallback for the image build: linux/arm64, push to the Gitea registry |
|
||||
| `publish-apk.sh` | Mac: release APK, mapping and `latest.json` into `pi/out/apk/` for the owner to copy to the Pi |
|
||||
| `RESTORE.md` | Restore from a Drive backup, step by step (SPEC §10.9) |
|
||||
| `../.gitea/workflows/build-images.yaml` | Gitea Actions image build (the normal path) |
|
||||
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`, `ha_ssh_key(.pub)`, `rclone.conf`, optional `registry.env`. Never committed |
|
||||
|
||||
All commands below run from the repository root.
|
||||
All Mac commands below run from the repository root of the main checkout (`/Users/jhag/projects/kvitt`).
|
||||
|
||||
## Development stack
|
||||
|
||||
@@ -140,4 +146,301 @@ gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gse
|
||||
Rotating the key: create a new one with
|
||||
`gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337`,
|
||||
restart the server, then delete the old key id with `gcloud iam service-accounts keys delete <key id> ...`.
|
||||
Copying the key to the Pi is M2c-pi (§10.7 step 7).
|
||||
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by `pi/install.sh` ("Pi: what the owner does", step 6).
|
||||
|
||||
## Pi stack (SPEC §10.1 to §10.10, D-100)
|
||||
|
||||
### How it fits together
|
||||
|
||||
| Where | What runs | Who runs it |
|
||||
|---|---|---|
|
||||
| Gitea Actions (runner label `host`, a Raspberry Pi) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") |
|
||||
| Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) |
|
||||
| Kvitt Pi, `/opt/kvitt/` | `compose.yaml`, `Caddyfile`, `.env`, `secrets/`, `import/`, `apk/`, and the scripts `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (copied there by `install.sh`) | The owner, in their own SSH session |
|
||||
| Home Assistant Pi | Its own certbot renews `icehack.asuscomm.com`; cert-sync reads the two PEM files over SSH with a dedicated key | Untouched apart from one `authorized_keys` entry |
|
||||
|
||||
Stack (`pi/compose.yaml`, project name `kvitt`): `caddy` (publishes 8443, the only published port), `server`,
|
||||
`cert-sync`, `backup`; volumes `data`, `certs`, `caddy_data`, `backups`; network `kvitt`; every service
|
||||
`restart: unless-stopped`, json-file logs 10 MB x 3, `TZ=Europe/Stockholm`. Images are
|
||||
`${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup:${KVITT_TAG}` with `KVITT_REGISTRY=gitea.lan/jacobh2`
|
||||
(D-104; the names the workflow pushes).
|
||||
|
||||
### Versions (resolved 2026-10-18)
|
||||
|
||||
| Component | Version | Source |
|
||||
|---|---|---|
|
||||
| Caddy | `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af` (multi-arch index; has linux/arm64/v8) | Docker Hub official image; tag `2.11.7` = `2.11` = `latest`, pushed 2026-10-14 |
|
||||
| Alpine (cert-sync, backup) | `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` | Docker Hub official image; `3.24` = `latest` |
|
||||
| cert-sync packages (Alpine v3.24) | openssh-client-default 10.3_p1-r1, curl 8.22.0-r0, openssl 3.5.9-r0, supercronic 0.2.49-r1, tzdata 2026e-r0 | `apk policy` in the pinned image |
|
||||
| backup packages (Alpine v3.24) | sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e (M2c-dev), supercronic 0.2.49-r1 | `apk policy` in the pinned image |
|
||||
| Docker Engine on the Pi | Docker's Debian apt repository, arm64 packages (`docker-ce` 5:29.8.2 current): `docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin` | docs.docker.com/engine/install/raspberry-pi-os/: that page is for 32-bit (armhf) only; 64-bit Raspberry Pi OS uses the Debian instructions (docs.docker.com/engine/install/debian/) |
|
||||
|
||||
**Why supercronic, not busybox crond (D-104).** Both sidecars run as non-root users (cert-sync uid 1000, backup
|
||||
uid 10001). Busybox crond started as a non-root user in alpine 3.24.2 never ran a job (tested 2026-10-18: an
|
||||
every-minute job produced no output in two minutes). supercronic is a cron for containers that runs jobs as the
|
||||
user it runs as and honours `TZ`; the schedules are unchanged (`0 4 * * *` cert-sync, `30 3 * * *` backup).
|
||||
|
||||
### Backup uid (D-105, SPEC §10.2 "M2c-pi reconciles the two")
|
||||
|
||||
`backup` runs as **uid 10001**, the server's uid, on the Pi as on the Mac: `sqlite3 .backup` on a WAL database
|
||||
must open (and write) `kvitt.sqlite-shm`, which the server creates as 10001 with mode 0644, so uid 1000 could not
|
||||
back up the live database without loosening the server's files. Therefore `/opt/kvitt/secrets/rclone.conf` is
|
||||
mode 0600 **owned by 10001:10001** (not 1000). cert-sync stays uid 1000 with `ha_ssh_key` and `ha_known_hosts`
|
||||
mode 0600 owned by 1000:1000 (ssh refuses a key not owned by its user or readable by others). rclone writes
|
||||
refreshed access tokens back to its config; the mounted file is read-only, so `backup.sh` works on a private copy
|
||||
in `/tmp` each run. `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to
|
||||
10001, `/certs` to 1000) with a one-off root container (`docker compose run --user 0:0 --cap-add CHOWN`). Caddy
|
||||
runs as root (official image) and reads the 0640 certificate files.
|
||||
|
||||
### What was checked on the Mac (2026-10-18)
|
||||
|
||||
* `shellcheck` on every `infra/**/*.sh`: clean.
|
||||
* `podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config`: exit 0.
|
||||
* `cert-sync` and `backup` images built for linux/arm64 with Podman (not pushed).
|
||||
* End-to-end rehearsal with Podman: a stand-in Home Assistant sshd with the variant A key below, the cert-sync
|
||||
image as uid 1000 with `StrictHostKeyChecking=yes`, the pinned Caddy with `pi/Caddyfile` in front of a stand-in
|
||||
server. The first fetch installed both files (0640); `GET /health` through Caddy answered over TLS; a renewed
|
||||
certificate was installed and Caddy reloaded through its admin API without a restart; an unchanged certificate
|
||||
was left alone; an older certificate and a key that does not match the certificate were refused with the old pair
|
||||
kept; TLS 1.1 was refused. **Caddy logged the request headers with `"Authorization":["REDACTED"]` and the token
|
||||
never appeared in its log** (SPEC §10.5.2).
|
||||
* Backup rehearsal with a local rclone remote: upload, `lsf` confirmation by name and size, local deletion only
|
||||
after both files were confirmed (D-002); files kept and `last-success` unchanged when the upload failed; the
|
||||
leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h.
|
||||
* Not checked (needs the real hosts): the Home Assistant add-on's handling of `authorized_keys` options, the
|
||||
Gitea workflow (not yet run), pulls from `gitea.lan`, the router forward, Drive.
|
||||
|
||||
### Gitea Actions workflow (`.gitea/workflows/build-images.yaml`), not yet run
|
||||
|
||||
Same setup as the owner's working example: `runs-on: host`, `sh` steps, `apk add git docker-cli docker-cli-buildx`,
|
||||
`docker login gitea.lan` with the repository secret `kvitt_cicd` (created by the owner) and `${{ github.actor }}`,
|
||||
a buildx builder whose `buildkitd.toml` sets `http = true` for `gitea.lan` (created once and reused, so its cache
|
||||
survives), `actions/checkout@v4` with `set-safe-directory` and `fetch-depth: 0`, the version from the newest
|
||||
`v1.0.*` git tag plus one, and the git tag pushed after the build. That version is what `deploy.sh` takes.
|
||||
|
||||
**Risk: the runner is a Raspberry Pi 3 with 1 GB RAM.** The server image compiles about 232 Rust crates,
|
||||
aws-lc-sys's C code among them. The workflow passes `--build-arg CARGO_BUILD_JOBS=1`; it takes effect only once
|
||||
`server/Dockerfile` declares `ARG CARGO_BUILD_JOBS` in its build stage (requested from the server's owner; until
|
||||
then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The
|
||||
Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build
|
||||
is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If
|
||||
it fails for memory, add swap on the runner Pi, or build on the Mac with `infra/build-images.sh`.
|
||||
|
||||
### Mac fallback: `infra/build-images.sh [tag]`
|
||||
|
||||
Builds the same three images for linux/arm64 and pushes them when `infra/secrets/registry.env` exists
|
||||
(`KVITT_REGISTRY=gitea.lan/jacobh2`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` with
|
||||
write:package); otherwise it builds locally and prints the exact push commands. Tag: the short git SHA by default,
|
||||
plus `latest`. Refuses a dirty tree and does not build while a Gradle build holds the gradle-serial lock. With
|
||||
Podman (this Mac) it pushes with `--tls-verify=false`; with Docker it creates a buildx builder that allows plain
|
||||
HTTP to the registry.
|
||||
|
||||
### Publishing an APK: `infra/publish-apk.sh`
|
||||
|
||||
Version rule (SPEC §10.6): `version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-<short sha>`
|
||||
of HEAD's commit date (Europe/Stockholm), passed as `-Pkvitt.versionCode` and `-Pkvitt.versionName`. The image
|
||||
tags of the Gitea workflow (`v1.0.N`) are separate and say nothing about the APK. The script refuses a dirty tree,
|
||||
builds `:app:assembleRelease` through `tools/gradle-serial.sh` (JDK 25 on `PATH`), refuses an unsigned APK, and
|
||||
writes into `infra/pi/out/apk/`: `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` (the R8 mapping,
|
||||
for reading stack traces; never served) and `latest.json` (temp file, then rename); it keeps the newest 5 APKs
|
||||
with their mappings. Then the owner, on the Mac:
|
||||
|
||||
```bash
|
||||
cd /Users/jhag/projects/kvitt
|
||||
scp infra/pi/out/apk/* <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/opt/kvitt/apk/
|
||||
ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST> /opt/kvitt/install-apk.sh
|
||||
```
|
||||
|
||||
`install-apk.sh` checks the SHA-256 against `latest.json`, copies the APKs into the `data` volume (`/data/apk/`)
|
||||
and `latest.json` last with a rename, keeps the newest 5 there and prints `GET /apk/latest`.
|
||||
|
||||
## Pi: what the owner does
|
||||
|
||||
Fill these in first (in `infra/pi/.env` on the Mac; the bundle carries the file to the Pi):
|
||||
|
||||
| Placeholder in `infra/pi/.env` | What | Where to find it |
|
||||
|---|---|---|
|
||||
| `KVITT_PI_HOST` | LAN address of the Kvitt Pi (give it a static DHCP lease) | ASUS router web UI, LAN, DHCP Server, client list |
|
||||
| `KVITT_PI_SSH_USER` | Your user on the Kvitt Pi | The user set in Raspberry Pi Imager |
|
||||
| `HA_HOST` | LAN address of the Home Assistant Pi | Router client list, or Home Assistant, Settings, System, Network |
|
||||
| `HA_PORT` | SSH port of the SSH add-on (default 22) | Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, Network |
|
||||
| `HA_USER` | SSH user of the add-on (`root` for "Terminal & SSH"; the configured `username` for "Advanced SSH & Web Terminal") | Same add-on, Configuration |
|
||||
| `HA_CERT_DIR` | Directory holding `fullchain.pem` and `privkey.pem` on the Home Assistant Pi (`/ssl` on Home Assistant OS) | In the add-on's terminal: `ls -l /ssl` |
|
||||
| `KVITT_TAG` | Image tag to run: the newest `v1.0.N` the Gitea workflow pushed (or the SHA from `build-images.sh`) | Gitea, the repository, Packages, `kvitt-server` |
|
||||
| `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` | Only if the Gitea packages are private: a Gitea user and a token with `read:package` | Gitea, Settings, Applications, Generate New Token |
|
||||
| `KVITT_REGISTRY` | Already `gitea.lan/jacobh2` | |
|
||||
| `RCLONE_REMOTE`, `RCLONE_FOLDER_ID` | Already `gdrive:` and `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` | |
|
||||
|
||||
### 1. Provision the Kvitt Pi
|
||||
|
||||
Raspberry Pi Imager on the Mac: Raspberry Pi 3, **Raspberry Pi OS Lite (64-bit)**; in its settings set the host
|
||||
name, your user and password, the network, time zone Europe/Stockholm, and enable SSH with your public key. Boot
|
||||
it, give it a static DHCP lease in the router, then from the Mac: `ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>`.
|
||||
|
||||
### 2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)
|
||||
|
||||
On the Pi:
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y ca-certificates curl python3
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/debian
|
||||
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker "$USER"
|
||||
exit
|
||||
```
|
||||
|
||||
Log in again; `docker compose version` prints a version and `docker run --rm hello-world` prints
|
||||
"Hello from Docker!".
|
||||
|
||||
### 3. Put the cert-sync key on the Home Assistant SSH add-on
|
||||
|
||||
Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, `authorized_keys`: add **one** new entry.
|
||||
Try variant A first (replace `/ssl` if your `HA_CERT_DIR` differs):
|
||||
|
||||
Variant A (restricted, preferred):
|
||||
|
||||
```
|
||||
restrict,command="tar -C /ssl -cf - fullchain.pem privkey.pem" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
|
||||
```
|
||||
|
||||
Save, restart the add-on. If the add-on refuses to save or start with it (its Log tab shows an error about the
|
||||
key), replace it with variant B:
|
||||
|
||||
Variant B (plain key):
|
||||
|
||||
```
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
|
||||
```
|
||||
|
||||
cert-sync works with both unchanged: it always asks for exactly that `tar` command. Tell the orchestrator which
|
||||
variant the add-on accepted, so it is recorded here. The private key is
|
||||
`/Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key` (git-ignored; never paste it anywhere).
|
||||
|
||||
### 4. Fill the `.env` and stage the secrets, on the Mac
|
||||
|
||||
```bash
|
||||
cd /Users/jhag/projects/kvitt
|
||||
cp infra/pi/.env.example infra/pi/.env
|
||||
open -e infra/pi/.env # replace every CHANGE_ME (table above), save
|
||||
mkdir -p infra/pi/secrets
|
||||
cp infra/secrets/fcm-service-account.json infra/secrets/ha_ssh_key infra/secrets/rclone.conf infra/pi/secrets/
|
||||
```
|
||||
|
||||
`rclone.conf` is already generated from your `rclone authorize "drive"` (remote `[gdrive]`, `type = drive`,
|
||||
`scope = drive`, the token); the Drive folder id is `RCLONE_FOLDER_ID` in `.env`, not in the conf.
|
||||
`infra/pi/.env` and `infra/pi/secrets/` are git-ignored.
|
||||
|
||||
### 5. Copy the bundle to the Pi and record the Home Assistant host key
|
||||
|
||||
On the Mac:
|
||||
|
||||
```bash
|
||||
scp -r infra/pi <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:kvitt-pi
|
||||
rm -r infra/pi/secrets # the originals stay in infra/secrets/
|
||||
```
|
||||
|
||||
On the Pi:
|
||||
|
||||
```bash
|
||||
cd ~/kvitt-pi
|
||||
ssh-keyscan -p <HA_PORT> <HA_HOST> > secrets/ha_known_hosts
|
||||
wc -l secrets/ha_known_hosts # at least 1 line
|
||||
```
|
||||
|
||||
### 6. Install and start
|
||||
|
||||
On the Pi:
|
||||
|
||||
```bash
|
||||
cd ~/kvitt-pi && ./install.sh
|
||||
```
|
||||
|
||||
It creates `/opt/kvitt/{secrets,import,apk}`, installs the four secret files (`fcm-service-account.json` 0440
|
||||
root:10001; `ha_ssh_key`, `ha_known_hosts` 0600 1000:1000; `rclone.conf` 0600 10001:10001), adds `gitea.lan` to
|
||||
`insecure-registries` in `/etc/docker/daemon.json` (and restarts Docker if that changed), copies `compose.yaml`,
|
||||
`Caddyfile`, `.env` (0600) and the scripts to `/opt/kvitt`, pulls, starts and waits. Expected end:
|
||||
`server: healthy`, `cert-sync: healthy`, `caddy: healthy`, a `docker compose ps` table, the line
|
||||
`GET https://icehack.asuscomm.com:8443/health (via this Pi): {"status":"ok",...}`, then "Kvitt is running.".
|
||||
Then `rm -r ~/kvitt-pi/secrets` (the script reminds you). Running `install.sh` again is safe.
|
||||
|
||||
### 7. Router forward
|
||||
|
||||
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule, protocol TCP, external port 8443, internal
|
||||
IP `<KVITT_PI_HOST>`, internal port 8443; Apply. Then on the phone with Wi-Fi off, open
|
||||
`https://icehack.asuscomm.com:8443/health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no).
|
||||
|
||||
### 8. First backup and bootstrap
|
||||
|
||||
On the Pi:
|
||||
|
||||
```bash
|
||||
/opt/kvitt/backup-now.sh # ends with "last-success: <stamp>" and the newest kvitt-*.sqlite.gz and pictures-*.tar.gz on Drive
|
||||
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
|
||||
```
|
||||
|
||||
### 9. Deploying a new version
|
||||
|
||||
After the Gitea workflow pushed `v1.0.N` (or `infra/build-images.sh` printed a tag), on the Pi:
|
||||
|
||||
```bash
|
||||
/opt/kvitt/deploy.sh v1.0.N
|
||||
```
|
||||
|
||||
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets
|
||||
`KVITT_TAG`, pulls, starts, waits for `server: healthy` and prints `/health`. If the server does not come up:
|
||||
`/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup`; if a migration had partially applied,
|
||||
restore the pre-deploy backup (`infra/RESTORE.md`). A newer bundle with a changed `compose.yaml` or `Caddyfile` is
|
||||
installed by copying the bundle again (step 5, without the secrets) and running `./install.sh`.
|
||||
|
||||
### Fallback: Caddy internal CA (SPEC §10.5.3, not deployed)
|
||||
|
||||
Only if cert-sync cannot be made to work. In `/opt/kvitt/Caddyfile` replace the line
|
||||
`tls /certs/fullchain.pem /certs/privkey.pem` with `tls internal`, then `docker compose restart caddy` in
|
||||
`/opt/kvitt`. Copy Caddy's root to the Mac once: on the Pi
|
||||
`docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/kvitt_root_ca.crt`, then on the Mac
|
||||
`scp <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/tmp/kvitt_root_ca.crt android/app/src/release/res/raw/kvitt_root_ca.crt`
|
||||
(a public certificate, committed by an Android agent) and set `kvitt.tls.pinnedRoot=true` in
|
||||
`android/gradle.properties` (D-082). The root is valid 10 years. Caddy still waits for cert-sync to be healthy,
|
||||
which needs the two files in the `certs` volume: keep the last fetched pair there, or remove the `cert-sync`
|
||||
condition from `depends_on` in `/opt/kvitt/compose.yaml`.
|
||||
|
||||
### Operations (SPEC §10.10), on the Pi in `/opt/kvitt`
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Status | `docker compose ps` |
|
||||
| Logs | `docker compose logs --tail 100 <caddy, server, cert-sync or backup>` |
|
||||
| Backup now | `/opt/kvitt/backup-now.sh` |
|
||||
| Upgrade | `/opt/kvitt/deploy.sh <tag>` |
|
||||
| Roll back | `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` |
|
||||
| Certificate now | `docker compose exec cert-sync /usr/local/bin/sync.sh` |
|
||||
| Certificate expiry and issuer | `docker compose exec cert-sync openssl x509 -in /certs/fullchain.pem -noout -enddate -issuer` |
|
||||
| Extra device invite | `docker compose exec server kvitt-server pair-device --user <user_id>` |
|
||||
| Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) |
|
||||
| Disk | `df -h /` and `docker system df`; SQLite a few MB, pictures under 1 MB, APKs about 20 MB each, 5 kept |
|
||||
| No token in Caddy's log | `docker compose logs caddy \| grep -c 'kvt_'` prints `0` |
|
||||
| Restore | `infra/RESTORE.md` |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
* `cert-sync` unhealthy, so `caddy` does not start: `docker compose logs cert-sync`. "Host key verification
|
||||
failed": redo `ssh-keyscan` (step 5) and run `./install.sh` again. "Permission denied (publickey)": the add-on
|
||||
does not have the key (step 3). "did not hold fullchain.pem and privkey.pem": wrong `HA_CERT_DIR`, or `HA_USER`
|
||||
cannot read `privkey.pem`.
|
||||
* Pull fails with "http: server gave HTTP response to HTTPS client": `gitea.lan` is missing from
|
||||
`/etc/docker/daemon.json` (`install.sh` adds it; check with `docker info | grep -A3 'Insecure Registries'`).
|
||||
* Pull fails with "unauthorized": the packages are private; set `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`
|
||||
in `~/kvitt-pi/.env` and run `./install.sh` again.
|
||||
* `install.sh` says `/opt/kvitt/secrets/<file>` is a directory: Docker created it because the file was missing at a
|
||||
start; `sudo rmdir` it, put the file in `~/kvitt-pi/secrets/`, run `./install.sh`.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Restore Kvitt from a Drive backup (SPEC §10.9)
|
||||
|
||||
Run on the Kvitt Pi, in `/opt/kvitt`, as the owner's user. Every command uses the running `backup` container,
|
||||
which has `sqlite3`, `rclone`, the Drive credentials and the `data` and `backups` volumes, and runs as uid 10001,
|
||||
the owner of the server's files. Nothing is deleted: the current database and pictures are moved aside.
|
||||
Do not run it around 03:30, when the nightly backup runs.
|
||||
|
||||
```bash
|
||||
cd /opt/kvitt
|
||||
B="docker compose exec -T backup sh -c"
|
||||
FOLDER=$(sed -n 's/^RCLONE_FOLDER_ID=//p' .env); REMOTE=$(sed -n 's/^RCLONE_REMOTE=//p' .env)
|
||||
```
|
||||
|
||||
## 1. Stop the server
|
||||
|
||||
```bash
|
||||
docker compose stop server
|
||||
```
|
||||
|
||||
## 2. Download the chosen backup from Drive to the Pi
|
||||
|
||||
List what is on Drive (newest last) and pick a stamp:
|
||||
|
||||
```bash
|
||||
$B 'c=$(mktemp); cp /config/rclone/rclone.conf "$c"; RCLONE_CONFIG=$c rclone lsf "$1" --drive-root-folder-id "$2" --files-only; rm -f "$c"' sh "$REMOTE" "$FOLDER" | sort
|
||||
STAMP=20261102T013000Z # the stamp of the pair you chose
|
||||
```
|
||||
|
||||
Download both files into the `backups` volume:
|
||||
|
||||
```bash
|
||||
$B 'set -e; mkdir -p /backups/restore; c=$(mktemp); cp /config/rclone/rclone.conf "$c"
|
||||
for f in "kvitt-$3.sqlite.gz" "pictures-$3.tar.gz"; do
|
||||
RCLONE_CONFIG=$c rclone copy "$1$f" /backups/restore/ --drive-root-folder-id "$2"
|
||||
done; rm -f "$c"; ls -l /backups/restore' sh "$REMOTE" "$FOLDER" "$STAMP"
|
||||
```
|
||||
|
||||
(A file downloaded in the browser instead: copy it to the Pi with `scp`, then
|
||||
`docker compose cp <file> backup:/backups/restore/`.)
|
||||
|
||||
## 3. Unpack and check
|
||||
|
||||
```bash
|
||||
$B 'set -e; cd /backups/restore; gunzip -f "kvitt-$1.sqlite.gz"; sqlite3 "kvitt-$1.sqlite" "PRAGMA integrity_check"' sh "$STAMP"
|
||||
```
|
||||
|
||||
It must print `ok`. Anything else: stop here and pick another backup.
|
||||
|
||||
## 4. Move the current data aside and put the backup in place
|
||||
|
||||
```bash
|
||||
$B 'set -e; aside="/data/pre-restore-$(date -u +%Y%m%dT%H%M%SZ)"; mkdir "$aside"
|
||||
for f in /data/kvitt.sqlite /data/kvitt.sqlite-wal /data/kvitt.sqlite-shm /data/pictures; do
|
||||
[ -e "$f" ] && mv "$f" "$aside/"
|
||||
done
|
||||
cp "/backups/restore/kvitt-$1.sqlite" /data/kvitt.sqlite
|
||||
tar -xzf "/backups/restore/pictures-$1.tar.gz" -C /data
|
||||
echo "previous data kept in $aside"; ls -l /data' sh "$STAMP"
|
||||
```
|
||||
|
||||
## 5. New sync epoch
|
||||
|
||||
```bash
|
||||
docker compose run --rm server rotate-epoch
|
||||
```
|
||||
|
||||
It prints the new epoch. The phones see it and re-push their data (§14.4.8).
|
||||
|
||||
## 6. Start the server and check health
|
||||
|
||||
```bash
|
||||
docker compose start server
|
||||
docker compose ps server # healthy within a minute
|
||||
curl -fsS --resolve icehack.asuscomm.com:8443:127.0.0.1 https://icehack.asuscomm.com:8443/health
|
||||
```
|
||||
|
||||
The last line prints `{"status":"ok",...}`.
|
||||
|
||||
## 7. The phones
|
||||
|
||||
Open Kvitt on both phones (owner's and partner's) and leave it open for a minute; the next sync detects the new
|
||||
epoch and re-pushes the phone's data (if nothing seems to happen, close the app and open it again). Check that both phones show the same balances on Översikt (yes/no).
|
||||
|
||||
## Afterwards
|
||||
|
||||
When the phones agree, the downloaded files can go:
|
||||
`$B 'rm -r /backups/restore'`. The `/data/pre-restore-<stamp>/` folder stays until you decide otherwise
|
||||
(`$B 'ls /data'` shows it). Run `/opt/kvitt/backup-now.sh` once to put the restored state on Drive.
|
||||
+16
-9
@@ -1,20 +1,27 @@
|
||||
# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh run by crond.
|
||||
# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh on a daily schedule.
|
||||
# Versions resolved 2026-10-17: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below);
|
||||
# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e.
|
||||
# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e;
|
||||
# 2026-10-18: supercronic 0.2.49.
|
||||
# The scheduler is supercronic, not busybox crond: on the Pi the container runs as uid 10001, not root (README,
|
||||
# "Backup uid"), and busybox crond cannot run jobs as a non-root user (checked 2026-10-18). See D-104.
|
||||
FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
RUN apk add --no-cache \
|
||||
"sqlite~3.53" \
|
||||
"rclone~1.74" \
|
||||
"tar~1.35" \
|
||||
"supercronic~0.2" \
|
||||
gzip \
|
||||
tzdata
|
||||
tzdata \
|
||||
&& adduser -D -u 10001 -g "Kvitt backup" kvitt \
|
||||
&& mkdir -p /backups /data \
|
||||
&& chown 10001:10001 /backups /data
|
||||
|
||||
COPY backup.sh /backup.sh
|
||||
RUN chmod 0755 /backup.sh \
|
||||
&& echo '30 3 * * * /backup.sh' > /etc/crontabs/root
|
||||
COPY backup.sh healthcheck.sh /
|
||||
RUN chmod 0755 /backup.sh /healthcheck.sh \
|
||||
&& echo '30 3 * * * /backup.sh' > /etc/kvitt-backup.crontab
|
||||
|
||||
ENV TZ=Europe/Stockholm
|
||||
# Daily at 03:30 Europe/Stockholm. The development stack runs it on demand instead
|
||||
# (`docker compose run --rm backup /backup.sh`, see infra/README.md).
|
||||
CMD ["crond", "-f", "-l", "8"]
|
||||
# Daily at 03:30 Europe/Stockholm; /tmp/kvitt-started feeds the healthcheck's "no run yet since start".
|
||||
# The development stack runs it on demand instead (`docker compose run --rm backup /backup.sh`, see infra/README.md).
|
||||
CMD ["sh", "-c", "date +%s > /tmp/kvitt-started && exec supercronic -quiet /etc/kvitt-backup.crontab"]
|
||||
|
||||
+32
-7
@@ -3,9 +3,11 @@
|
||||
# 1. online, consistent snapshot of /data/kvitt.sqlite with sqlite3 .backup
|
||||
# 2. PRAGMA integrity_check on the snapshot must print ok, else abort
|
||||
# 3. gzip -9 the snapshot and tar the pictures, both with the same UTC stamp
|
||||
# 4. Drive upload: skipped when RCLONE_FOLDER_ID is empty (the development stack, D-001);
|
||||
# the upload itself and the D-002 local deletion are built by M2c-pi
|
||||
# 5. write /backups/last-success with the stamp
|
||||
# 4. Drive upload when RCLONE_FOLDER_ID is set (the Pi): rclone copy --immutable of every kvitt-*.sqlite.gz and
|
||||
# pictures-*.tar.gz in /backups (this run's pair and any left by an earlier failed upload), never sync, never
|
||||
# delete on Drive; once every file is confirmed on Drive with rclone lsf (name and size) the local copies are
|
||||
# deleted (D-002). Skipped with a log line when RCLONE_FOLDER_ID is empty (the development stack, D-001): files kept.
|
||||
# 5. write /backups/last-success with the stamp (only after this run's pair is confirmed on Drive, when uploading)
|
||||
# Logs carry file names and sizes only, never database content (SPEC §8.7).
|
||||
set -eu
|
||||
|
||||
@@ -44,13 +46,36 @@ fi
|
||||
mv "$pics_out.tmp" "$pics_out"
|
||||
log "wrote $(basename "$db_out") ($(wc -c < "$db_out") bytes) and $(basename "$pics_out") ($(wc -c < "$pics_out") bytes)"
|
||||
|
||||
# 4. Drive upload
|
||||
# 4. Drive upload (D-002)
|
||||
if [ -z "${RCLONE_FOLDER_ID:-}" ]; then
|
||||
log "RCLONE_FOLDER_ID is empty: Drive upload skipped, local files kept"
|
||||
else
|
||||
# TODO(M2c-pi): rclone copy --immutable to "${RCLONE_REMOTE:-gdrive:}" --drive-root-folder-id, confirm with
|
||||
# rclone lsf, then delete the local files (D-002). Not part of the development stack (D-001).
|
||||
fail "RCLONE_FOLDER_ID is set but the Drive upload is not built yet (M2c-pi); local files kept"
|
||||
remote=${RCLONE_REMOTE:-gdrive:}
|
||||
conf_src=${RCLONE_CONFIG_SOURCE:-/config/rclone/rclone.conf}
|
||||
[ -r "$conf_src" ] || fail "no readable rclone config at $conf_src; local files kept"
|
||||
# rclone writes refreshed access tokens back to its config; the mounted one is read-only, so use a private copy.
|
||||
conf=$(mktemp)
|
||||
cp "$conf_src" "$conf"
|
||||
export RCLONE_CONFIG="$conf"
|
||||
trap 'rm -f "$conf"' EXIT
|
||||
confirmed=""
|
||||
for f in "$BACKUP_DIR"/kvitt-*.sqlite.gz "$BACKUP_DIR"/pictures-*.tar.gz; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
size=$(wc -c < "$f" | tr -d ' ')
|
||||
rclone copy "$f" "$remote" --drive-root-folder-id "$RCLONE_FOLDER_ID" --immutable --retries 3 --stats 0 --log-level ERROR \
|
||||
|| fail "rclone copy of $name failed; local files kept"
|
||||
listed=$(rclone lsf "$remote" --drive-root-folder-id "$RCLONE_FOLDER_ID" --files-only --format sp --separator ' ' \
|
||||
--include "/$name" --log-level ERROR) || fail "rclone lsf could not confirm $name; local files kept"
|
||||
[ "$listed" = "$size $name" ] || fail "$name is not on Drive with $size bytes; local file kept"
|
||||
log "uploaded and confirmed on Drive: $name ($size bytes)"
|
||||
confirmed="$confirmed $f"
|
||||
done
|
||||
# Every file is confirmed on Drive: delete the local copies (D-002). Nothing on Drive is ever deleted.
|
||||
for f in $confirmed; do rm -f "$f"; done
|
||||
log "local copies deleted after Drive confirmation"
|
||||
rm -f "$conf"
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
# 5. Success marker
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# backup healthcheck (SPEC §10.2): healthy when /backups/last-success is newer than 26 hours, or when no run is due
|
||||
# yet since the container started (started less than 26 hours ago).
|
||||
limit=$((26 * 3600))
|
||||
now=$(date +%s)
|
||||
marker=${BACKUP_DIR:-/backups}/last-success
|
||||
if [ -f "$marker" ] && [ $((now - $(stat -c %Y "$marker"))) -lt "$limit" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ -f /tmp/kvitt-started ] && [ $((now - $(cat /tmp/kvitt-started))) -lt "$limit" ]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "no successful backup in the last 26 hours"
|
||||
exit 1
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the three Kvitt images for linux/arm64 on the Mac and push them to the Gitea registry (SPEC §10.3).
|
||||
# The Mac fallback for the Gitea Actions workflow .gitea/workflows/build-images.yaml (which is the normal path).
|
||||
# Usage: infra/build-images.sh [tag] tag defaults to the short git SHA of HEAD; `latest` is always tagged too.
|
||||
#
|
||||
# Images (D-104): ${KVITT_REGISTRY}/kvitt-server (context server/), kvitt-cert-sync (infra/cert-sync),
|
||||
# kvitt-backup (infra/backup). KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env
|
||||
# (git-ignored):
|
||||
# KVITT_REGISTRY=gitea.lan/jacobh2
|
||||
# KVITT_REGISTRY_USER=<gitea user> optional
|
||||
# KVITT_REGISTRY_TOKEN=<token, write:package> optional
|
||||
# Without that file the images are built locally and the exact push commands are printed instead.
|
||||
# Refuses a dirty working tree, and refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md).
|
||||
# Works with Docker (buildx) and with Podman (this Mac's `docker`); the registry is plain HTTP on the home Wi-Fi.
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
[[ -z "$(git status --porcelain)" ]] || { echo "build-images.sh: the working tree is dirty; commit or stash first." >&2; exit 1; }
|
||||
lock=/tmp/kvitt-gradle.lock
|
||||
if [[ -d "$lock" ]] && pid="$(cat "$lock/pid" 2>/dev/null)" && [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo "build-images.sh: a Gradle build is running (pid $pid); try again when it is done." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag="${1:-$(git rev-parse --short HEAD)}"
|
||||
[[ "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || { echo "build-images.sh: not a valid tag: $tag" >&2; exit 2; }
|
||||
|
||||
reg_file="$repo_root/infra/secrets/registry.env"
|
||||
registry="gitea.lan/jacobh2"; user=""; token=""; push=0
|
||||
if [[ -f "$reg_file" ]]; then
|
||||
value() { sed -n "s/^$1=//p" "$reg_file" | tail -n 1; }
|
||||
registry="$(value KVITT_REGISTRY)"; registry="${registry:-gitea.lan/jacobh2}"
|
||||
user="$(value KVITT_REGISTRY_USER)"; token="$(value KVITT_REGISTRY_TOKEN)"
|
||||
push=1
|
||||
fi
|
||||
registry_host="${registry%%/*}"
|
||||
|
||||
# name|context|dockerfile
|
||||
images=(
|
||||
"kvitt-server|server|server/Dockerfile"
|
||||
"kvitt-cert-sync|infra/cert-sync|infra/cert-sync/Dockerfile"
|
||||
"kvitt-backup|infra/backup|infra/backup/Dockerfile"
|
||||
)
|
||||
|
||||
if docker version 2>/dev/null | grep -qi podman; then engine=podman; else engine=docker; fi
|
||||
echo "Engine: $engine; registry: $registry; tag: $tag (and latest); push: $([[ $push == 1 ]] && echo yes || echo no)"
|
||||
|
||||
if [[ $push == 1 && -n "$user" && -n "$token" ]]; then
|
||||
if [[ $engine == podman ]]; then
|
||||
printf '%s' "$token" | podman login --tls-verify=false "$registry_host" -u "$user" --password-stdin
|
||||
else
|
||||
printf '%s' "$token" | docker login "$registry_host" -u "$user" --password-stdin
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $engine == docker && $push == 1 ]]; then
|
||||
# A buildx builder that may push to the plain-HTTP registry (same rule as the Gitea workflow).
|
||||
if ! docker buildx inspect kvitt-http-builder >/dev/null 2>&1; then
|
||||
cfg="$(mktemp)"
|
||||
printf '[registry."%s"]\n http = true\n' "$registry_host" > "$cfg"
|
||||
docker buildx create --name kvitt-http-builder --config "$cfg" >/dev/null
|
||||
rm -f "$cfg"
|
||||
fi
|
||||
fi
|
||||
|
||||
for spec in "${images[@]}"; do
|
||||
IFS='|' read -r name context dockerfile <<< "$spec"
|
||||
ref="$registry/$name"
|
||||
echo "== $ref:$tag"
|
||||
if [[ $engine == podman ]]; then
|
||||
podman build --platform linux/arm64 -f "$dockerfile" -t "$ref:$tag" -t "$ref:latest" "$context"
|
||||
if [[ $push == 1 ]]; then
|
||||
podman push --tls-verify=false "$ref:$tag"
|
||||
podman push --tls-verify=false "$ref:latest"
|
||||
fi
|
||||
elif [[ $push == 1 ]]; then
|
||||
docker buildx build --builder kvitt-http-builder --platform linux/arm64 -f "$dockerfile" \
|
||||
-t "$ref:$tag" -t "$ref:latest" --push "$context"
|
||||
else
|
||||
docker buildx build --platform linux/arm64 -f "$dockerfile" -t "$ref:$tag" -t "$ref:latest" --load "$context"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $push == 1 ]]; then
|
||||
echo "Pushed. On the Pi: /opt/kvitt/deploy.sh $tag"
|
||||
else
|
||||
echo
|
||||
echo "Built locally, not pushed: $reg_file does not exist. To push, create it (KVITT_REGISTRY=$registry, plus"
|
||||
echo "KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN if Gitea needs them) and run this script again, or push by hand:"
|
||||
for spec in "${images[@]}"; do
|
||||
IFS='|' read -r name _ _ <<< "$spec"
|
||||
if [[ $engine == podman ]]; then
|
||||
echo " podman push --tls-verify=false $registry/$name:$tag && podman push --tls-verify=false $registry/$name:latest"
|
||||
else
|
||||
echo " docker push $registry/$name:$tag && docker push $registry/$name:latest # needs $registry_host in insecure-registries"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
# Kvitt cert-sync image (SPEC §10.3, §10.5.1): alpine with openssh-client, curl and sync.sh on a daily schedule.
|
||||
# Versions resolved 2026-10-18: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below); packages
|
||||
# from Alpine v3.24 main/community: openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9 (PEM checks),
|
||||
# supercronic 0.2.49, tzdata 2026e.
|
||||
# The scheduler is supercronic, not busybox crond: the container runs as uid 1000 (SPEC §10.2, D-023) and busybox
|
||||
# crond cannot run jobs as a non-root user (checked 2026-10-18 in alpine 3.24.2: no job ever ran). See D-104.
|
||||
FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
RUN apk add --no-cache \
|
||||
"openssh-client-default~10.3" \
|
||||
"curl~8.22" \
|
||||
"openssl~3.5" \
|
||||
"supercronic~0.2" \
|
||||
tzdata \
|
||||
&& adduser -D -u 1000 -g "Kvitt cert-sync" kvitt \
|
||||
&& mkdir -p /certs \
|
||||
&& chown 1000:1000 /certs
|
||||
|
||||
COPY sync.sh entrypoint.sh /usr/local/bin/
|
||||
RUN chmod 0755 /usr/local/bin/sync.sh /usr/local/bin/entrypoint.sh \
|
||||
&& echo '0 4 * * * /usr/local/bin/sync.sh' > /etc/kvitt-cert-sync.crontab
|
||||
|
||||
ENV TZ=Europe/Stockholm
|
||||
USER 1000:1000
|
||||
# sync.sh once at start, then daily at 04:00 Europe/Stockholm.
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# cert-sync container start (SPEC §10.5.1): one sync at start, then the daily schedule. A failed first sync keeps
|
||||
# the container running (the healthcheck reports whether a certificate exists).
|
||||
/usr/local/bin/sync.sh || true
|
||||
exec supercronic -quiet /etc/kvitt-cert-sync.crontab
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/sh
|
||||
# Kvitt certificate sync (SPEC §10.5.1). Fetches fullchain.pem and privkey.pem from the Home Assistant Pi over SSH
|
||||
# and installs them into /certs when they are valid, newer and changed; then reloads Caddy through its admin API.
|
||||
# Any failure logs one line and keeps the existing certificate.
|
||||
#
|
||||
# The remote command is always `tar -C <cert dir> -cf - fullchain.pem privkey.pem`, read as a tar stream. That
|
||||
# works with both authorized_keys variants of the README: with `restrict,command="tar ..."` the forced command
|
||||
# runs whatever is requested, and with a plain key the requested command is the same tar line.
|
||||
#
|
||||
# Environment (from /opt/kvitt/.env): HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR.
|
||||
# Files: /secrets/ha_ssh_key, /secrets/known_hosts (read-only), /certs (writable), /etc/caddy/Caddyfile (read-only).
|
||||
set -eu
|
||||
|
||||
CERTS=${CERTS_DIR:-/certs}
|
||||
KEY=${HA_SSH_KEY:-/secrets/ha_ssh_key}
|
||||
KNOWN_HOSTS=${HA_KNOWN_HOSTS:-/secrets/known_hosts}
|
||||
CADDYFILE=${CADDYFILE:-/etc/caddy/Caddyfile}
|
||||
CADDY_ADMIN=${CADDY_ADMIN:-http://caddy:2019}
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) cert-sync: $*"; }
|
||||
|
||||
tmp=""
|
||||
cleanup() { [ -n "$tmp" ] && rm -rf "$tmp"; return 0; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
fail() { log "FAILED: $*; existing certificate kept"; exit 1; }
|
||||
|
||||
for v in HA_HOST HA_PORT HA_USER HA_CERT_DIR; do
|
||||
eval "val=\${$v:-}"
|
||||
# shellcheck disable=SC2154 # val is set by the eval above
|
||||
[ -n "$val" ] || fail "$v is not set"
|
||||
done
|
||||
[ -r "$KEY" ] || fail "no readable key at $KEY"
|
||||
[ -r "$KNOWN_HOSTS" ] || fail "no readable known_hosts at $KNOWN_HOSTS"
|
||||
|
||||
tmp=$(mktemp -d /tmp/cert-sync.XXXXXX)
|
||||
|
||||
# 1. Fetch as a tar stream.
|
||||
if ! ssh -i "$KEY" -p "$HA_PORT" \
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=20 \
|
||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null \
|
||||
-o LogLevel=ERROR \
|
||||
"$HA_USER@$HA_HOST" "tar -C '$HA_CERT_DIR' -cf - fullchain.pem privkey.pem" > "$tmp/certs.tar" 2> "$tmp/ssh.err"; then
|
||||
fail "ssh to $HA_USER@$HA_HOST:$HA_PORT failed: $(head -c 300 "$tmp/ssh.err" | tr '\n' ' ')"
|
||||
fi
|
||||
mkdir "$tmp/new"
|
||||
tar -xf "$tmp/certs.tar" -C "$tmp/new" fullchain.pem privkey.pem 2>/dev/null || fail "the tar stream did not hold fullchain.pem and privkey.pem"
|
||||
new_chain="$tmp/new/fullchain.pem"
|
||||
new_key="$tmp/new/privkey.pem"
|
||||
|
||||
# 2. Both parse as PEM, the certificate has not expired, and the key belongs to the certificate.
|
||||
openssl x509 -in "$new_chain" -noout 2>/dev/null || fail "fullchain.pem does not parse as a PEM certificate"
|
||||
openssl pkey -in "$new_key" -noout 2>/dev/null || fail "privkey.pem does not parse as a PEM private key"
|
||||
openssl x509 -in "$new_chain" -noout -checkend 0 >/dev/null 2>&1 || fail "fullchain.pem has expired"
|
||||
cert_pub=$(openssl x509 -in "$new_chain" -noout -pubkey | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
key_pub=$(openssl pkey -in "$new_key" -pubout -outform DER 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
[ "$cert_pub" = "$key_pub" ] || fail "privkey.pem does not belong to fullchain.pem"
|
||||
new_end_iso=$(openssl x509 -in "$new_chain" -noout -enddate -dateopt iso_8601 | cut -d= -f2)
|
||||
|
||||
# 3. Unchanged? Older than the installed one?
|
||||
cur_chain="$CERTS/fullchain.pem"
|
||||
cur_key="$CERTS/privkey.pem"
|
||||
if [ -s "$cur_chain" ] && [ -s "$cur_key" ]; then
|
||||
if [ "$(sha256sum < "$new_chain")" = "$(sha256sum < "$cur_chain")" ] && \
|
||||
[ "$(sha256sum < "$new_key")" = "$(sha256sum < "$cur_key")" ]; then
|
||||
log "unchanged (not after $new_end_iso)"
|
||||
exit 0
|
||||
fi
|
||||
# ISO 8601 not-after dates compare as strings.
|
||||
cur_end_iso=$(openssl x509 -in "$cur_chain" -noout -enddate -dateopt iso_8601 2>/dev/null | cut -d= -f2 || true)
|
||||
if [ -n "$cur_end_iso" ] && openssl x509 -in "$cur_chain" -noout -checkend 0 >/dev/null 2>&1 && \
|
||||
[ "$new_end_iso" != "$cur_end_iso" ] && \
|
||||
[ "$(printf '%s\n%s\n' "$new_end_iso" "$cur_end_iso" | sort | head -n 1)" = "$new_end_iso" ]; then
|
||||
fail "fetched certificate (not after $new_end_iso) expires before the installed one ($cur_end_iso)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Install: mode 0640, rename into place (key first, so a reload never pairs a new chain with an old key for long).
|
||||
install -m 0640 "$new_key" "$CERTS/.privkey.pem.new" || fail "cannot write $CERTS"
|
||||
install -m 0640 "$new_chain" "$CERTS/.fullchain.pem.new" || fail "cannot write $CERTS"
|
||||
mv -f "$CERTS/.privkey.pem.new" "$cur_key"
|
||||
mv -f "$CERTS/.fullchain.pem.new" "$cur_chain"
|
||||
log "installed new certificate (not after $new_end_iso)"
|
||||
|
||||
# 5. Reload Caddy. At first start Caddy is not up yet (it waits for this container to be healthy) and loads the
|
||||
# files when it starts, so an unreachable admin API is not a failure.
|
||||
if curl -fsS -o /dev/null --max-time 20 -X POST "$CADDY_ADMIN/load" \
|
||||
-H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @"$CADDYFILE" 2> "$tmp/curl.err"; then
|
||||
log "caddy reloaded"
|
||||
elif curl -fsS -o /dev/null --max-time 5 "$CADDY_ADMIN/config/" 2>/dev/null; then
|
||||
log "FAILED: caddy reload: $(head -c 300 "$tmp/curl.err" | tr '\n' ' '); the new files are in place and load when Caddy restarts"
|
||||
exit 1
|
||||
else
|
||||
log "caddy not reachable; it loads the certificate when it starts"
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
# Kvitt Pi settings (SPEC §10.2). Copy to .env in this folder, replace every CHANGE_ME, then run ./install.sh,
|
||||
# which copies it to /opt/kvitt/.env (mode 0600). install.sh refuses a .env that still contains CHANGE_ME.
|
||||
# No secret files here: those go to /opt/kvitt/secrets/ (infra/README.md "Pi: what the owner does").
|
||||
|
||||
# --- Read by compose.yaml -----------------------------------------------------------------------------------------
|
||||
|
||||
# Registry and owner path of the Kvitt images; images are ${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup.
|
||||
KVITT_REGISTRY=gitea.lan/jacobh2
|
||||
# Image tag to run. deploy.sh sets it; first install: the newest version tag the Gitea workflow pushed (vMAJOR.MINOR.PATCH),
|
||||
# or the short git SHA that infra/build-images.sh printed.
|
||||
KVITT_TAG=CHANGE_ME_TAG
|
||||
# LAN address (or host name) of the Home Assistant Pi that holds the Let's Encrypt certificate (OQ-3).
|
||||
HA_HOST=CHANGE_ME_HA_HOST
|
||||
# SSH port of the Home Assistant SSH add-on (OQ-3; the "Advanced SSH & Web Terminal" and "Terminal & SSH" add-ons default to 22).
|
||||
HA_PORT=22
|
||||
# SSH user on the Home Assistant Pi (OQ-3; the Home Assistant SSH add-ons log in as root, or the user set in the add-on).
|
||||
HA_USER=CHANGE_ME_HA_USER
|
||||
# Directory holding fullchain.pem and privkey.pem on the Home Assistant Pi (OQ-3; /ssl on Home Assistant OS).
|
||||
HA_CERT_DIR=CHANGE_ME_HA_CERT_DIR
|
||||
# rclone remote of the Drive backups, as named in rclone.conf.
|
||||
RCLONE_REMOTE=gdrive:
|
||||
# Google Drive folder id the backups go to (OQ-4, answered).
|
||||
RCLONE_FOLDER_ID=1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV
|
||||
|
||||
# --- Read by install.sh only (never by the containers) ------------------------------------------------------------
|
||||
|
||||
# Gitea user that may pull the images; leave empty when the Gitea packages are public (anonymous pull).
|
||||
KVITT_REGISTRY_USER=
|
||||
# Gitea access token of that user with read:package scope; leave empty for anonymous pull. Never commit a filled .env.
|
||||
KVITT_REGISTRY_TOKEN=
|
||||
|
||||
# --- Informational: used in the owner's commands on the Mac (scp), read by nothing on the Pi --------------------
|
||||
|
||||
# LAN address of the Kvitt Pi itself (OQ-3).
|
||||
KVITT_PI_HOST=CHANGE_ME_KVITT_PI_HOST
|
||||
# SSH user on the Kvitt Pi (OQ-3).
|
||||
KVITT_PI_SSH_USER=CHANGE_ME_KVITT_PI_SSH_USER
|
||||
@@ -0,0 +1,5 @@
|
||||
# Filled on the Mac or the Pi, never committed: host addresses and registry credentials.
|
||||
.env
|
||||
# publish-apk.sh output (APKs, latest.json, R8 mappings) and secrets staged for the copy to the Pi.
|
||||
out/
|
||||
secrets/
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
admin 0.0.0.0:2019
|
||||
auto_https off
|
||||
}
|
||||
|
||||
icehack.asuscomm.com:8443 {
|
||||
tls /certs/fullchain.pem /certs/privkey.pem
|
||||
request_body {
|
||||
max_size 6MB
|
||||
}
|
||||
reverse_proxy server:8080
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the backup once on the Kvitt Pi (SPEC §10.9) and wait for it: /opt/kvitt/backup-now.sh
|
||||
# Succeeds only when backup.sh succeeded and /backups/last-success changed: the snapshot passed integrity_check,
|
||||
# both files are confirmed on Drive and their local copies are deleted (D-002). Lists the newest files on Drive.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
. "$here/lib.sh"
|
||||
|
||||
before=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true)
|
||||
compose exec -T backup /backup.sh || die "backup.sh failed (lines above)"
|
||||
after=$(compose exec -T backup cat /backups/last-success 2>/dev/null || true)
|
||||
[[ -n "$after" && "$after" != "$before" ]] || die "last-success did not change (was '${before:-none}', is '${after:-none}')"
|
||||
say "last-success: $after"
|
||||
remote=$(env_value RCLONE_REMOTE); folder=$(env_value RCLONE_FOLDER_ID)
|
||||
say "Newest files in the Drive folder:"
|
||||
# shellcheck disable=SC2016 # the script runs inside the backup container, with its own \$1 and \$2
|
||||
compose exec -T backup sh -c 'c=$(mktemp); cp /config/rclone/rclone.conf "$c"; RCLONE_CONFIG=$c rclone lsf "$1" --drive-root-folder-id "$2" --files-only --log-level ERROR; s=$?; rm -f "$c"; exit $s' \
|
||||
sh "${remote:-gdrive:}" "$folder" | sort | tail -n 4
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# First admin invite on the Kvitt Pi (SPEC FR-1, §10.4). Run ON the Pi once, after install.sh: /opt/kvitt/bootstrap.sh
|
||||
# Prints the invite code, link and expiry. Exit 2 from the server means the database is already initialised.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
. "$here/lib.sh"
|
||||
|
||||
set +e
|
||||
compose exec -T server kvitt-server bootstrap
|
||||
status=$?
|
||||
set -e
|
||||
case "$status" in
|
||||
0) say "Open the link on the owner's phone (or enter the code on the pairing screen)." ;;
|
||||
2) say "Already initialised: no new invite. Extra devices: docker compose exec server kvitt-server pair-device --user <user_id>" ;;
|
||||
*) die "bootstrap exited $status (docker compose logs server)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,137 @@
|
||||
# Kvitt production stack on the Kvitt Pi (SPEC §10.2). Deployed by install.sh to /opt/kvitt/compose.yaml together
|
||||
# with /opt/kvitt/Caddyfile and /opt/kvitt/.env (from .env.example). Run every command from /opt/kvitt.
|
||||
# Only caddy publishes a port (8443); the server, Caddy's admin API and the sidecars are reachable only on the
|
||||
# internal network `kvitt` (SPEC §8.9).
|
||||
# Image names: ${KVITT_REGISTRY}/kvitt-<name>, the names the Gitea Actions workflow pushes (D-104).
|
||||
|
||||
name: kvitt
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
caddy:
|
||||
# Official image, pinned by version and multi-arch index digest (resolved 2026-10-18, infra/README.md).
|
||||
image: docker.io/library/caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 64m
|
||||
ports:
|
||||
- "8443:8443"
|
||||
environment:
|
||||
TZ: Europe/Stockholm
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- certs:/certs:ro
|
||||
- caddy_data:/data
|
||||
depends_on:
|
||||
cert-sync:
|
||||
condition: service_healthy
|
||||
server:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:2019/config/ >/dev/null"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
logging: *logging
|
||||
networks: [kvitt]
|
||||
|
||||
server:
|
||||
image: ${KVITT_REGISTRY:?set KVITT_REGISTRY in .env}/kvitt-server:${KVITT_TAG:?set KVITT_TAG in .env}
|
||||
command: ["serve"]
|
||||
restart: unless-stopped
|
||||
user: "10001:10001"
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 160m
|
||||
environment:
|
||||
TZ: Europe/Stockholm
|
||||
# The remaining server settings keep their SPEC §10.4 defaults (KVITT_PUBLIC_URL
|
||||
# https://icehack.asuscomm.com:8443, KVITT_TRUSTED_PROXY caddy, KVITT_FCM_PROJECT_ID kvitt-1337, ...).
|
||||
KVITT_DATA_DIR: /data
|
||||
volumes:
|
||||
- data:/data
|
||||
- /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro
|
||||
# Empty except during the one-off Splitwise import (SPEC §10.8, D-025).
|
||||
- /opt/kvitt/import:/import:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "kvitt-server", "healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging: *logging
|
||||
networks: [kvitt]
|
||||
|
||||
cert-sync:
|
||||
image: ${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG}
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 32m
|
||||
environment:
|
||||
TZ: Europe/Stockholm
|
||||
HA_HOST: ${HA_HOST:?set HA_HOST in .env}
|
||||
HA_PORT: ${HA_PORT:-22}
|
||||
HA_USER: ${HA_USER:?set HA_USER in .env}
|
||||
HA_CERT_DIR: ${HA_CERT_DIR:?set HA_CERT_DIR in .env}
|
||||
volumes:
|
||||
- certs:/certs
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro
|
||||
- /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -s /certs/fullchain.pem && test -s /certs/privkey.pem"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging: *logging
|
||||
networks: [kvitt]
|
||||
|
||||
backup:
|
||||
image: ${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}
|
||||
restart: unless-stopped
|
||||
# uid 10001 like the server, so it can open the WAL database files the server owns; rclone.conf on the Pi is
|
||||
# therefore owned by 10001 (infra/README.md "Backup uid", D-105).
|
||||
user: "10001:10001"
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 96m
|
||||
environment:
|
||||
TZ: Europe/Stockholm
|
||||
RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:}
|
||||
RCLONE_FOLDER_ID: ${RCLONE_FOLDER_ID:?set RCLONE_FOLDER_ID in .env}
|
||||
volumes:
|
||||
- data:/data
|
||||
- backups:/backups
|
||||
- /opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck.sh"]
|
||||
interval: 5m
|
||||
timeout: 10s
|
||||
retries: 1
|
||||
start_period: 30s
|
||||
logging: *logging
|
||||
networks: [kvitt]
|
||||
|
||||
volumes:
|
||||
data:
|
||||
certs:
|
||||
caddy_data:
|
||||
backups:
|
||||
|
||||
networks:
|
||||
kvitt:
|
||||
driver: bridge
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy an image tag on the Kvitt Pi (SPEC §10.3, §10.10). Run ON the Pi: /opt/kvitt/deploy.sh <tag>
|
||||
# 1. backup first (backup-now.sh: Drive upload confirmed, last-success updated), because a tag may carry a
|
||||
# migration and the Pi cannot tell beforehand (the rule "backup before a deploy with a migration" is applied
|
||||
# to every deploy); skipped when no database exists yet, or with --skip-backup (rollback while Drive is down)
|
||||
# 2. sets KVITT_TAG in /opt/kvitt/.env (the previous tag is kept in /opt/kvitt/previous-tag)
|
||||
# 3. docker compose pull && docker compose up -d, waits for server to be healthy, prints GET /health
|
||||
# Rollback after a failed migration: /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)"; if a migration had
|
||||
# partially applied, restore the pre-deploy backup (infra/RESTORE.md).
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
. "$here/lib.sh"
|
||||
|
||||
skip_backup=0
|
||||
tag=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-backup) skip_backup=1 ;;
|
||||
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||
-*) die "unknown option $arg" ;;
|
||||
*) [[ -z "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"; tag=$arg ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$tag" ]] || die "usage: $0 <tag> [--skip-backup]"
|
||||
[[ "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || die "not a valid image tag: $tag"
|
||||
[[ -f "$KVITT_DIR/.env" ]] || die "$KVITT_DIR/.env is missing: run install.sh first"
|
||||
|
||||
current=$(env_value KVITT_TAG)
|
||||
say "Deploying $tag (running: ${current:-none})"
|
||||
|
||||
# 1. Backup before the deploy.
|
||||
if (( skip_backup == 1 )); then
|
||||
say "Backup skipped (--skip-backup)"
|
||||
elif [[ "$(service_health server)" == none ]] || ! compose exec -T backup test -f /data/kvitt.sqlite 2>/dev/null; then
|
||||
say "No running server with a database: no backup needed"
|
||||
else
|
||||
"$here/backup-now.sh" || die "the pre-deploy backup failed; nothing deployed (fix the backup, or use --skip-backup knowingly)"
|
||||
fi
|
||||
|
||||
# 2. Tag.
|
||||
[[ -n "$current" ]] && printf '%s\n' "$current" > "$KVITT_DIR/previous-tag"
|
||||
env_set KVITT_TAG "$tag"
|
||||
|
||||
# 3. Pull and start.
|
||||
if ! compose pull; then
|
||||
[[ -n "$current" ]] && env_set KVITT_TAG "$current"
|
||||
die "pull of $tag failed; KVITT_TAG left at ${current:-its old value}"
|
||||
fi
|
||||
compose up -d --remove-orphans
|
||||
if ! wait_healthy server 180; then
|
||||
compose logs --tail 50 server || true
|
||||
die "server is not healthy on $tag. Roll back: $KVITT_DIR/deploy.sh ${current:-<previous tag>} --skip-backup"
|
||||
fi
|
||||
compose ps
|
||||
print_health || die "server is healthy but /health through Caddy failed (docker compose logs caddy cert-sync)"
|
||||
say "Deployed $tag."
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Put the APKs that infra/publish-apk.sh made on the Mac into the server's data volume (SPEC §10.6).
|
||||
# The owner copies infra/pi/out/apk/ from the Mac to /opt/kvitt/apk/ on the Pi, then runs ON the Pi:
|
||||
# /opt/kvitt/install-apk.sh
|
||||
# Copies every kvitt-<version_code>.apk from /opt/kvitt/apk into /data/apk, then latest.json last and atomically
|
||||
# (temp file, rename), so the server never announces a file it does not have; keeps the newest 5 APKs in /data/apk.
|
||||
# R8 mapping files (kvitt-<version_code>.mapping.txt) stay in /opt/kvitt/apk; the server never needs them.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
. "$here/lib.sh"
|
||||
|
||||
src="$KVITT_DIR/apk"
|
||||
[[ -f "$src/latest.json" ]] || die "no $src/latest.json: copy infra/pi/out/apk/ from the Mac first (README, Publishing an APK)"
|
||||
file_name=$(sed -n 's/.*"file_name": *"\([^"]*\)".*/\1/p' "$src/latest.json")
|
||||
[[ "$file_name" =~ ^kvitt-[0-9]+\.apk$ ]] || die "latest.json has no valid file_name"
|
||||
[[ -f "$src/$file_name" ]] || die "latest.json names $file_name, which is not in $src"
|
||||
sha=$(sed -n 's/.*"sha256": *"\([0-9a-f]\{64\}\)".*/\1/p' "$src/latest.json")
|
||||
[[ "$(sha256sum "$src/$file_name" | cut -d' ' -f1)" == "$sha" ]] || die "$file_name does not match the sha256 in latest.json"
|
||||
|
||||
# backup runs as uid 10001, the owner of /data, and has a shell; the server image has none.
|
||||
# shellcheck disable=SC2016 # the script runs inside the backup container
|
||||
compose run --rm --no-deps -T -v "$src:/incoming:ro" --entrypoint /bin/sh backup -c '
|
||||
set -eu
|
||||
mkdir -p /data/apk
|
||||
for f in /incoming/kvitt-*.apk; do
|
||||
[ -f "$f" ] || continue
|
||||
n=$(basename "$f")
|
||||
[ -f "/data/apk/$n" ] && cmp -s "$f" "/data/apk/$n" && continue
|
||||
cp "$f" "/data/apk/.$n.tmp" && mv -f "/data/apk/.$n.tmp" "/data/apk/$n"
|
||||
echo "copied $n"
|
||||
done
|
||||
cp /incoming/latest.json /data/apk/.latest.json.tmp
|
||||
mv -f /data/apk/.latest.json.tmp /data/apk/latest.json
|
||||
# keep the newest 5 APKs by version code
|
||||
ls /data/apk | sed -n "s/^kvitt-\([0-9]*\)\.apk$/\1/p" | sort -n -r | tail -n +6 | while read -r v; do
|
||||
rm -f "/data/apk/kvitt-$v.apk"; echo "removed kvitt-$v.apk"
|
||||
done
|
||||
echo "latest.json installed"'
|
||||
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest (via this Pi):"
|
||||
curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/apk/latest" \
|
||||
|| die "GET /apk/latest failed"
|
||||
echo
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Kvitt Pi install (infra/README.md "Pi: what the owner does"). Run ON the Kvitt Pi, as the owner's user, from the
|
||||
# bundle folder copied from the Mac (infra/pi/): cd ~/kvitt-pi && ./install.sh
|
||||
# Idempotent: safe to run again after changing .env, the secrets, compose.yaml or the Caddyfile.
|
||||
# 1. checks Docker Engine and the compose plugin
|
||||
# 2. checks .env (no CHANGE_ME left in the keys the stack reads)
|
||||
# 3. creates /opt/kvitt/{secrets,import,apk} and installs the secret files from ./secrets/ with their modes
|
||||
# 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set
|
||||
# 5. copies compose.yaml, Caddyfile, .env and the operation scripts to /opt/kvitt
|
||||
# 6. pulls the images, gives the named volumes their owners, starts the stack, waits for /health
|
||||
# Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration.
|
||||
set -euo pipefail
|
||||
bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
. "$bundle/lib.sh"
|
||||
|
||||
# 1. Docker
|
||||
command -v docker >/dev/null || die "docker is not installed (README step 2)"
|
||||
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing (README step 2)"
|
||||
docker info >/dev/null 2>&1 || die "cannot talk to Docker as $(id -un): add yourself to the docker group (sudo usermod -aG docker $(id -un)), log out and in"
|
||||
command -v curl >/dev/null || die "curl is missing: sudo apt-get install -y curl"
|
||||
|
||||
# 2. .env
|
||||
env_src="$bundle/.env"
|
||||
[[ -f "$env_src" ]] || die "no .env next to install.sh: cp .env.example .env and fill it (README step 4)"
|
||||
for key in KVITT_REGISTRY KVITT_TAG HA_HOST HA_PORT HA_USER HA_CERT_DIR RCLONE_REMOTE RCLONE_FOLDER_ID; do
|
||||
value=$(env_value "$key" "$env_src")
|
||||
[[ -n "$value" ]] || die ".env: $key is empty"
|
||||
[[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value"
|
||||
done
|
||||
registry=$(env_value KVITT_REGISTRY "$env_src")
|
||||
registry_host=${registry%%/*}
|
||||
registry_user=$(env_value KVITT_REGISTRY_USER "$env_src")
|
||||
registry_token=$(env_value KVITT_REGISTRY_TOKEN "$env_src")
|
||||
|
||||
# 3. Directories and secrets. uid 1000 = cert-sync, uid 10001 = server and backup (SPEC §10.2, §10.7, D-023, D-105).
|
||||
say "Creating $KVITT_DIR"
|
||||
sudo install -d -m 0755 -o "$(id -u)" -g "$(id -g)" "$KVITT_DIR" "$KVITT_DIR/import" "$KVITT_DIR/apk"
|
||||
sudo install -d -m 0700 -o root -g root "$KVITT_DIR/secrets"
|
||||
# name : owner uid : group : mode
|
||||
secret_specs=(
|
||||
"fcm-service-account.json:0:10001:0440"
|
||||
"ha_ssh_key:1000:1000:0600"
|
||||
"ha_known_hosts:1000:1000:0600"
|
||||
"rclone.conf:10001:10001:0600"
|
||||
)
|
||||
missing=()
|
||||
for spec in "${secret_specs[@]}"; do
|
||||
IFS=: read -r name uid gid mode <<< "$spec"
|
||||
src="$bundle/secrets/$name"
|
||||
dst="$KVITT_DIR/secrets/$name"
|
||||
if [[ -f "$src" ]]; then
|
||||
sudo install -m "$mode" -o "$uid" -g "$gid" "$src" "$dst"
|
||||
say " secrets/$name installed (mode $mode, owner $uid:$gid)"
|
||||
elif sudo test -f "$dst"; then
|
||||
sudo chown "$uid:$gid" "$dst"
|
||||
sudo chmod "$mode" "$dst"
|
||||
say " secrets/$name already in place (mode $mode, owner $uid:$gid)"
|
||||
else
|
||||
missing+=("$name")
|
||||
fi
|
||||
if sudo test -d "$dst"; then
|
||||
die "$dst is a directory (Docker creates one when a file was missing at start): sudo rmdir $dst, then run again"
|
||||
fi
|
||||
done
|
||||
(( ${#missing[@]} == 0 )) || die "missing secret files: ${missing[*]} (put them in $bundle/secrets/, README step 5)"
|
||||
if [[ -d "$bundle/secrets" ]]; then
|
||||
say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets"
|
||||
fi
|
||||
|
||||
# 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login.
|
||||
daemon_json=/etc/docker/daemon.json
|
||||
changed=$(sudo python3 - "$daemon_json" "$registry_host" <<'PY'
|
||||
import json, os, sys
|
||||
path, host = sys.argv[1], sys.argv[2]
|
||||
cfg = {}
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
text = f.read().strip()
|
||||
cfg = json.loads(text) if text else {}
|
||||
regs = cfg.setdefault("insecure-registries", [])
|
||||
if host in regs:
|
||||
print("no")
|
||||
else:
|
||||
regs.append(host)
|
||||
tmp = path + ".kvitt-tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
print("yes")
|
||||
PY
|
||||
)
|
||||
if [[ "$changed" == yes ]]; then
|
||||
say "Added $registry_host to insecure-registries in $daemon_json; restarting Docker"
|
||||
sudo systemctl restart docker
|
||||
else
|
||||
say "$registry_host is already in insecure-registries"
|
||||
fi
|
||||
if [[ -n "$registry_user" && -n "$registry_token" ]]; then
|
||||
printf '%s' "$registry_token" | docker login "$registry_host" -u "$registry_user" --password-stdin >/dev/null
|
||||
say "Logged in to $registry_host as $registry_user"
|
||||
else
|
||||
say "No registry credentials in .env: pulling anonymously"
|
||||
fi
|
||||
|
||||
# 5. Stack files and operation scripts.
|
||||
install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml"
|
||||
install -m 0644 "$bundle/Caddyfile" "$KVITT_DIR/Caddyfile"
|
||||
install -m 0600 "$env_src" "$KVITT_DIR/.env"
|
||||
for script in lib.sh deploy.sh bootstrap.sh backup-now.sh install-apk.sh; do
|
||||
install -m 0755 "$bundle/$script" "$KVITT_DIR/$script"
|
||||
done
|
||||
say "Installed compose.yaml, Caddyfile, .env and the scripts in $KVITT_DIR"
|
||||
|
||||
# 6. Pull, volume owners, start.
|
||||
compose config --quiet
|
||||
compose pull
|
||||
compose create
|
||||
# Named volumes start owned by root; give each its container's uid (the server image has no shell, so the
|
||||
# backup image, which mounts the same data volume, sets /data). Runs as root once per install, then exits.
|
||||
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh backup \
|
||||
-c 'chown 10001:10001 /data /backups && mkdir -p /data/apk && chown 10001:10001 /data/apk'
|
||||
compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh cert-sync \
|
||||
-c 'chown 1000:1000 /certs'
|
||||
compose up -d --remove-orphans
|
||||
ok=1
|
||||
wait_healthy server 180 || ok=0
|
||||
wait_healthy cert-sync 120 || { ok=0; say "cert-sync has no certificate yet: docker compose logs cert-sync (README, Troubleshooting)"; }
|
||||
wait_healthy caddy 120 || ok=0
|
||||
compose ps
|
||||
print_health || ok=0
|
||||
(( ok == 1 )) || die "the stack is not fully up yet; see the lines above"
|
||||
say "Kvitt is running. Next: $KVITT_DIR/bootstrap.sh (first install only)."
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for the Kvitt Pi scripts (install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh).
|
||||
# Sourced, not run. These scripts run ON the Kvitt Pi as the owner's user (a member of the docker group).
|
||||
# shellcheck disable=SC2034 # variables are used by the scripts that source this file
|
||||
|
||||
KVITT_DIR=${KVITT_DIR:-/opt/kvitt}
|
||||
PUBLIC_HOST=icehack.asuscomm.com
|
||||
PUBLIC_PORT=8443
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Value of KEY in an env file (default /opt/kvitt/.env); empty when unset.
|
||||
env_value() {
|
||||
local file=${2:-$KVITT_DIR/.env}
|
||||
sed -n "s/^$1=//p" "$file" | tail -n 1
|
||||
}
|
||||
|
||||
# Set KEY=VALUE in an env file in place (adds the line when missing). Keeps the file's mode.
|
||||
env_set() {
|
||||
local key=$1 value=$2 file=${3:-$KVITT_DIR/.env} tmp
|
||||
tmp=$(mktemp "$file.XXXXXX")
|
||||
if grep -q "^$key=" "$file"; then
|
||||
awk -v k="$key" -v v="$value" 'index($0, k "=") == 1 { print k "=" v; next } { print }' "$file" > "$tmp"
|
||||
else
|
||||
cat "$file" > "$tmp"
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
fi
|
||||
chmod --reference="$file" "$tmp"
|
||||
mv -f "$tmp" "$file"
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose --project-directory "$KVITT_DIR" -f "$KVITT_DIR/compose.yaml" --env-file "$KVITT_DIR/.env" "$@"
|
||||
}
|
||||
|
||||
# Health status of a compose service's container: healthy, unhealthy, starting, or none.
|
||||
service_health() {
|
||||
local id
|
||||
id=$(compose ps -q "$1" 2>/dev/null | head -n 1)
|
||||
[[ -n "$id" ]] || { echo none; return; }
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$id" 2>/dev/null || echo none
|
||||
}
|
||||
|
||||
# Wait until a service is healthy (default 180 s). Returns 1 on timeout.
|
||||
wait_healthy() {
|
||||
local service=$1 timeout=${2:-180} waited=0 status
|
||||
while :; do
|
||||
status=$(service_health "$service")
|
||||
[[ "$status" == healthy ]] && { say "$service: healthy"; return 0; }
|
||||
if (( waited >= timeout )); then
|
||||
say "$service: $status after ${timeout}s"
|
||||
return 1
|
||||
fi
|
||||
sleep 5
|
||||
waited=$((waited + 5))
|
||||
done
|
||||
}
|
||||
|
||||
# GET /health through Caddy on this Pi, with the real certificate check (no -k): proves TLS and the proxy path.
|
||||
print_health() {
|
||||
local out
|
||||
if out=$(curl -fsS --max-time 15 --resolve "$PUBLIC_HOST:$PUBLIC_PORT:127.0.0.1" "https://$PUBLIC_HOST:$PUBLIC_PORT/health" 2>&1); then
|
||||
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi): $out"
|
||||
return 0
|
||||
fi
|
||||
say "GET https://$PUBLIC_HOST:$PUBLIC_PORT/health (via this Pi) failed: $out"
|
||||
return 1
|
||||
}
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the release APK on the Mac and stage it for the Pi (SPEC §10.6, D-100).
|
||||
# Usage, from the main checkout: infra/publish-apk.sh
|
||||
# 1. refuses a dirty working tree (the version is derived from HEAD)
|
||||
# 2. version (SPEC §10.6): version_code = `git rev-list --count HEAD`, version_name = YYYY.MM.DD-<short sha> of
|
||||
# HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode / -Pkvitt.versionName
|
||||
# 3. :app:assembleRelease through tools/gradle-serial.sh (JDK 25 from Homebrew on PATH, JAVA_HOME untouched)
|
||||
# 4. writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (R8 mapping, for
|
||||
# reading stack traces; never served), and latest.json (written to a temp file, then renamed)
|
||||
# 5. keeps the newest 5 APKs (and their mappings) in infra/pi/out/apk/
|
||||
# Then the owner copies the folder to the Pi and runs /opt/kvitt/install-apk.sh there (infra/README.md).
|
||||
# Refuses an unsigned build: without the kvitt.release.* keys in android/local.properties the APK cannot be
|
||||
# installed over an earlier release (D-024, D-101).
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
[[ -z "$(git status --porcelain)" ]] || { echo "publish-apk.sh: the working tree is dirty; commit or stash first." >&2; exit 1; }
|
||||
|
||||
version_code="$(git rev-list --count HEAD)"
|
||||
short_sha="$(git rev-parse --short=7 HEAD)"
|
||||
commit_day="$(TZ=Europe/Stockholm git show -s --date=format-local:%Y.%m.%d --format=%cd HEAD)"
|
||||
version_name="$commit_day-$short_sha"
|
||||
echo "Release $version_name (version_code $version_code)"
|
||||
|
||||
PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \
|
||||
"-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name"
|
||||
|
||||
out_dir="$repo_root/android/app/build/outputs/apk/release"
|
||||
apk="$out_dir/app-release.apk"
|
||||
mapping="$repo_root/android/app/build/outputs/mapping/release/mapping.txt"
|
||||
if [[ ! -f "$apk" ]]; then
|
||||
if [[ -f "$out_dir/app-release-unsigned.apk" ]]; then
|
||||
echo "publish-apk.sh: the build is unsigned (no kvitt.release.* keys in android/local.properties); not published." >&2
|
||||
else
|
||||
echo "publish-apk.sh: no APK in $out_dir" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
# The APK must be the one just built: output-metadata.json carries the version code Gradle used.
|
||||
python3 - "$out_dir/output-metadata.json" "$version_code" <<'PY'
|
||||
import json, sys
|
||||
meta = json.load(open(sys.argv[1]))
|
||||
codes = {str(e.get("versionCode")) for e in meta.get("elements", [])}
|
||||
if sys.argv[2] not in codes:
|
||||
sys.exit(f"publish-apk.sh: output-metadata.json has version codes {sorted(codes)}, expected {sys.argv[2]}")
|
||||
PY
|
||||
[[ -f "$mapping" ]] || { echo "publish-apk.sh: no R8 mapping at $mapping" >&2; exit 1; }
|
||||
|
||||
dest="$repo_root/infra/pi/out/apk"
|
||||
mkdir -p "$dest"
|
||||
file_name="kvitt-$version_code.apk"
|
||||
cp "$apk" "$dest/.$file_name.tmp" && mv -f "$dest/.$file_name.tmp" "$dest/$file_name"
|
||||
cp "$mapping" "$dest/kvitt-$version_code.mapping.txt"
|
||||
size_bytes="$(wc -c < "$dest/$file_name" | tr -d ' ')"
|
||||
sha256="$(shasum -a 256 "$dest/$file_name" | cut -d' ' -f1)"
|
||||
released_at="$(python3 -c 'import datetime; t=datetime.datetime.now(datetime.timezone.utc); print(t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z")')"
|
||||
|
||||
tmp="$(mktemp "$dest/.latest.json.XXXXXX")"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"version_code": $version_code,
|
||||
"version_name": "$version_name",
|
||||
"file_name": "$file_name",
|
||||
"size_bytes": $size_bytes,
|
||||
"sha256": "$sha256",
|
||||
"released_at": "$released_at"
|
||||
}
|
||||
JSON
|
||||
chmod 0644 "$tmp"
|
||||
mv -f "$tmp" "$dest/latest.json"
|
||||
|
||||
# Keep the newest 5 APKs by version code, with their mappings.
|
||||
for f in "$dest"/kvitt-*.apk; do
|
||||
[[ -f "$f" ]] || continue
|
||||
basename "$f" | sed -n 's/^kvitt-\([0-9]*\)\.apk$/\1/p'
|
||||
done | sort -n -r | tail -n +6 | while read -r old; do
|
||||
rm -f "$dest/kvitt-$old.apk" "$dest/kvitt-$old.mapping.txt"
|
||||
echo "removed kvitt-$old.apk and its mapping"
|
||||
done
|
||||
|
||||
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
|
||||
echo "Copy to the Pi and install (infra/README.md, Publishing an APK):"
|
||||
echo " scp infra/pi/out/apk/latest.json infra/pi/out/apk/$file_name infra/pi/out/apk/kvitt-$version_code.mapping.txt <ssh user>@<Kvitt Pi address>:/opt/kvitt/apk/"
|
||||
echo " ssh <ssh user>@<Kvitt Pi address> /opt/kvitt/install-apk.sh"
|
||||
Reference in New Issue
Block a user