A Gmail-watching Apps Script (documented in docs/deployment.adoc) posts the session ID from each LiveTrack email to a new webhook, which stores it in data/livetrack.json; a new sidebar panel lists what's been received.
193 lines
8.5 KiB
Plaintext
193 lines
8.5 KiB
Plaintext
= Deployment
|
|
:toc:
|
|
:toclevels: 2
|
|
|
|
MapTool deploys as a Docker container on the same server the Gitea Actions
|
|
runners run on. Two separate runners are registered against the Gitea
|
|
instance:
|
|
|
|
* The existing Docker-based runner (`gitea/runner:3`), labels
|
|
`ubuntu-latest`/`ubuntu-24.04`/`ubuntu-22.04` — runs the `check` job
|
|
in a fresh, isolated container per job, same as before.
|
|
* A dedicated native runner, installed directly on the host (no Docker
|
|
around it), label `cube-host` — runs only the `deploy` job.
|
|
|
|
They're separate because the Docker-based runner's jobs (whether in
|
|
its usual isolated per-job containers, or tried directly inside its
|
|
own container via a `host`-scheme label) have no reliable access to
|
|
the host's filesystem, `systemctl`, or even a full toolset (its own
|
|
container image is missing `rsync`, `docker` CLI, Node.js — it's built
|
|
for spawning per-job containers via its `docker.sock` mount, not for
|
|
running arbitrary host commands). The native runner sidesteps all of
|
|
that: it *is* a host process, so `docker`, `git`, etc. are just
|
|
whatever's installed on the server.
|
|
|
|
== Layout on the server
|
|
|
|
`/srv/docker/maptool/` holds:
|
|
|
|
* A checkout of this repository (kept in sync by the deploy job, see
|
|
below) — `Dockerfile`, `docker-compose.yaml`, application code.
|
|
* `data/`, *not* part of that sync — uploaded GPX files and
|
|
`offline.pmtiles`. Persisted via a bind mount (`./data:/app/data` in
|
|
`docker-compose.yaml`), so a redeploy (`docker compose up --build`)
|
|
never touches it.
|
|
|
|
== The native deploy runner
|
|
|
|
Installed from the official binary (see
|
|
https://docs.gitea.com/runner/installation/binary/), running as a
|
|
systemd service (`gitea-runner.service`) under a dedicated
|
|
`gitea-runner` system user, which needs:
|
|
|
|
* Docker access (member of the `docker` group).
|
|
* Write access to `/srv/docker/maptool`.
|
|
|
|
Its `config.yaml` (`/etc/gitea-runner/config.yaml`) declares a single
|
|
label:
|
|
|
|
[source,yaml]
|
|
----
|
|
runner:
|
|
labels:
|
|
- "cube-host"
|
|
----
|
|
|
|
The `host` scheme means jobs run directly via the runner process's own
|
|
shell — no container spawned — which is exactly what a plain, single
|
|
purpose deploy runner needs.
|
|
|
|
== Puma's bind address
|
|
|
|
`config/puma.rb` binds to `127.0.0.1` by default (see
|
|
`docs/architecture.adoc`) — fine for local dev, but inside a container
|
|
that would make the app unreachable through Docker's published port
|
|
(loopback is scoped to the container's own network namespace).
|
|
`docker-compose.yaml` sets `BIND_HOST=0.0.0.0` to override that for
|
|
the deployed container specifically; Docker's own port publishing
|
|
(`ports:` in the compose file) is the network boundary there instead
|
|
of Puma's bind address.
|
|
|
|
== Secrets (`.env`)
|
|
|
|
`docker-compose.yaml` reads `SESSION_SECRET`, `GITEA_OAUTH_CLIENT_ID`,
|
|
`GITEA_OAUTH_CLIENT_SECRET` and `MAPTOOL_ALLOWED_GITEA_USER` from the
|
|
environment (see `docs/architecture.adoc` for what they're for) —
|
|
docker compose loads these automatically from a `.env` file next to
|
|
`docker-compose.yaml`, i.e. `/srv/docker/maptool/.env`. That file is
|
|
gitignored and dockerignored on purpose: nothing here ever puts
|
|
secrets in the repository or a built image layer.
|
|
|
|
It's not created by hand on the server — the deploy job's "Write
|
|
.env" step regenerates it on every deploy from repo-level Gitea
|
|
Actions secrets (*Settings → Actions → Secrets*): `SESSION_SECRET`,
|
|
`OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `MAPTOOL_ALLOWED_GITEA_USER`.
|
|
The OAuth2 ones aren't named `GITEA_OAUTH_CLIENT_*` to match the `.env`
|
|
var names 1:1 — Gitea rejects secret names starting with `GITEA_` or
|
|
`GITHUB_` as reserved — but the workflow step maps them to the right
|
|
`.env` var names regardless. Gitea masks secret values in the job log
|
|
automatically. Set these once in the repo settings and every deploy
|
|
since keeps `.env` in sync — no server-side step needed beyond the
|
|
first-time OAuth2 application registration below.
|
|
|
|
`SESSION_SECRET` must stay the same value across deploys (regenerating
|
|
it every push would silently log everyone out every time `main`
|
|
changes) — generate it once (`openssl rand -hex 32`) and store that
|
|
same value as the secret from then on, don't regenerate per-deploy.
|
|
|
|
Register the OAuth2 application once, under any one Gitea account
|
|
(doesn't need to be an allowed user itself — any Gitea account can
|
|
authenticate through it, `MAPTOOL_ALLOWED_GITEA_USER` is what actually
|
|
decides who gets write access): *Settings → Applications → Manage
|
|
OAuth2 Applications → Create a new OAuth2 Application*, with redirect
|
|
URI `https://map.priesol.net/auth/callback`. That gives you the client
|
|
id/secret to store as the two `OAUTH_CLIENT_*` secrets above.
|
|
|
|
`MAPTOOL_ALLOWED_GITEA_USER` itself is a comma-separated list of Gitea
|
|
usernames, e.g. `randy,friend1,friend2` — not their emails, the
|
|
`login` field from Gitea's `/api/v1/user`.
|
|
|
|
== Garmin LiveTrack session webhook
|
|
|
|
`POST /api/livetrack/session` (`session_id`, `secret` form params) records a
|
|
Garmin LiveTrack session ID to `data/livetrack.json`; `GET
|
|
/api/livetrack/sessions` lists them, newest first. Intended caller: a Google
|
|
Apps Script bound to the Gmail account that receives LiveTrack emails,
|
|
triggered on a timer, extracting the session ID from the email and posting
|
|
it here — the endpoint itself doesn't know anything about email or Garmin's
|
|
API, just the ID string.
|
|
|
|
`secret` must match the `LIVETRACK_WEBHOOK_SECRET` repo secret (added to
|
|
`.env` the same way as the others above). Unlike the other secrets, this one
|
|
is optional — leaving it unset simply disables the endpoint (`halt 403` on
|
|
every call) rather than failing the deploy, since LiveTrack tracking isn't
|
|
core to the app.
|
|
|
|
The caller side is a Google Apps Script bound to the Gmail account that
|
|
receives the LiveTrack emails (script.google.com, "New project"), run on a
|
|
time-driven trigger:
|
|
|
|
[source,javascript]
|
|
----
|
|
const WEBHOOK_URL = "https://map.priesol.net/api/livetrack/session";
|
|
const WEBHOOK_SECRET = "PASTE_SAME_VALUE_AS_LIVETRACK_WEBHOOK_SECRET_IN_.env";
|
|
const PROCESSED_LABEL = "livetrack-processed";
|
|
|
|
function checkLiveTrackEmail() {
|
|
const label = GmailApp.getUserLabelByName(PROCESSED_LABEL) || GmailApp.createLabel(PROCESSED_LABEL);
|
|
const threads = GmailApp.search(`from:noreply@garmin.com subject:LiveTrack -label:${PROCESSED_LABEL}`);
|
|
|
|
threads.forEach(thread => {
|
|
// getBody() (raw HTML), not getPlainBody(): the email has no text/plain
|
|
// part, only text/html, and the link only exists as an href attribute
|
|
// — the auto-generated plain-text conversion isn't reliable for it.
|
|
const body = thread.getMessages()[0].getBody();
|
|
const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)/);
|
|
|
|
if (match) {
|
|
UrlFetchApp.fetch(WEBHOOK_URL, {
|
|
method: "post",
|
|
payload: { session_id: match[1], secret: WEBHOOK_SECRET }
|
|
});
|
|
}
|
|
|
|
thread.addLabel(label); // stops it being reprocessed next run
|
|
});
|
|
}
|
|
----
|
|
|
|
The Gmail label (rather than marking the message read/unread) tracks which
|
|
threads are already handled without touching the user's own inbox state.
|
|
`subject:LiveTrack` matches regardless of the account's language — Garmin
|
|
keeps "LiveTrack" as-is in the subject even in a localized
|
|
("Sledujte LiveTrack používateľa ...") one.
|
|
|
|
Set the trigger from the editor: clock icon on the left (Triggers) → *Add
|
|
Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer",
|
|
every 5 minutes.
|
|
|
|
Verified against a real "started an activity" LiveTrack email
|
|
(2026-09-06): sender `noreply@garmin.com`, link
|
|
`https://livetrack.garmin.com/session/<uuid>/token/<token>` — the regex
|
|
above only captures the `<uuid>` part, stopping at the next `/`. Garmin
|
|
also sends other LiveTrack notifications (e.g. activity ended) that
|
|
weren't checked — if one turns out to use a different link shape, the
|
|
webhook simply won't receive that session ID; nothing breaks.
|
|
|
|
== CI deploy job
|
|
|
|
`.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only
|
|
on a push to `main`, with `runs-on: cube-host`:
|
|
|
|
. Checks out the repo with plain `git` (`fetch` + `checkout`,
|
|
authenticated via a one-off `http.extraHeader`) rather than
|
|
`actions/checkout@v4` — a leftover from when this job ran in a
|
|
Node-less container; kept as-is since there's no reason to bring
|
|
Node back in for something this already does.
|
|
. Copies the checkout into `/srv/docker/maptool/` using plain `cp`/`rm`:
|
|
clears everything in the target except `data/`, copies the fresh
|
|
checkout over, then drops the copied `.git`.
|
|
. `cd /srv/docker/maptool && docker compose -p maptool up --build -d`
|
|
— rebuilds the image and recreates the container if anything
|
|
changed; a no-op if nothing did.
|