crates/gunnar-ui-server/src/lifecycle.rs
//! **gunnar-ui-server as a skidbladnir-managed service.**
//!
//! Every service in the suite goes through skidbladnir — unit generation,
//! install, uninstall. Until 2026-09-13 this console was the one that did not:
//! `deploy/gunnar-ui.service` was a hand-written file that `deploy/deploy.sh`
//! `tar`red onto the host and `cp`ed into `/etc/systemd/system` when `cmp` said
//! it differed. The unit is declared HERE now, in the shape
//! `monetize-ui-server`'s `lifecycle.rs` has: one [`ServiceSpec`], one
//! [`Lifecycle`]; [`unit_text`] renders it, [`install`] writes it.
//!
//! ## What is deliberately NOT changed
//!
//! **`User=root`.** The hand-written unit named no user, which in a system unit
//! means root, and that is what the console on the front has been running as:
//! `/etc/gunnar-ui.env` is mode 0600 root, `console_key` and `state.redb` are
//! root's. skidbladnir's renderer always emits `User=`/`Group=` in system scope,
//! so the account is now WRITTEN DOWN rather than implied — but it is the same
//! account. Moving the console to a service account of its own is a change to
//! the running deployment (three files to re-own and a re-enrolment if the key
//! moves), and it does not belong in the commit that changes how the unit is
//! generated.
//!
//! **No inline `Environment=` lines.** The hand-written unit had none, on
//! purpose, and its comment is the reason: systemd applies `EnvironmentFile=`
//! after `Environment=` whatever order they appear in, so a value in the unit is
//! ignored and the file always wins. Everything configurable is in
//! [`crate::envfile`] and reaches the process through
//! `EnvironmentFile=/etc/gunnar-ui.env`.
//!
//! **No `StartLimit`.** `monetize-ui-server` declares `give_up_after(5)`; this
//! one does not, because the hand-written unit did not and the difference is not
//! cosmetic — a start limit parks the unit in `failed` instead of retrying, and
//! that is a change to how the console behaves on a bad night. It can be added
//! deliberately later; it must not arrive as a side effect of this port.
//!
//! ## The service home the install must not create
//!
//! `WorkingDirectory=` is `/opt/gunnar-ui/current`, and `current` is a SYMLINK
//! that the release swap renames over. skidbladnir's privileged install creates
//! and re-owns the spec's *service home*, so a spec that declared this path as
//! its home would, on a host with no release yet, `create_dir_all` a REAL
//! directory called `current` — and the swap's `rename(2)` can never replace a
//! directory with a symlink. This spec therefore declares NO service home:
//! `/opt/gunnar-ui` is made by the deploy, not by the install.
use std::path::Path;
use skidbladnir::service::{AgentMode, HealthProbe, Lifecycle, ServiceSpec};
use skidbladnir::systemd::{RestartPolicy, Scope, SystemdServiceSpec};
/// The unit's name — `gunnar-ui.service`, the name the hand-written file had and
/// the name `systemctl restart gunnar-ui` on the box still means.
pub const UNIT: &str = "gunnar-ui";
/// `[Unit] Description=`, verbatim from the file this replaces.
pub const DESCRIPTION: &str = "gunnar console (BFF + SPA)";
/// The release layout's root. Everything below is either a release or state that
/// deliberately outlives one.
pub const ROOT: &str = "/opt/gunnar-ui";
/// The symlink both paths go through, so one rename moves the binary and the
/// bundle together. A deploy that changed them separately could serve a new
/// console against an old server for as long as the restart took.
pub const CURRENT: &str = "/opt/gunnar-ui/current";
/// `ExecStart=`.
pub const EXEC: &str = "/opt/gunnar-ui/current/gunnar-ui-server";
/// What the unit is generated from. Everything else this console needs is in
/// `/etc/gunnar-ui.env`; this is only what systemd itself has to be told.
#[derive(Debug, Clone)]
pub struct Settings {
/// Where the console listens — read back out of the env file, because the
/// health probe has to dial the address the service actually binds and not
/// a second copy of the number.
pub bind: String,
}
impl Default for Settings {
fn default() -> Self {
Settings {
bind: "127.0.0.1:3210".into(),
}
}
}
impl Settings {
/// Take the bind address from a parsed env file, falling back to the
/// compiled default the server itself falls back to.
pub fn from_env_file(entries: &[(String, String)]) -> Settings {
let bind = entries
.iter()
.find(|(k, _)| k == "GUNNAR_UI_BIND")
.map(|(_, v)| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| Settings::default().bind);
Settings { bind }
}
}
/// The backend-neutral declaration.
pub fn service_spec(exec: &Path, s: &Settings, scope: Scope) -> ServiceSpec {
let port = skidbladnir::service::split_bind_addr(&s.bind)
.map(|(_, p)| p)
.unwrap_or(3210);
ServiceSpec::new(UNIT, DESCRIPTION, exec.display().to_string())
.scope(scope)
// See the module docs: written down, not changed.
.user("root")
// `RestartSec=3`, not skidbladnir's default of 2 — the hand-written unit
// said 3 and this port changes no behaviour it is not asked to.
.restart(RestartPolicy::OnFailure)
.restart_sec(3)
// Not rendered into the unit; it is what `--status` dials to answer
// "healthy", and it must be the address the env file binds.
.health(HealthProbe::Tcp(port))
.agent(AgentMode::Fat)
// THE PROGRAM PATH. `current` and the binary's parent both canonicalise
// to the same release directory, so this asserts that the unit and the
// file were placed by the same step — the assertion skidbladnir added
// after a unit was found executing out of a cargo target tree.
.with_program_dirs([CURRENT])
// THE DATA ROOT, with the sub-directories this console owns named
// explicitly, because the root is SHARED: `/opt/gunnar-ui/data` is
// gunnar's own repository storage and `deploy.sh` says so in its layout
// comment ("gunnar's own, untouched by this script"). An uninstall that
// swept every child of the root would delete the forge. `console_key`,
// `guest_key` and `state.redb` are likewise left alone: an uninstall that
// forgets the passkeys is an uninstall nobody can undo.
.state_dir("releases")
.state_dir("incoming")
}
/// The neutral spec with a data root attached, or without one and saying so.
///
/// `DataRoot::from_env` refuses a root that is or contains a real user's home, so
/// a refusal here means the value is wrong, not that the uninstall is optional —
/// hence the line on stderr rather than a silent `None`.
pub fn lifecycle(exec: &Path, s: &Settings, scope: Scope) -> Lifecycle {
let mut spec = service_spec(exec, s, scope);
match skidbladnir::uninstall::DataRoot::from_env("GUNNAR_UI_DATA", Path::new(ROOT)) {
Ok(dr) => spec = spec.data_root(&dr),
Err(e) => eprintln!("{UNIT}: {e} — uninstall would erase nothing; not declaring a root"),
}
Lifecycle::new(spec)
}
/// The systemd projection: the neutral spec, plus the two things that are
/// systemd's business and nothing else's.
///
/// `env` is the CONTENT of `/etc/gunnar-ui.env` as it was read off the host.
/// skidbladnir's installer rewrites the `EnvironmentFile=` it is told about from
/// the spec, so the values have to ride along or the install would blank the
/// file — see [`install`], which is where that matters and where it is checked.
/// They ride as SECRETS, never as `env`, so not one of them is rendered into the
/// world-readable 0644 unit.
pub fn systemd_spec(
exec: &Path,
s: &Settings,
scope: Scope,
env: &[(String, String)],
) -> SystemdServiceSpec {
let mut sys = lifecycle(exec, s, scope)
.systemd_spec()
.with_env_file(crate::envfile::PATH);
// RENDER-ONLY, and deliberately not the spec's working dir: declaring it
// there would make it the service home the privileged install creates and
// re-owns, and `current` is a symlink the swap renames over. See the module
// docs.
sys.working_dir = CURRENT.to_string();
sys = sys.with_service_home(None::<String>);
for (k, v) in env {
sys = sys.with_secret_env(k, v);
}
sys
}
/// `--unit`: what would be written, without writing it.
pub fn unit_text(exec: &Path, s: &Settings, scope: Scope, env: &[(String, String)]) -> String {
skidbladnir::systemd::systemd_unit_scoped(&systemd_spec(exec, s, scope, env), scope)
}
/// What an install refuses to do, and why — checked before anything is written.
///
/// This is `deploy.sh`'s "Checking the host's configuration" step, typed. The
/// script ran it over SSH before it moved anything, for a reason it wrote down:
/// getting `GUNNAR_UI_DIR` wrong takes the console down without taking the
/// service down with it — the server starts, `/health` answers, and every page
/// is empty.
#[derive(Debug, PartialEq, Eq)]
pub enum Refusal {
/// `/etc/gunnar-ui.env` is not there. Nothing in this repository creates it,
/// on purpose: it holds the credential that is this console's admin.
NoEnvFile(String),
/// Declared `Need::Required` keys the file does not set.
MissingKeys(Vec<&'static str>),
/// Keys still carrying the template's placeholder.
Unfilled(Vec<&'static str>),
/// `GUNNAR_UI_DIR` is not `/opt/gunnar-ui/current/dist`.
WrongUiDir { found: String },
/// The install would write back fewer keys than it read. It cannot: the
/// installer rewrites the env file from the spec, so anything it did not
/// carry would be silently deleted from a file that holds credentials.
WouldDropKeys(Vec<String>),
/// **The host is configured to sell and this binary cannot.** The env file
/// sets one or more of [`crate::envfile::SELLING`] and the artifact asking
/// the question was built without `metered`.
///
/// This is the 2026-09-12 failure, typed. It is the only refusal here that
/// is about the BINARY rather than the file, and it is the reason
/// `--check-env` is run against the STAGED binary over SSH: the artifact
/// that is about to become `current` is the only thing that can answer
/// truthfully, because the answer is compiled into it.
ConfiguredToSellButCannot(Vec<&'static str>),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::NoEnvFile(p) => write!(
f,
"{p} is missing. Nothing installs it for you, because it holds \
GUNNAR_UI_ADMIN_TOKEN and an install that can silently rewrite that is an \
install that can silently lock everyone out. Run `gunnar-ui-server \
--env-template > {p}`, fill in every REPLACE-ME, `chmod 600 {p}`, and try \
again."
),
Refusal::MissingKeys(keys) => write!(
f,
"{} sets none of these, and the console needs every one of them: {}",
crate::envfile::PATH,
keys.join(", ")
),
Refusal::Unfilled(keys) => write!(
f,
"{} still carries the template's placeholder for {}. A placeholder is not a \
configured secret, it is a published one.",
crate::envfile::PATH,
keys.join(", ")
),
Refusal::WrongUiDir { found } => write!(
f,
"GUNNAR_UI_DIR in {} is {found:?}, and this layout needs {:?}. Nothing has \
been changed. With it wrong the server starts, answers /health, and serves \
an empty console — which is exactly how the first deploy of this layout \
failed.",
crate::envfile::PATH,
format_args!("{CURRENT}/dist"),
),
Refusal::WouldDropKeys(keys) => write!(
f,
"installing would rewrite {} without these keys it already has: {}. Refusing \
— nothing has been changed.",
crate::envfile::PATH,
keys.join(", ")
),
Refusal::ConfiguredToSellButCannot(keys) => write!(
f,
"{} sets {} — keys only a `metered` build acts on — but THIS binary is a \
`community` build and has none of that half in it: no /welcome, no /signup, no \
/auth/signup/status, no monetize client. `metered` is a NON-DEFAULT feature, \
so a bare `cargo build --release -p gunnar-ui-server` produces exactly this \
binary, and nothing about it looks wrong: it starts, answers /health and \
serves every page. What it answers a stranger returning from an external \
signup page is 401 \"log in to use the console\", which is how two live \
signups came to be stranded on 2026-09-11 and reversed. Rebuild with \
`--features metered`, or clear {} from {} to run a community console on \
purpose. Nothing has been changed.",
crate::envfile::PATH,
keys.join(", "),
keys.join(", "),
crate::envfile::PATH,
),
}
}
}
/// Every reason this env file is not installable, in the order an operator fixes
/// them. Pure: no filesystem, no root, so the whole refusal set is unit-testable.
///
/// `metered` is what the ARTIFACT is, not what the file says: callers pass
/// [`crate::METERED`], which is compiled in. It is a parameter rather than a
/// `cfg!` inside because a refusal nobody can drive red is a refusal nobody can
/// trust — with it here, one test binary asks the same function both ways.
pub fn refusals(
env_file_exists: bool,
entries: &[(String, String)],
metered: bool,
) -> Vec<Refusal> {
let mut out = Vec::new();
if !env_file_exists {
out.push(Refusal::NoEnvFile(crate::envfile::PATH.to_string()));
return out;
}
let missing = crate::envfile::missing_required(entries);
if !missing.is_empty() {
out.push(Refusal::MissingKeys(missing));
}
let unfilled = crate::envfile::unfilled(entries);
if !unfilled.is_empty() {
out.push(Refusal::Unfilled(unfilled));
}
let want = format!("{CURRENT}/dist");
match entries.iter().find(|(k, _)| k == "GUNNAR_UI_DIR") {
Some((_, v)) if v.trim() == want => {}
Some((_, v)) => out.push(Refusal::WrongUiDir {
found: v.trim().to_string(),
}),
// Already reported as a missing required key; not reported twice.
None => {}
}
// LAST, because it is the only one that is about the binary rather than the
// file, and because an operator who has to fix it fixes it somewhere else
// entirely: with a `cargo build --features metered`, not with an editor on
// the box.
if !metered {
let selling = crate::envfile::configured_to_sell(entries);
if !selling.is_empty() {
out.push(Refusal::ConfiguredToSellButCannot(selling));
}
}
out
}
/// The keys an install would write back, against the keys it read. Anything in
/// the second and not the first is a credential this install would delete.
pub fn dropped_keys(read: &[(String, String)], written: &str) -> Vec<String> {
let back: Vec<String> = crate::envfile::parse(written)
.into_iter()
.map(|(k, _)| k)
.collect();
read.iter()
.map(|(k, _)| k.clone())
.filter(|k| !back.contains(k))
.collect()
}
/// **INSTALL.** Refuse, then hand the whole thing to skidbladnir.
///
/// `entries` is `/etc/gunnar-ui.env` as read off this host. It is written back
/// by skidbladnir's installer — that is not optional, the installer rewrites the
/// file it is told to reference — so the last gate before anything is written is
/// that the round trip loses nothing.
#[cfg(unix)]
pub fn install(
exec: &Path,
s: &Settings,
scope: Scope,
entries: &[(String, String)],
) -> anyhow::Result<()> {
let refusals = refusals(true, entries, crate::METERED);
if let Some(first) = refusals.first() {
anyhow::bail!("{first}");
}
let sys = systemd_spec(exec, s, scope, entries);
let dropped = dropped_keys(entries, &skidbladnir::systemd::env_file(&sys));
if !dropped.is_empty() {
anyhow::bail!("{}", Refusal::WouldDropKeys(dropped));
}
sys.validate().map_err(|e| anyhow::anyhow!(e))?;
skidbladnir::systemd::install_systemd_scoped(&sys, scope)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn good_env() -> Vec<(String, String)> {
let mut v = crate::envfile::parse(&crate::envfile::template());
for (k, val) in v.iter_mut() {
if k == "GUNNAR_UI_ADMIN_TOKEN" {
*val = "6c1f0a9d5e3b47a28f0c9d1e5b7a3f4c".into();
}
if k == "GUNNAR_UI_MONETIZE_TOKEN_FILE" {
*val = "/opt/gunnar-ui/monetize-token".into();
}
// `GUNNAR_UI_SMTP_TOKEN_FILE` joined the template after this fixture
// was written, and nothing here filled it — so `unfilled` refused it
// in EVERY test built on `good_env`, and three of them asserted on
// the exact refusal list. They were measuring the fixture's own gap
// rather than the thing each one is named for. Same treatment as the
// monetize path beside it, for the same reason.
if k == "GUNNAR_UI_SMTP_TOKEN_FILE" {
*val = "/opt/gunnar-ui/smtp-token".into();
}
}
// The path placeholder IS the value an operator keeps; `unfilled` only
// refuses it while it is byte-identical to the template's.
v.retain(|(k, _)| k != "GUNNAR_UI_MONETIZE_TOKEN_FILE" && k != "GUNNAR_UI_SMTP_TOKEN_FILE");
v
}
#[test]
fn the_bind_the_probe_dials_comes_from_the_env_file() {
let s = Settings::from_env_file(&[("GUNNAR_UI_BIND".into(), "127.0.0.1:9999".into())]);
let spec = service_spec(Path::new(EXEC), &s, Scope::System);
assert_eq!(spec.health, HealthProbe::Tcp(9999));
// …and an env file that says nothing falls back to what the server itself
// falls back to, not to a third number.
assert_eq!(Settings::from_env_file(&[]).bind, "127.0.0.1:3210");
}
#[test]
fn not_one_configured_value_reaches_the_world_readable_unit() {
let env = vec![
("GUNNAR_UI_ADMIN_TOKEN".into(), "s3cret-token".into()),
("GUNNAR_UI_CONTROL".into(), "10.13.8.101:50051".into()),
];
let unit = unit_text(Path::new(EXEC), &Settings::default(), Scope::System, &env);
assert!(!unit.contains("s3cret-token"), "{unit}");
assert!(!unit.contains("Environment="), "{unit}");
assert!(
unit.contains("EnvironmentFile=/etc/gunnar-ui.env"),
"{unit}"
);
// …and they are all in the 0600 file instead.
let body = skidbladnir::systemd::env_file(&systemd_spec(
Path::new(EXEC),
&Settings::default(),
Scope::System,
&env,
));
assert!(
body.contains("GUNNAR_UI_ADMIN_TOKEN=s3cret-token"),
"{body}"
);
}
#[test]
fn the_install_never_creates_a_directory_where_the_swap_needs_a_symlink() {
// `current` is renamed over by the release swap. A declared service home
// is `create_dir_all`ed by the privileged install, and a real directory
// there can never be replaced by rename(2).
let sys = systemd_spec(Path::new(EXEC), &Settings::default(), Scope::System, &[]);
assert_eq!(sys.working_dir, CURRENT);
assert_eq!(sys.service_home, None);
}
#[test]
fn an_uninstall_does_not_delete_gunnars_repositories() {
let lc = lifecycle(Path::new(EXEC), &Settings::default(), Scope::System);
let spec = lc.spec();
assert_eq!(spec.data_root.as_deref(), Some(ROOT));
// Named sub-dirs, so the sweep is those and not every child of the root.
assert_eq!(spec.state_dirs, vec!["releases", "incoming"]);
assert!(!spec.state_dirs.iter().any(|d| d == "data"));
}
#[test]
fn a_host_with_no_env_file_is_refused_and_told_how_to_make_one() {
let r = refusals(false, &[], METERED_BUILD);
assert_eq!(r.len(), 1);
assert!(matches!(r[0], Refusal::NoEnvFile(_)));
assert!(r[0].to_string().contains("--env-template"));
}
#[test]
fn the_ui_dir_check_deploy_sh_made_over_ssh_is_still_made() {
let mut env = good_env();
for (k, v) in env.iter_mut() {
if k == "GUNNAR_UI_DIR" {
*v = "/opt/gunnar-ui/releases/20260913T090000Z-94eaa98/dist".into();
}
}
let r = refusals(true, &env, METERED_BUILD);
assert_eq!(
r,
vec![Refusal::WrongUiDir {
found: "/opt/gunnar-ui/releases/20260913T090000Z-94eaa98/dist".into()
}]
);
// …and a file that names `current` passes.
assert!(refusals(true, &good_env(), METERED_BUILD).is_empty());
}
/// Both halves are DRIVEN here, whichever half this test binary happens to
/// be: `metered` is an argument, not a `cfg!`. These two constants only name
/// the two values so the asserts read as what they are about.
const METERED_BUILD: bool = true;
const COMMUNITY_BUILD: bool = false;
/// A host with a buy link and a binary that cannot receive the payer back.
fn env_that_sells() -> Vec<(String, String)> {
let mut env = good_env();
env.push((
"GUNNAR_UI_BUY_URL".into(),
"https://buy.stripe.com/6oU5kEfAV1cL4Ny9V9bMQ00".into(),
));
// A path of the operator's own, not the template's: a value still
// byte-identical to the placeholder is refused by `unfilled` instead,
// and this test is about the other refusal.
env.push((
"GUNNAR_UI_MONETIZE_TOKEN_FILE".into(),
"/opt/gunnar-ui/secrets/monetize-token".into(),
));
env
}
/// ★ **The 2026-09-12 failure, refused before anything moves.**
///
/// The console on the front carried `GUNNAR_UI_MONETIZE` and
/// `GUNNAR_UI_BUY_URL` and was a binary built without `metered`, so it
/// advertised a purchase, sent the buyer to the vendor, and met them on the
/// way back with 401. Two live card payments were taken and refunded. This
/// is the deploy's `--check-env` step seeing it — run against the STAGED
/// binary, over SSH, before the symlink swap.
#[test]
fn a_console_that_cannot_sell_is_refused_on_a_host_that_expects_to() {
let r = refusals(true, &env_that_sells(), COMMUNITY_BUILD);
assert_eq!(
r,
vec![Refusal::ConfiguredToSellButCannot(vec![
"GUNNAR_UI_MONETIZE_TOKEN_FILE",
"GUNNAR_UI_BUY_URL",
])],
"a community binary on a selling host must be refused BY NAME"
);
// The message has to be actionable by somebody reading one line of a
// deploy log, so it names the rebuild and the keys.
let said = r[0].to_string();
assert!(said.contains("--features metered"), "{said}");
assert!(said.contains("GUNNAR_UI_BUY_URL"), "{said}");
assert!(said.contains("/auth/signup/status"), "{said}");
}
/// The GREEN half, and the two ways it can be green.
#[test]
fn the_paid_binary_passes_and_so_does_a_community_one_that_sells_nothing() {
// The same file, with the paid half actually in the artifact.
assert!(refusals(true, &env_that_sells(), METERED_BUILD).is_empty());
// …and a community console on a host that takes no money is the
// ordinary self-hosted deployment, which must keep installing.
assert!(refusals(true, &good_env(), COMMUNITY_BUILD).is_empty());
}
/// A stock template is not a shop. `GUNNAR_UI_MONETIZE_TOKEN_FILE` is a
/// `Fill::Placeholder`, and a placeholder is a value nobody chose — counting
/// it would refuse every community install of an unedited template for a
/// line that switches nothing on.
#[test]
fn an_unfilled_template_does_not_read_as_a_host_that_sells() {
let template = crate::envfile::parse(&crate::envfile::template());
assert!(crate::envfile::configured_to_sell(&template).is_empty());
// And `GUNNAR_UI_MONETIZE` alone never counts: it is only dialled once a
// bearer is found, so it turns nothing on by itself.
let addr_only = vec![("GUNNAR_UI_MONETIZE".into(), "https://monetize.ignalina.dk".into())];
assert!(crate::envfile::configured_to_sell(&addr_only).is_empty());
}
#[test]
fn an_install_that_would_forget_a_key_the_host_already_has_is_refused() {
// The installer REWRITES /etc/gunnar-ui.env from the spec. If a key the
// host had did not ride along, the install would delete it from a file
// that holds credentials — silently, and as root.
let read = vec![
("GUNNAR_UI_BIND".into(), "127.0.0.1:3210".into()),
("SOMETHING_AN_OPERATOR_ADDED".into(), "1".into()),
];
let sys = systemd_spec(Path::new(EXEC), &Settings::default(), Scope::System, &read);
assert!(dropped_keys(&read, &skidbladnir::systemd::env_file(&sys)).is_empty());
// …and the check can see a drop when there is one.
let half = systemd_spec(
Path::new(EXEC),
&Settings::default(),
Scope::System,
&read[..1],
);
assert_eq!(
dropped_keys(&read, &skidbladnir::systemd::env_file(&half)),
vec!["SOMETHING_AN_OPERATOR_ADDED".to_string()]
);
}
#[test]
fn the_exec_the_unit_names_is_the_one_the_swap_moves() {
let unit = unit_text(
&PathBuf::from(EXEC),
&Settings::default(),
Scope::System,
&[],
);
assert!(
unit.contains("ExecStart=/opt/gunnar-ui/current/gunnar-ui-server"),
"{unit}"
);
}
}