Compare commits
15 Commits
3a176c5cc8
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 276b048fa9 | |||
| faa3265e49 | |||
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a |
@@ -92,6 +92,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: apps/server/Dockerfile
|
file: apps/server/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
|||||||
+135
-12
@@ -1,12 +1,24 @@
|
|||||||
name: Release desktop
|
name: Release desktop
|
||||||
|
|
||||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||||
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||||
|
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||||
|
#
|
||||||
|
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||||
|
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||||
|
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||||
|
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||||
|
# would leak the moment a booth PC is compromised — this box's threat model
|
||||||
|
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||||
|
# stays private; only signed installers become public, same as most desktop
|
||||||
|
# software. mca/public_releases is shared across apps in the org, not
|
||||||
|
# parking-specific — namespace release tags/asset names accordingly if another
|
||||||
|
# app starts publishing there too.
|
||||||
#
|
#
|
||||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||||
# with the updater key (Gitea secrets), assembles latest.json, and uploads
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
# everything to the Release for that tag.
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -63,6 +75,27 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Sync tauri.conf.json version to the git tag
|
||||||
|
# tauri.conf.json's own "version" field is what Tauri bakes into the
|
||||||
|
# bundle filename, the app's internal version, AND the updater's
|
||||||
|
# "current vs. new" comparison — it is NOT derived from the git tag
|
||||||
|
# automatically. Hit in v0.1.1: the tag was bumped but this file
|
||||||
|
# wasn't, so the signed binary + its .sig were still built (and
|
||||||
|
# named) as 0.1.0 while latest.json (built from TAG below) claimed
|
||||||
|
# 0.1.1 — the updater found the "update", downloaded a file whose
|
||||||
|
# signature didn't match what the manifest claimed to sign, and
|
||||||
|
# silently failed (a separate bug in desktop-updater.ts's error
|
||||||
|
# handling made this invisible — also fixed). Patch it here so the
|
||||||
|
# checked-in value is only ever a placeholder for local dev builds;
|
||||||
|
# a real release's version is always driven by the tag.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Build + sign desktop bundle
|
- name: Build + sign desktop bundle
|
||||||
env:
|
env:
|
||||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
@@ -73,30 +106,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
id: collect
|
id: collect
|
||||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||||
|
# stripped from filenames. productName is "Parking System" (a space), so
|
||||||
|
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||||
|
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||||
|
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||||
|
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||||
|
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||||
-exec cp {} dist/ \;
|
-print0 | while IFS= read -r -d '' f; do
|
||||||
|
name=$(basename "$f" | tr ' ' '-')
|
||||||
|
cp "$f" "dist/${name}"
|
||||||
|
done
|
||||||
echo "Artifacts:"; ls -la dist/
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
- name: Assemble latest.json
|
- name: Assemble latest.json
|
||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
|
# appliances actually reach; see the workflow header for why. Adjust the
|
||||||
|
# platform keys you actually ship.
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
MIRROR_REPO: mca/public_releases
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||||
cat > dist/latest.json <<JSON
|
cat > dist/latest.json <<JSON
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "${VERSION}",
|
||||||
@@ -129,12 +173,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
"${API}/repos/${REPO}/releases" || true)
|
"${API}/repos/${REPO}/releases" || true)
|
||||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
if [ -z "$REL_ID" ]; then
|
if [ -z "$REL_ID" ]; then
|
||||||
# Release may already exist for this tag — look it up by tag.
|
# Release may already exist for this tag — look it up by tag.
|
||||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
fi
|
fi
|
||||||
echo "release id: ${REL_ID}"
|
echo "release id: ${REL_ID}"
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -147,3 +191,82 @@ jobs:
|
|||||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
done
|
done
|
||||||
echo "done"
|
echo "done"
|
||||||
|
|
||||||
|
- name: Mirror release to mca/public_releases (Gitea API)
|
||||||
|
# This is the release the updater and any human downloader actually use —
|
||||||
|
# public_releases has no source, only installers, so it can be public
|
||||||
|
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||||
|
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||||
|
# not exposed to any deployed client).
|
||||||
|
#
|
||||||
|
# Publishes to TWO tags there, since public_releases is shared across
|
||||||
|
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||||
|
# newest tag on the WHOLE repo (would break the moment another app
|
||||||
|
# publishes something newer):
|
||||||
|
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||||
|
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||||
|
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||||
|
# (a stable name every appliance can always resolve, regardless of
|
||||||
|
# what else gets released in this repo meanwhile).
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
MIRROR_REPO: mca/public_releases
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
create_or_get_release() {
|
||||||
|
local mirror_tag="$1" prerelease="$2"
|
||||||
|
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||||
|
echo "create response (${mirror_tag}): ${REL}"
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||||
|
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||||
|
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
fi
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
upload_assets() {
|
||||||
|
local rel_id="$1"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "mirroring ${name} -> release ${rel_id}"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||||
|
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||||
|
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Versioned, permanent.
|
||||||
|
create_or_get_release "desktop-${TAG}" false
|
||||||
|
echo "versioned mirror release id: ${REL_ID}"
|
||||||
|
upload_assets "${REL_ID}"
|
||||||
|
|
||||||
|
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||||
|
# with the same name 409s otherwise), then re-upload.
|
||||||
|
create_or_get_release "desktop-latest" false
|
||||||
|
LATEST_REL_ID="${REL_ID}"
|
||||||
|
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||||
|
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||||
|
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||||
|
done || true
|
||||||
|
upload_assets "${LATEST_REL_ID}"
|
||||||
|
echo "done"
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ dist/
|
|||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
|
questions.txt
|
||||||
|
|||||||
+11
-3
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
|||||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||||
window needs a display (WSLg or an X server).
|
window needs a display (WSLg or an X server).
|
||||||
|
|
||||||
|
## Auto-update
|
||||||
|
|
||||||
|
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||||
|
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||||
|
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||||
|
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||||
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
|
committed.
|
||||||
|
|
||||||
## Not here (deliberately)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
this shell connects to it.
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Parking System",
|
"productName": "Parking System",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.parking.desktop",
|
"identifier": "com.parking.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"frontendDist": "../../web/dist",
|
"frontendDist": "../../web/dist",
|
||||||
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||||
"beforeBuildCommand": "pnpm --filter @parking/web build"
|
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|||||||
# ---- runtime: slim, non-root ----
|
# ---- runtime: slim, non-root ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||||
|
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||||
|
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq, siteConfig } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
|
||||||
|
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||||
|
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||||
|
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||||
|
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||||
|
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||||
|
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||||
|
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let target: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||||
|
target = join(workDir, "target");
|
||||||
|
process.env.BACKUP_KEY = KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BACKUP_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupService — persisted status survives a restart", () => {
|
||||||
|
it("a fresh instance sees the previous instance's last success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
expect(first.status().lastSuccessAt).toBeNull();
|
||||||
|
const result = await first.run("manual");
|
||||||
|
|
||||||
|
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||||
|
// production this would be a fresh process re-opening the same sqlite file).
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||||
|
expect(status.lastError).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||||
|
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||||
|
const badTarget = join(workDir, "not-a-dir");
|
||||||
|
writeFileSync(badTarget, "x");
|
||||||
|
setTargetDir(t.db, badTarget);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await expect(first.run("manual")).rejects.toThrow();
|
||||||
|
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastError).not.toBeNull();
|
||||||
|
expect(status.lastErrorAt).not.toBeNull();
|
||||||
|
expect(status.lastSuccessAt).toBeNull();
|
||||||
|
|
||||||
|
// Now point at a real directory and succeed — the persisted error must clear.
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
await second.run("manual");
|
||||||
|
const third = new BackupService(t.db);
|
||||||
|
const finalStatus = third.status();
|
||||||
|
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(finalStatus.lastError).toBeNull();
|
||||||
|
expect(finalStatus.lastErrorAt).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||||
|
it("is due immediately when no success has ever been recorded", () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
expect(svc.isDue()).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await first.run("manual");
|
||||||
|
|
||||||
|
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||||
|
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
expect(second.isDue()).toBe(false);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
|
||||||
|
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||||
|
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||||
|
|
||||||
|
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||||
|
expect(svc.isDue(overADayLater)).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
const afterFirst = svc.status().lastSuccessAt;
|
||||||
|
|
||||||
|
await svc.runScheduled(); // not due yet — must not run again
|
||||||
|
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
|||||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||||
|
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||||
|
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||||
|
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||||
|
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
export function backupKeyFromEnv(): string {
|
export function backupKeyFromEnv(): string {
|
||||||
@@ -65,16 +71,33 @@ export class BackupService {
|
|||||||
readonly #logger?: FastifyBaseLogger;
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
#running = false;
|
#running = false;
|
||||||
#lastSuccessAt: string | null = null;
|
|
||||||
#lastResult: BackupResult | null = null;
|
|
||||||
#lastErrorAt: string | null = null;
|
|
||||||
#lastError: string | null = null;
|
|
||||||
|
|
||||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||||
|
* or reset on restart). */
|
||||||
|
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||||
|
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#persist(patch: {
|
||||||
|
backupLastSuccessAt?: string | null;
|
||||||
|
backupLastResultJson?: string | null;
|
||||||
|
backupLastErrorAt?: string | null;
|
||||||
|
backupLastError?: string | null;
|
||||||
|
}): void {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const existing = this.#row();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
targetDir(): string | null {
|
targetDir(): string | null {
|
||||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
@@ -104,6 +127,15 @@ export class BackupService {
|
|||||||
|
|
||||||
status(): BackupStatus {
|
status(): BackupStatus {
|
||||||
const r = this.retention();
|
const r = this.retention();
|
||||||
|
const row = this.#row();
|
||||||
|
let lastResult: BackupStatus["lastResult"] = null;
|
||||||
|
if (row?.backupLastResultJson) {
|
||||||
|
try {
|
||||||
|
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||||
|
} catch {
|
||||||
|
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
configured: this.configured,
|
configured: this.configured,
|
||||||
targetDir: this.targetDir(),
|
targetDir: this.targetDir(),
|
||||||
@@ -111,12 +143,10 @@ export class BackupService {
|
|||||||
keepDailyDays: r.keepDailyDays,
|
keepDailyDays: r.keepDailyDays,
|
||||||
keyPresent: this.keyPresent,
|
keyPresent: this.keyPresent,
|
||||||
running: this.#running,
|
running: this.#running,
|
||||||
lastSuccessAt: this.#lastSuccessAt,
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
lastResult: this.#lastResult
|
lastResult,
|
||||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
: null,
|
lastError: row?.backupLastError ?? null,
|
||||||
lastErrorAt: this.#lastErrorAt,
|
|
||||||
lastError: this.#lastError,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +169,17 @@ export class BackupService {
|
|||||||
try {
|
try {
|
||||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
this.#lastResult = res;
|
this.#persist({
|
||||||
this.#lastSuccessAt = new Date().toISOString();
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
this.#lastError = null;
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#lastError = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
this.#lastErrorAt = new Date().toISOString();
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.#running = false;
|
this.#running = false;
|
||||||
@@ -156,13 +189,34 @@ export class BackupService {
|
|||||||
return this.#inflight;
|
return this.#inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
/**
|
||||||
|
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||||
|
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||||
|
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||||
|
* frequent backups.
|
||||||
|
*/
|
||||||
async runScheduled(): Promise<void> {
|
async runScheduled(): Promise<void> {
|
||||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
try {
|
try {
|
||||||
await this.run("scheduled");
|
await this.run("scheduled");
|
||||||
} catch {
|
} catch {
|
||||||
/* recorded in last-error; already logged */
|
/* recorded in last-error; already logged */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||||
|
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||||
|
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||||
|
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||||
|
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||||
|
*/
|
||||||
|
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||||
|
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||||
|
const last = new Date(lastSuccessAt).getTime();
|
||||||
|
if (Number.isNaN(last)) return true;
|
||||||
|
return now.getTime() - last >= intervalMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/version", () => {
|
||||||
|
it("without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||||
|
process.env.BUILD_VERSION = "stage-abc1234";
|
||||||
|
try {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||||
|
} finally {
|
||||||
|
delete process.env.BUILD_VERSION;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("RBAC permission gate", () => {
|
describe("RBAC permission gate", () => {
|
||||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||||
|
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||||
|
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||||
|
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||||
|
// site config, so it isn't stored in site_config.
|
||||||
|
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||||
|
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Read site config (capacity + park metadata).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|||||||
@@ -336,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void runSnapPrune(); // once at startup
|
void runSnapPrune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||||
|
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||||
|
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||||
|
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||||
|
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||||
|
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||||
|
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||||
|
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||||
backupTimer.unref();
|
backupTimer.unref();
|
||||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
if (backupService.configured) {
|
if (backupService.configured) {
|
||||||
|
|||||||
@@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
|||||||
return apiFetch("/api/backup/status");
|
return apiFetch("/api/backup/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||||
|
buildVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVersion(): Promise<VersionInfo> {
|
||||||
|
return apiFetch("/api/version");
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupConfigPatch {
|
export interface BackupConfigPatch {
|
||||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||||
targetDir?: string | null;
|
targetDir?: string | null;
|
||||||
|
|||||||
@@ -41,11 +41,22 @@ export async function checkForDesktopUpdate(
|
|||||||
|
|
||||||
// Download + install the signed update (signature verified against the
|
// Download + install the signed update (signature verified against the
|
||||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||||
await update.downloadAndInstall();
|
try {
|
||||||
|
await update.downloadAndInstall();
|
||||||
|
} catch (err) {
|
||||||
|
// A real update WAS found and accepted — this is a genuine install
|
||||||
|
// failure (bad signature, corrupted download, disk/permission issue),
|
||||||
|
// not "offline". Surface it instead of silently reverting to the old
|
||||||
|
// version with no explanation.
|
||||||
|
console.error("desktop update download/install failed:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
await relaunch();
|
await relaunch();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||||
// keeps running on the current version; checking again next launch.
|
// keeps running on the current version; checking again next launch. Still
|
||||||
|
// log it so a real install failure (rethrown above) isn't invisible.
|
||||||
|
console.warn("desktop update check/apply skipped:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
can,
|
can,
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
openShift,
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
@@ -94,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||||||
|
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||||||
|
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||||||
|
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||||||
|
function VersionBadge() {
|
||||||
|
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||||||
|
const version = q.data?.buildVersion;
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -112,6 +124,7 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{show("site:read") && <VersionBadge />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-36
@@ -30,42 +30,6 @@
|
|||||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
|
|
||||||
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
|
|
||||||
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
|
|
||||||
# radius, never shared with a real booth even in the lab.
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
[[stack]]
|
|
||||||
name = "park-lab"
|
|
||||||
[stack.config]
|
|
||||||
server = "park-lab"
|
|
||||||
git_provider = "git.infra.msai.al"
|
|
||||||
git_account = "komodo"
|
|
||||||
repo = "mca/parking_solution"
|
|
||||||
branch = "dev"
|
|
||||||
file_paths = [
|
|
||||||
"docker-compose.yml",
|
|
||||||
"docker-compose.prod.yml"
|
|
||||||
]
|
|
||||||
registry_provider = "git.infra.msai.al"
|
|
||||||
registry_account = "komodo"
|
|
||||||
environment = """
|
|
||||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
|
||||||
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
|
|
||||||
# dev-<sha> only when reproducing a specific state.
|
|
||||||
TAG=dev
|
|
||||||
COOKIE_SECURE=0
|
|
||||||
VISION_ENABLED=1
|
|
||||||
WS_ALLOWED_ORIGINS=
|
|
||||||
JWT_SECRET=[[park_lab_jwt_secret]]
|
|
||||||
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
|
|
||||||
BACKUP_KEY=[[park_lab_backup_key]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
[[stack]]
|
[[stack]]
|
||||||
name = "park-buzi"
|
name = "park-buzi"
|
||||||
[stack.config]
|
[stack.config]
|
||||||
@@ -93,3 +57,33 @@ JWT_SECRET=[[park_buzi_jwt_secret]]
|
|||||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-2"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-2"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "stage"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
|
TAG=stage-28bd838
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
WS_ALLOWED_ORIGINS=
|
||||||
|
JWT_SECRET=[[park_2_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||||
|
BACKUP_KEY=[[park_2_backup_key]]
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
|
||||||
|
-- in-process fields on BackupService (never written to the DB) — so every server restart
|
||||||
|
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
|
||||||
|
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
|
||||||
|
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
|
||||||
|
-- run recorded yet (or, for the error pair, no failure since the last success). See
|
||||||
|
-- wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error` text;
|
||||||
@@ -176,6 +176,13 @@
|
|||||||
"when": 1783948800000,
|
"when": 1783948800000,
|
||||||
"tag": "0024_validation_programs",
|
"tag": "0024_validation_programs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788078414270,
|
||||||
|
"tag": "0025_backup_last_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
backupKeepLast: integer("backup_keep_last"),
|
backupKeepLast: integer("backup_keep_last"),
|
||||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||||
|
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||||||
|
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||||||
|
* server restart — before this column existed, a restart silently reset that status to
|
||||||
|
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||||||
|
* See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupLastSuccessAt: text("backup_last_success_at"),
|
||||||
|
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||||||
|
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||||||
|
backupLastResultJson: text("backup_last_result_json"),
|
||||||
|
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||||||
|
* reason. null = no failure recorded (or none since the last success). */
|
||||||
|
backupLastErrorAt: text("backup_last_error_at"),
|
||||||
|
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||||||
|
backupLastError: text("backup_last_error"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, durability, backup, recovery, security, crypto]
|
tags: [parking, durability, backup, recovery, security, crypto]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-29
|
updated: 2026-08-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backup & Disaster Recovery
|
# Backup & Disaster Recovery
|
||||||
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
|
|||||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||||
|
|
||||||
|
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
|
||||||
|
|
||||||
|
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
|
||||||
|
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
|
||||||
|
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
|
||||||
|
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
|
||||||
|
|
||||||
|
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
|
||||||
|
process-start:**
|
||||||
|
|
||||||
|
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
|
||||||
|
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
|
||||||
|
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
|
||||||
|
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
|
||||||
|
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
|
||||||
|
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
|
||||||
|
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
|
||||||
|
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
|
||||||
|
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
|
||||||
|
2. **The schedule was measured from process start, not from the last real backup.** The daily
|
||||||
|
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
|
||||||
|
from whenever the *process* last started, not from wall-clock time or from when a backup last
|
||||||
|
actually succeeded. The exact same restart that wiped the in-memory status also reset this
|
||||||
|
countdown, which is why the cadence can silently drift or skip past a day with no error ever
|
||||||
|
surfacing anywhere.
|
||||||
|
|
||||||
|
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
|
||||||
|
about this design was built to survive that.**
|
||||||
|
|
||||||
|
### Fix (2026-08-30)
|
||||||
|
|
||||||
|
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
|
||||||
|
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
|
||||||
|
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
|
||||||
|
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
|
||||||
|
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
|
||||||
|
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
|
||||||
|
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
|
||||||
|
the previous instance last recorded — no more restart amnesia.
|
||||||
|
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
|
||||||
|
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
|
||||||
|
never from process uptime.
|
||||||
|
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
|
||||||
|
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
|
||||||
|
cadence immune to restart timing entirely: however often the process happens to restart, the
|
||||||
|
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
|
||||||
|
— not 24h after whatever moment the process most recently came back up.
|
||||||
|
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
|
||||||
|
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
|
||||||
|
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
|
||||||
|
|
||||||
|
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
|
||||||
|
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
|
||||||
|
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
|
||||||
|
[[reconciliation]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-06
|
updated: 2026-08-30
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,5 +140,70 @@ hint. The transport option label no longer hardcodes lp0.
|
|||||||
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||||
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||||
|
|
||||||
|
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
|
||||||
|
|
||||||
|
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
|
||||||
|
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
|
||||||
|
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
|
||||||
|
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
|
||||||
|
|
||||||
|
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
|
||||||
|
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
|
||||||
|
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
|
||||||
|
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
|
||||||
|
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
|
||||||
|
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
|
||||||
|
of the device node is current.
|
||||||
|
|
||||||
|
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
|
||||||
|
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
|
||||||
|
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
|
||||||
|
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
|
||||||
|
printers cut power to their own USB interface board when the cover-open microswitch trips (a
|
||||||
|
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
|
||||||
|
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
|
||||||
|
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
|
||||||
|
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
|
||||||
|
itself is redone.
|
||||||
|
|
||||||
|
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
|
||||||
|
redone against current host state → the new node is picked up → fixed.
|
||||||
|
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
|
||||||
|
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
|
||||||
|
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
|
||||||
|
target on a policy-driven restart, the container can come back up still bound to the pre-incident
|
||||||
|
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
|
||||||
|
|
||||||
|
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
|
||||||
|
confirm at the next occurrence, BEFORE restarting anything:
|
||||||
|
```bash
|
||||||
|
# host:
|
||||||
|
ls -la /dev/usb/ && stat /dev/usb/lp1
|
||||||
|
# container:
|
||||||
|
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
|
||||||
|
```
|
||||||
|
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
|
||||||
|
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
|
||||||
|
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
|
||||||
|
whether the Bus/Device number changes.
|
||||||
|
|
||||||
|
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
|
||||||
|
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
|
||||||
|
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
|
||||||
|
one without touching app code.
|
||||||
|
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
|
||||||
|
polling.
|
||||||
|
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
|
||||||
|
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
|
||||||
|
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
|
||||||
|
|
||||||
|
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
|
||||||
|
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
|
||||||
|
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
|
||||||
|
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
|
||||||
|
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
|
||||||
|
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
|
||||||
|
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
|
||||||
|
|
||||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-06
|
updated: 2026-09-02
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -282,24 +282,38 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||||
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one. **Get this
|
||||||
|
right in the command itself** — it's a plain field in `periphery.config.toml` on the host, so a
|
||||||
|
typo/placeholder here needs a config edit + agent restart to fix, NOT a rename in Core's UI
|
||||||
|
(which only relabels Core's record, not the agent's real identity — gotcha #12 below).
|
||||||
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
the proxy. (Gotcha #7 below.)
|
the proxy. (Gotcha #7 below.)
|
||||||
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
|
(singular).
|
||||||
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
|
|
||||||
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
|
|
||||||
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
|
|
||||||
```bash
|
|
||||||
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
|
||||||
systemctl --user reset-failed periphery && systemctl --user restart periphery
|
|
||||||
```
|
|
||||||
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
|
||||||
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
|
||||||
|
|
||||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
|
||||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer
|
||||||
|
> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result:
|
||||||
|
> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`,
|
||||||
|
> crash-loop until systemd gives up (`Start request repeated too quickly`).
|
||||||
|
>
|
||||||
|
> **Fix + restart:**
|
||||||
|
> ```bash
|
||||||
|
> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
||||||
|
> systemctl --user reset-failed periphery && systemctl --user restart periphery
|
||||||
|
> ```
|
||||||
|
> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
||||||
|
> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
||||||
|
>
|
||||||
|
> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still
|
||||||
|
> isn't deployed. Immediately continue to **verify below, then §7b**.
|
||||||
|
|
||||||
|
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
|
||||||
|
goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|
||||||
|
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
|
||||||
|
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
|
||||||
|
|
||||||
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
||||||
|
|
||||||
@@ -482,8 +496,36 @@ works; the desktop app is a separate workstream.
|
|||||||
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||||
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||||
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
||||||
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
|
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit
|
||||||
|
on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) —
|
||||||
|
**check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in
|
||||||
|
§7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed —
|
||||||
|
§7a's fix only starts the agent, §7b deploys the Stack.
|
||||||
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
||||||
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
||||||
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
||||||
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||||
|
12. **Renaming a Server in Core's UI does NOT change the agent's actual identity.**
|
||||||
|
`connect_as` is a plain field persisted in the agent's own
|
||||||
|
`~/.config/komodo/periphery.config.toml` — Core's UI rename only relabels Core's *record*,
|
||||||
|
the agent keeps re-announcing under its original `connect_as` on every reconnect. Symptom (hit
|
||||||
|
2026-08-30, lab box): a server named via a leftover template placeholder in the install
|
||||||
|
command kept reappearing in Core no matter how many times it was renamed there, while the
|
||||||
|
intended name sat permanently NOT OK (nothing was ever checking in as that name). **Fix: edit
|
||||||
|
`connect_as` directly in `periphery.config.toml` on the host, then `systemctl --user restart
|
||||||
|
periphery`** — no reinstall/re-onboarding needed. Delete the stray old-name Server record in
|
||||||
|
Core afterward. Lesson: always double-check `--connect-as` is a REAL name (never leave a
|
||||||
|
template placeholder like `<new-server-name>` in a copy-pasted install command) — Core will
|
||||||
|
happily create a server with that literal string.
|
||||||
|
13. **Upgrading an already-installed Periphery is: re-run the same installer, unchanged
|
||||||
|
`--connect-as`.** No separate update mechanism, no update-only flag. The installer script
|
||||||
|
explicitly skips rewriting `periphery.config.toml` if one already exists ("Config already
|
||||||
|
exists, skipping...") — it only stops the service, replaces the binary, and restarts — so a
|
||||||
|
re-run is **config-preserving** and a fresh/dummy `--onboarding-key` value on that re-run is
|
||||||
|
simply unused (confirmed against Komodo's own `setup-periphery.py` source, 2026-08-30; no
|
||||||
|
Periphery-specific breaking changes between v2.2.0 and v2.3.2 per Komodo's release notes).
|
||||||
|
Verified end-to-end on `art-docker-station` (lab, dry run) then `park-buzi` (live booth,
|
||||||
|
2026-08-30): same command as §7a step 2, same `--connect-as`, app containers untouched
|
||||||
|
throughout (Periphery restarting itself never touches the already-running compose stack).
|
||||||
|
**Always dry-run a version bump on a lab/dev box before a live booth**, even with a clean
|
||||||
|
release-notes check — this project only had one lab box to test against and used it first.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, desktop, frontend]
|
tags: [parking, decisions, desktop, frontend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-21
|
updated: 2026-09-03
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,20 +140,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
||||||
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
||||||
prod build and the desktop build (same SPA).
|
prod build and the desktop build (same SPA).
|
||||||
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
|
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||||
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
|
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||||
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
|
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||||
export; the browser-served-by-Fastify build should override to `""`.
|
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
|
||||||
|
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
|
||||||
|
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
|
||||||
|
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
|
||||||
|
string did not match the expected pattern."` on the first authenticated request (login). Login
|
||||||
|
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
|
||||||
|
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
|
||||||
|
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
|
||||||
|
overrides the shared file for the desktop build only, without touching it (the browser/booth build
|
||||||
|
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
|
||||||
|
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
|
||||||
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
||||||
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
||||||
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
||||||
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
||||||
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
||||||
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
|
operation; updates are out-of-band). **WS origin:** the desktop window's origin
|
||||||
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
|
|
||||||
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
|
|
||||||
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
|
|
||||||
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
|
|
||||||
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
||||||
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
||||||
`apps/server/.env.example`).
|
`apps/server/.env.example`).
|
||||||
@@ -165,8 +172,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
||||||
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
the **source repo's own** Gitea "latest release" redirect
|
||||||
|
(`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is
|
||||||
|
**private**, and the updater runs on offline-first field appliances with **no Gitea credentials**.
|
||||||
|
Every deployed update check was silently failing (swallowed by a `try/catch` in
|
||||||
|
`desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured.
|
||||||
|
**Fix:** signed installers are now mirrored to a separate **public**, releases-only repo,
|
||||||
|
`mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling
|
||||||
|
infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points
|
||||||
|
there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since
|
||||||
|
other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-<TAG>`
|
||||||
|
split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own
|
||||||
|
record) and the public mirror (what the updater and any human downloader actually use).
|
||||||
|
**Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s
|
||||||
|
updater `headers` so it could read the private repo directly — ruled out because that token would
|
||||||
|
ship inside every installed binary in the field, and this appliance's own threat model names the
|
||||||
|
**booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the
|
||||||
|
whole private repo, with no cheap way to rotate it across appliances already in the field, was
|
||||||
|
judged worse than publishing installers-only.
|
||||||
|
- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows
|
||||||
|
kiosk-browser fallback path.
|
||||||
|
|
||||||
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
@@ -174,7 +200,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
|
|
||||||
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to
|
||||||
|
`mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for
|
||||||
|
why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN`
|
||||||
|
(`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never
|
||||||
|
shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there:
|
||||||
|
`desktop-<TAG>` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing
|
||||||
|
assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this
|
||||||
|
repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint
|
||||||
|
both point at `desktop-latest`. This is what the auto-updater actually consumes.
|
||||||
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
||||||
@@ -198,3 +232,25 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
The unsigned CI build therefore overrides it off with
|
The unsigned CI build therefore overrides it off with
|
||||||
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||||
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||||
|
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
|
||||||
|
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
|
||||||
|
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
|
||||||
|
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
|
||||||
|
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
|
||||||
|
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
|
||||||
|
"update," downloaded it, then failed signature verification against a manifest that didn't
|
||||||
|
actually describe the file it pointed at. Compounded by a second bug (below) that made this
|
||||||
|
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
|
||||||
|
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
|
||||||
|
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
|
||||||
|
release derives its version from the tag automatically.
|
||||||
|
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
|
||||||
|
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
|
||||||
|
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
|
||||||
|
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
|
||||||
|
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
|
||||||
|
error, no log, the app just silently reverted to the old version and re-showed the same "update
|
||||||
|
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
|
||||||
|
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
||||||
|
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
||||||
|
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
||||||
|
|||||||
+3
-3
@@ -58,7 +58,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
|||||||
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
||||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
|
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
||||||
|
|
||||||
## Concepts — device architecture & safety
|
## Concepts — device architecture & safety
|
||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
@@ -69,7 +69,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
|||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14); park-buzi cover-open-wedges-USB-status bug (docker restart-only fix) under investigation.
|
||||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -134,7 +134,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
|||||||
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
|
||||||
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
||||||
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+94
@@ -2673,3 +2673,97 @@ the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependen
|
|||||||
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
||||||
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
||||||
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
|
||||||
|
|
||||||
|
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
|
||||||
|
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
|
||||||
|
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
|
||||||
|
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
|
||||||
|
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
|
||||||
|
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
|
||||||
|
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
|
||||||
|
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
|
||||||
|
of the old device node until the container's mount namespace is recreated — which `docker restart`
|
||||||
|
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
|
||||||
|
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
|
||||||
|
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
|
||||||
|
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
|
||||||
|
[[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
|
||||||
|
|
||||||
|
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
|
||||||
|
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
|
||||||
|
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
|
||||||
|
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
|
||||||
|
measured from PROCESS START, not wall-clock time since the last real backup — so any server
|
||||||
|
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
|
||||||
|
visible status back to "Never" and reset the 24h countdown, independent of the actual
|
||||||
|
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
|
||||||
|
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
|
||||||
|
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
|
||||||
|
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
|
||||||
|
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
|
||||||
|
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
|
||||||
|
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
|
||||||
|
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
|
||||||
|
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
|
||||||
|
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
|
||||||
|
|
||||||
|
Two real incidents this session, both closed out as new gotchas (#12, #13) on
|
||||||
|
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
|
||||||
|
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
|
||||||
|
renamed in the UI — because `connect_as` is a plain field in the agent's own
|
||||||
|
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
|
||||||
|
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
|
||||||
|
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
|
||||||
|
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
|
||||||
|
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
|
||||||
|
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
|
||||||
|
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
|
||||||
|
+ exact commands on [[appliance-provisioning]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
|
||||||
|
|
||||||
|
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
|
||||||
|
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
|
||||||
|
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
|
||||||
|
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
|
||||||
|
actually field-verified end to end. Fix: signed installers now mirror to a new public,
|
||||||
|
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
|
||||||
|
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
|
||||||
|
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
|
||||||
|
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
|
||||||
|
deployed binary worse than just publishing installers publicly. `release.yml`,
|
||||||
|
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
||||||
|
[[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
|
||||||
|
|
||||||
|
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
|
||||||
|
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
|
||||||
|
file and was never given its own override — the desktop shell has been building with an empty
|
||||||
|
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
|
||||||
|
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
|
||||||
|
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
|
||||||
|
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
|
||||||
|
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
|
||||||
|
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
|
||||||
|
build only (process env wins in Vite's load order) — verified both builds independently. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
|
||||||
|
|
||||||
|
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
|
||||||
|
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
|
||||||
|
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
|
||||||
|
what latest.json claimed to describe, and signature verification failed on every download; (2)
|
||||||
|
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
|
||||||
|
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
|
||||||
|
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
|
||||||
|
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
|
||||||
|
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|||||||
Reference in New Issue
Block a user