⬆️ Lightflow Upgrade¶
Lightflow is a generic task runner: the upgrade logic lives entirely in the
script it runs and the host-side gate it reaches over SSH. This tutorial
configures an installed Lightflow instance to drive the service upgrades of
the Podman services (calibre, immich, kouizine, n8n, planka, vaultwarden, …)
through podmanctl --upgrade — one click in the UI instead of an SSH session,
with the same security model as the Lightflow Backup
pipeline: Lightflow never gets a shell on the host and can never aim
podmanctl at anything else, the boundary lives in the upgrade-gate forced
command on the host.
📋 Requirements¶
Info
Lightflow Upgrade requires the installation of:
🛤️ End-to-end path¶
How a run travels from the UI to the restarted stack — and who enforces what at each hop:
| # | Hop | Enforced by |
|---|---|---|
| 1 | the task step runs python3 upgrade.py --upgrade <svc> in the Lightflow container |
Lightflow (pool, step timeout, live logs) |
| 2 | ssh upgrade_ops@host.containers.internal "podmanctl --upgrade <svc> --timeout <n>" |
key from the UPGRADE_SSH_KEY Variable; restrict = no shell, no forwarding |
| 3 | sshd ignores the requested command and runs the forced command sudo upgrade-gate |
authorized_keys + sudoers (exactly one invocation, no wildcard) |
| 4 | the gate re-reads the request from SSH_ORIGINAL_COMMAND and accepts only podmanctl --upgrade <known-service> [--timeout N] |
upgrade-gate (root) |
| 5 | timeout -k 10 N podmanctl --upgrade <svc>: stop → git pull → pull base + registry images → full rebuild → restart → prune |
podmanctl (root); coreutils timeout = host-side deadline |
The step's box is 🟩 when the whole chain exits 0 and 🟥 otherwise; podmanctl's
live output (pulls, build, restart, final dashboard) streams into the step log
while it runs.
🗂️ The one-step upgrade model¶
Where the backup pipeline needs four ordered steps (stop / sync / archive /
start), an upgrade needs exactly one: podmanctl --upgrade already drives
the full lifecycle on the host, so a single step — and a single box in the
grid — carries the whole operation:
| Step | Action | Box |
|---|---|---|
upgrade |
podmanctl --upgrade <svc>(stop → git pull → pull bases + registry images → rebuild → start → prune) |
🟩 / 🟥 |
A failed upgrade leaves the service STOPPED — on purpose
podmanctl aborts on a failed git pull, and a failed build never starts
broken code: the service is deliberately not restarted. Read the step
log, fix the cause, then re-run the task or podmanctl --start <svc> on
the host.
--upgrade never edits versions
Pinned tags (*_VERSION in the service env file, or the Image= tag in a
Quadlet) must be bumped and pushed first — the upgrade then pulls the
new tags and rebuilds on them. See each service runbook's Upgrading
section.
🔐 Upgrade Gate¶
The host-side plumbing that the task calls through SSH:
- podmanctl (performs the upgrade — already installed)
- upgrade-gate (the validating forced command)
- upgrade_ops SSH account.
🚪 Install upgrade-gate¶
Install upgrade-gate to /usr/local/bin
# the forced command run for every upgrade_ops SSH connection: it re-reads
# the client's request from SSH_ORIGINAL_COMMAND and lets EXACTLY one shape
# through — `podmanctl --upgrade <service>` for a known service, with an
# optional `--timeout N` enforced host-side via coreutils timeout
sudo tee /usr/local/bin/upgrade-gate >/dev/null <<'EOF'
#!/bin/sh
# upgrade-gate — SSH dispatch for the upgrade_ops forced command.
# Runs as root via sudo (sudoers keeps SSH_ORIGINAL_COMMAND), so podmanctl's
# internal per-user sudo calls never prompt.
set -eu
set -f # no glob expansion while word-splitting the client's command line
refuse() { echo "upgrade-gate: $1" >&2; exit 1; }
set -- ${SSH_ORIGINAL_COMMAND:-}
{ [ $# -eq 3 ] || [ $# -eq 5 ]; } && [ "$1" = "podmanctl" ] && [ "$2" = "--upgrade" ] \
|| refuse "refused: '${SSH_ORIGINAL_COMMAND:-}' (only 'podmanctl --upgrade <service> [--timeout N]' crosses)"
svc=$3
case "$svc" in *[!a-z0-9-]*|'') refuse "bad service name '$svc'" ;; esac
[ -d "/media/ssd/podman-users/$svc" ] || refuse "unknown service '$svc'"
# optional host-side deadline: upgrade.py derives it from the Lightflow step
# timeout; coreutils timeout TERMs podmanctl's process group at the deadline
# and KILLs 10s later — so a wedged upgrade dies on the host even if the SSH
# session never tears down
limit=""
if [ $# -eq 5 ]; then
[ "$4" = "--timeout" ] || refuse "unexpected flag '$4' (only --timeout N)"
case "$5" in ''|*[!0-9]*) refuse "bad timeout '$5' (expected integer seconds)" ;; esac
limit=$5
fi
rc=0
if [ -n "$limit" ]; then
timeout -k 10 "$limit" /usr/local/bin/podmanctl --upgrade "$svc" || rc=$?
else
/usr/local/bin/podmanctl --upgrade "$svc" || rc=$?
fi
# safety net only: podmanctl re-owns the tree itself, between the pull and
# the builds — ownership MUST NOT change after a build, since Podman keys a
# COPY layer on the copied files' tar headers (uid/gid included), so a
# post-build chown makes identical sources hash differently next time and
# the next upgrade recompiles for nothing. This only ever fires for an
# upgrade that failed before podmanctl reached that step; data/ is skipped
# on purpose: those trees belong to the containers' mapped subuids.
SERVICE_DIR="/media/ssd/podman/$svc"
if [ -d "$SERVICE_DIR" ]; then
find "$SERVICE_DIR" -path "$SERVICE_DIR/data" -prune \
-o -user root -exec chown "${svc}_svc:${svc}_admins" {} +
fi
exit $rc
EOF
# executable for everyone, writable by root only
sudo chmod 0755 /usr/local/bin/upgrade-gate
Same validating-gate idea as
backupctl --ssh-gate, shrunk to a single command shape. The trailingfind/chownis a safety net, not the re-own that matters:podmanctl --upgradealready normalizes ownership between the pull and the builds, and that order is the whole point — re-owning files after a build changes their tar headers, which Podman's cache keysCOPYlayers on, so the next upgrade recompiles untouched code. The net only catches an upgrade that failed before podmanctl got that far.
👤 Create the upgrade_ops SSH account¶
Create the upgrade_ops user and sudoers rule
# dedicated account used ONLY by the Lightflow container to reach the gate.
# NOT added to the no-ssh group on purpose: SSH is its entire reason to exist,
# and the forced command below is the only thing it can ever execute.
sudo useradd --system --create-home --home-dir /home/upgrade_ops --shell /bin/bash upgrade_ops
# sudoers drop-in: keep the client's original command line visible to the
# gate (sshd exports it as SSH_ORIGINAL_COMMAND) and allow exactly ONE
# invocation as root — no wildcard
sudo tee /etc/sudoers.d/upgrade-gate >/dev/null <<'EOF'
Defaults!/usr/local/bin/upgrade-gate env_keep += "SSH_ORIGINAL_COMMAND"
upgrade_ops ALL=(root) NOPASSWD: /usr/local/bin/upgrade-gate
EOF
sudo chmod 0440 /etc/sudoers.d/upgrade-gate
# validate the sudoers syntax before it can lock you out
sudo visudo -cf /etc/sudoers.d/upgrade-gate
# smoke-test the gate from the admin account (env_keep applies here too):
# anything but `podmanctl --upgrade <service>` must be refused
SSH_ORIGINAL_COMMAND="podmanctl --stop vaultwarden" sudo upgrade-gate
🧰 Trust the service repositories for root¶
Let the root-run git pull work non-interactively
Through the gate, podmanctl's git pull runs as root with no TTY: the
origin must be pullable without any prompt, and the repo must be trusted
for root. Both fail closed — a refused pull aborts the upgrade and leaves
the service stopped.
# list every service clone's origin: a /media/ssd/git/... local path
# is fine, a https://git.fum-server.fr/... origin needs credentials
# and dies in the gate's no-TTY context with
# "could not read Username for 'https://…': No such device or address"
for d in /media/ssd/podman/*/.git; do
r=${d%/.git}
printf '%-14s %s\n' "${r##*/}" "$(git -C "$r" remote get-url origin 2>/dev/null)"
done
# point each https clone at the co-located bare repo — nginx serves
# /media/ssd/git as git.fum-server.fr, so it is the SAME repository —
# and trust BOTH paths for root, or the pull dies with "detected
# dubious ownership"
for d in /media/ssd/podman/*/.git; do
svc=$(basename "${d%/.git}")
bare=/media/ssd/git/podman/$svc.git
case "$(git -C "${d%/.git}" remote get-url origin 2>/dev/null)" in
https://*)
[ -d "$bare" ] || { echo "$svc: no $bare — skipped" >&2; continue; }
git -C "${d%/.git}" remote set-url origin "$bare"
echo "$svc: origin -> $bare" ;;
esac
sudo git config --system --add safe.directory "${d%/.git}"
[ -d "$bare" ] && sudo git config --system --add safe.directory "$bare"
done
# setsid -w = no controlling TTY (the gate's context) while keeping the
# output and exit code — a plain `sudo git pull` would PROMPT for a
# username instead of reproducing the failure
sudo setsid -w git -C /media/ssd/podman/gokapi pull --rebase </dev/null
echo "rc=$?"
The push-deploy services (cardbox, claudenames, kouizine, lightflow, mediascope, pooping, tripwise) were already rewired by their runbooks' push-deploy step — the loop leaves them untouched. Service directories without a
.gitare simply skipped by podmanctl (➖).
🔑 Authorize Lightflow's SSH key¶
Lightflow doesn't keep a private key file on disk: the key material lives in
the UPGRADE_SSH_KEY Variable (secret) and upgrade.py writes it to a
private, short-lived temp file only while ssh runs. The same applies to the
host-key line in UPGRADE_KNOWN_HOSTS — exactly the
backup pipeline's model,
with its own dedicated key pair and account.
Generate a key pair, authorize it, and capture the values to paste
# generate a throwaway key pair in a temp dir, the PRIVATE key will live only
# in the UPGRADE_SSH_KEY Variable, never as a file on the host or a mount
TMP="$(mktemp -d)"
ssh-keygen -t ed25519 -N "" -C "lightflow-upgrade" -f "${TMP}/id_ed25519"
# authorize the PUBLIC key for upgrade_ops with the SAME forced command
PUBKEY="$(cat ${TMP}/id_ed25519.pub)"
sudo mkdir -p /home/upgrade_ops/.ssh
sudo tee -a /home/upgrade_ops/.ssh/authorized_keys >/dev/null <<EOF
restrict,command="sudo /usr/local/bin/upgrade-gate" ${PUBKEY}
EOF
sudo chmod 0700 /home/upgrade_ops/.ssh
sudo chmod 0600 /home/upgrade_ops/.ssh/authorized_keys
sudo chown -R upgrade_ops:upgrade_ops /home/upgrade_ops/.ssh
# ---- value for UPGRADE_KNOWN_HOSTS ------------------------------------------
# the container reaches the host as host.containers.internal, so the known_hosts
# line must carry THAT name in front of the host's own ed25519 public host key
printf 'host.containers.internal %s\n' \
"$(sudo awk '{print $1, $2}' /etc/ssh/ssh_host_ed25519_key.pub)"
# ---- value for UPGRADE_SSH_KEY ----------------------------------------------
# print the PRIVATE key to copy, then wipe the temp dir so nothing lingers
cat "${TMP}/id_ed25519"
rm -rf "${TMP}"
Paste the private key into the
UPGRADE_SSH_KEYVariable (secret) and thehost.containers.internal …line intoUPGRADE_KNOWN_HOSTS. No private key file is kept on the host and no.sshvolume is mounted. The gate accepts onlypodmanctl --upgrade <service> [--timeout N]— nothing else crosses, in particular no stop/start and no shell.
⚙️ Configure Lightflow (in the UI)¶
1. Create the upgrade pool
Pools → Add pool
- Name:
upgrade - Slots:
1
A 1-slot pool serializes the upgrades: a full rebuild already saturates the ODROID's CPU, two at once would just thrash — and stretch both stacks' downtime.
2. Create the upgrade variables
Variables → Add variable (these become environment variables for the script):
| Key | Value | Secret |
|---|---|---|
UPGRADE_HOST |
upgrade_ops@host.containers.internal |
no |
UPGRADE_SSH_KEY |
(paste the SSH private key contents — see above) | yes |
UPGRADE_KNOWN_HOSTS |
(paste the known_hosts host-key line — see above) |
no |
There is no timeout Variable: the step timeout on each task is the single knob — Lightflow injects it as
LIGHTFLOW_STEP_TIMEOUTandupgrade.pyderives the host-side--timeoutpassed to the gate from it.
3. Create the upgrade script
#!/usr/bin/env python3
"""
upgrade.py — sample Lightflow task script (podmanctl upgrade of a Podman service).
Lightflow itself is a generic runner: this script holds the upgrade logic and
reaches the host over the restricted `upgrade_ops` SSH forced command, which
runs `sudo upgrade-gate` on the host. The gate lets exactly ONE command shape
through — `podmanctl --upgrade <service> [--timeout N]` — so Lightflow never
gets a shell and can never aim podmanctl at anything else.
One Lightflow TASK per service, made of a single STEP — podmanctl drives the
whole lifecycle on the host (stop, git pull, pull base + registry images,
full rebuild, restart, prune), so one box in the grid carries the upgrade:
Step 1 upgrade python3 /scripts/upgrade.py --upgrade <service> (on_success)
Configuration comes from Lightflow VARIABLES (injected as environment variables):
UPGRADE_HOST upgrade_ops@host.containers.internal (required)
UPGRADE_SSH_KEY SSH private key — inline PEM value OR a file path
UPGRADE_KNOWN_HOSTS known_hosts entry — inline value OR a file path
The timeout knob is the per-service STEP timeout set in the UI — rebuild
times vary wildly between a registry-image stack and a source-built one,
so pick it per service and be generous. Lightflow enforces it in the
container (SIGTERM the step's process group, SIGKILL 10s later) and
injects it as LIGHTFLOW_STEP_TIMEOUT; this script derives a slightly
smaller host-side deadline from it and passes it to the gate, which runs
podmanctl under coreutils `timeout` — so a wedged upgrade is aborted ON
THE HOST even if the SSH session never tears down. A failed or timed-out
upgrade can leave the service stopped — on purpose (a half-upgraded
stack must not blind-restart).
Holding the SSH key (and known_hosts) inline in a secret Variable keeps the
script generic — any future device just needs its own key Variable, with no
host-side key files to provision. The key material is written to a private,
short-lived temp file only while `ssh` runs, then removed.
"""
import os
import re
import subprocess
import sys
import tempfile
import textwrap
ACTIONS = {"--upgrade"}
def env(key, default=None):
return os.environ.get(key, default)
def materialize(value, kind):
"""Resolve a key / known_hosts VALUE to a file path that `ssh` can use.
A Variable may hold either a filesystem PATH (legacy) or the INLINE value
itself (preferred — generic, no host-side key files). Inline content is
written to a private (0600) temp file; the caller removes it afterwards.
A private key is recognised by its PEM header; a known_hosts entry by the
whitespace separating its fields. A key whose line breaks were flattened
to spaces by a single-line input field is re-wrapped into a valid PEM.
Returns (path, is_temp).
"""
# normalize line endings: a value pasted or stored with Windows CRLF
# (or a stray \r) keeps \r inside the body, so force LF.
text = value.replace("\r\n", "\n").replace("\r", "\n").strip()
inline = "-----BEGIN" in text if kind == "key" else any(c.isspace() for c in text)
if not inline:
return value, False
if kind == "key":
# Some Variable fields flatten a multi-line PEM onto ONE line, turning
# the newlines into spaces — ssh then fails with "error in libcrypto".
# Rebuild a canonical PEM: keep the BEGIN/END markers, strip ALL
# whitespace from the base64 body, then re-wrap at 70 columns. This is
# a no-op on an already-correct key, so it is always safe to run.
m = re.search(r"-----BEGIN ([A-Z0-9 ]+)-----(.*)-----END \1-----", text, re.S)
if m:
label = m.group(1)
body = re.sub(r"\s+", "", m.group(2))
text = "-----BEGIN {0}-----\n{1}\n-----END {0}-----".format(
label, "\n".join(textwrap.wrap(body, 70))
)
fd, path = tempfile.mkstemp(prefix="lightflow-", suffix=f".{kind}")
with os.fdopen(fd, "w") as f:
f.write(text + "\n")
os.chmod(path, 0o600)
return path, True
def host_timeout():
"""Host-side deadline derived from the ONE timeout knob: the step
timeout Lightflow injects as LIGHTFLOW_STEP_TIMEOUT. Slightly below
it, so the gate's coreutils `timeout` aborts podmanctl cleanly before
Lightflow tears down the process group; the 30s margin is capped at a
quarter of the step timeout so short steps keep most of their budget."""
step = int(env("LIGHTFLOW_STEP_TIMEOUT", "7200"))
return max(10, step - min(30, step // 4))
def ssh_upgrade(args):
"""Run `podmanctl <args> --timeout <derived>` on the host via the SSH forced command."""
host = env("UPGRADE_HOST")
if not host:
sys.exit("error: UPGRADE_HOST is not set (e.g. upgrade_ops@host.containers.internal)")
key, key_tmp = materialize(env("UPGRADE_SSH_KEY", "/data/.ssh/id_ed25519"), "key")
known, known_tmp = materialize(env("UPGRADE_KNOWN_HOSTS", "/data/.ssh/known_hosts"), "known_hosts")
remote = "podmanctl " + " ".join(args) + f" --timeout {host_timeout()}"
cmd = [
"ssh",
"-i", key,
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", f"UserKnownHostsFile={known}",
host,
remote,
]
print("+ " + " ".join(cmd), flush=True)
try:
return subprocess.run(cmd).returncode
finally:
for path, is_tmp in ((key, key_tmp), (known, known_tmp)):
if is_tmp:
try:
os.unlink(path)
except OSError:
pass
def main():
if len(sys.argv) < 3 or sys.argv[1] not in ACTIONS:
sys.exit("usage: upgrade.py --upgrade <service>")
action, service = sys.argv[1], sys.argv[2]
if not service.strip():
sys.exit("error: service name is empty")
if action == "--upgrade":
rc = ssh_upgrade(["--upgrade", service])
else:
sys.exit(f"error: unknown action {action!r} (--upgrade)")
sys.exit(rc)
if __name__ == "__main__":
main()
4. Create one task per service
Tasks → New task — example for vaultwarden:
- Name:
upgrade_vaultwarden - CRON: none needed — leave Auto off and trigger with ▶ Run now (an upgrade is a deliberate act; add a CRON only if you really want unattended upgrades)
- Pool:
upgrade - Steps (a single step running
python3 upgrade.py --upgrade vaultwarden):
| # | Name | Command | Arguments | Run rule | Timeout |
|---|---|---|---|---|---|
| 1 | upgrade | python3 |
--upgrade vaultwarden |
on_success | 600 |
Set the timeout per service — it must cover the whole stop → pull → rebuild → restart cycle: a registry-image stack (vaultwarden, planka, n8n) finishes in a few minutes, a source-built stack (kouizine, tripwise, mediascope, claudenames, cardbox, pooping) does a full rebuild after the base-image pull and can need 30–60 min on the ODROID. Duplicate the task for each service, changing the name, the service argument and the timeout.
Never upgrade lightflow through itself
podmanctl --upgrade lightflow stops the Lightflow stack — the very
container running the task. The dying SSH client tears down the session and
the host kills the half-done upgrade, leaving Lightflow stopped with nobody
left to restart it. Upgrade Lightflow from the host instead — see the
Lightflow runbook.
⬆️ Operations¶
Run and monitor the upgrades
- on demand: open a task → ▶ Run now (or ▶ Run on the Tasks page) — the normal flow after bumping a version in the service repo
- grid: 🟩 upgraded and restarted — 🟥 failed or timed out (the service
may be left stopped: read the log, then re-run or
podmanctl --start <svc>) - logs: open a task → pick a run column → pick the
upgradestep — the wholepodmanctloutput (pulls, build, restart, dashboard) streams live - check:
podmanctl --list --checkon the host confirms every stack is up and the registry images are current
First run — smoke test
Trigger the smallest service first (upgrade_vaultwarden) and check:
- the box goes green and the log shows the stop → pull → rebuild → start sequence
- the service is back up:
podmanctl --list(and the app answers in the browser) - the gate refuses anything else:
SSH_ORIGINAL_COMMAND="podmanctl --stop vaultwarden" sudo upgrade-gate→ refused
A timed-out upgrade is aborted on the host too
The gate runs podmanctl under coreutils timeout with a deadline derived
from the step timeout (slightly smaller), so a wedged upgrade dies on the
host even if the SSH session never tears down cleanly. Either way the
interrupted upgrade — mid-pull or mid-build — leaves the service
stopped. Be generous with the step timeouts and treat a red box as "go
check the host", not "it probably finished anyway".