feat(maestro): optional maestro-web systemd unit in installer (NAP-231)
ci / rust (push) Successful in 2m9s
ci / rust (push) Successful in 2m9s
After the daemon-unit step, the wizard now offers a second user unit (`maestro-web.service`) that runs `maestro-registry` on 127.0.0.1:8888 so the web UI is reachable in a browser locally. Decisions: - The unit runs `maestro-registry` directly — the web bundle is already embedded in that binary, so a separate `maestro-web` binary would be pure cost. `Description=` and the unit filename make the user-facing framing clear. - `Wants=maestrod.service` + `After=` (not `Requires=`): the registry process doesn't actually need the daemon to start; the web UI just has nothing to show without one. Soft dependency expresses that. - Default loopback bind is `127.0.0.1:8888` to make it obvious which port is the local UI vs a separately deployed registry on `:8080`. Wizard surface: - New prompt (default *no*) after the daemon-systemd step. - New flags: `--no-web`, `--web-bind <addr>`, `--web-exec-start <path>`. - Skipped automatically when systemd is absent or `--no-systemd` is set. - The re-run/update path mirrors the daemon flow: refresh the unit, daemon-reload, restart instead of start. - `maestro-registry` is only `cargo install`ed when the user opts in, keeping the no-web install footprint unchanged. docs/INSTALL.md gets the new flags + a full sample unit body.
This commit is contained in:
@@ -14,4 +14,7 @@ pub mod install;
|
||||
pub mod systemd;
|
||||
|
||||
pub use install::{cargo_install_args, systemctl_user_args, CargoSource, SystemctlAction};
|
||||
pub use systemd::{is_systemd_host, render_user_unit, user_unit_path, UNIT_FILE_NAME};
|
||||
pub use systemd::{
|
||||
is_systemd_host, render_user_unit, render_web_user_unit, user_unit_path, web_user_unit_path,
|
||||
DEFAULT_WEB_BIND_ADDR, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME,
|
||||
};
|
||||
|
||||
@@ -16,13 +16,15 @@ use std::process::{Command, ExitCode};
|
||||
use clap::Parser;
|
||||
use maestro_installer::install::cargo_bin;
|
||||
use maestro_installer::{
|
||||
cargo_install_args, is_systemd_host, render_user_unit, systemctl_user_args, user_unit_path,
|
||||
CargoSource, SystemctlAction, UNIT_FILE_NAME,
|
||||
cargo_install_args, is_systemd_host, render_user_unit, render_web_user_unit,
|
||||
systemctl_user_args, user_unit_path, web_user_unit_path, CargoSource, SystemctlAction,
|
||||
DEFAULT_WEB_BIND_ADDR, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME,
|
||||
};
|
||||
|
||||
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
||||
const DAEMON_CRATE: &str = "maestro-daemon";
|
||||
const CLI_CRATE: &str = "maestro-cli";
|
||||
const REGISTRY_CRATE: &str = "maestro-registry";
|
||||
const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -61,10 +63,24 @@ struct Args {
|
||||
#[arg(long)]
|
||||
no_configure: bool,
|
||||
|
||||
/// Skip the optional local web-UI systemd unit (no
|
||||
/// `maestro-registry` install, no `maestro-web.service`).
|
||||
#[arg(long)]
|
||||
no_web: bool,
|
||||
|
||||
/// Override the ExecStart path written to the systemd unit.
|
||||
/// Default: `~/.cargo/bin/maestrod`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
exec_start: Option<PathBuf>,
|
||||
|
||||
/// Override the ExecStart path for `maestro-web.service`.
|
||||
/// Default: `~/.cargo/bin/maestro-registry`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
web_exec_start: Option<PathBuf>,
|
||||
|
||||
/// Loopback `host:port` the local web UI binds to.
|
||||
#[arg(long, value_name = "ADDR", default_value = DEFAULT_WEB_BIND_ADDR)]
|
||||
web_bind: String,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -117,6 +133,7 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// 3. systemd integration.
|
||||
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
|
||||
let systemd_active = systemd_detected && !args.no_systemd;
|
||||
if args.no_systemd {
|
||||
eprintln!("\nSkipping systemd step (--no-systemd).");
|
||||
} else if !systemd_detected {
|
||||
@@ -125,7 +142,17 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
handle_systemd(&args, interactive, configured)?;
|
||||
}
|
||||
|
||||
print_summary(configured, systemd_detected && !args.no_systemd);
|
||||
// 4. Optional local web UI (maestro-registry on loopback).
|
||||
let web_installed = if systemd_active && !args.no_web {
|
||||
handle_web_unit(&source, &args, interactive)?
|
||||
} else {
|
||||
if args.no_web {
|
||||
eprintln!("\nSkipping local web UI step (--no-web).");
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
print_summary(configured, systemd_active, web_installed, &args.web_bind);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +173,8 @@ fn print_banner(source: &CargoSource) {
|
||||
eprintln!("This wizard will:");
|
||||
eprintln!(" 1. cargo install both `{DAEMON_CRATE}` and `{CLI_CRATE}`");
|
||||
eprintln!(" 2. (optional) run `maestrod configure` to bootstrap the config file");
|
||||
eprintln!(" 3. (optional) install a systemd user unit and enable it");
|
||||
eprintln!(" 3. (optional) install the maestrod systemd user unit");
|
||||
eprintln!(" 4. (optional) install a local web UI systemd unit (`maestro-web.service`)");
|
||||
eprintln!();
|
||||
match source {
|
||||
CargoSource::Git { url, branch } => {
|
||||
@@ -304,6 +332,105 @@ fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
|
||||
}
|
||||
|
||||
fn default_web_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
maestro_installer::systemd::default_maestro_registry_path()
|
||||
.ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into())
|
||||
}
|
||||
|
||||
/// Drop the web-UI unit (and install `maestro-registry` if the user opts in).
|
||||
/// Returns `true` if the unit ended up on disk after this call.
|
||||
fn handle_web_unit(
|
||||
source: &CargoSource,
|
||||
args: &Args,
|
||||
interactive: bool,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let unit_path = web_user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
|
||||
let already_installed = unit_path.exists();
|
||||
|
||||
eprintln!();
|
||||
if already_installed {
|
||||
eprintln!(
|
||||
"local web UI unit already present at {} — refreshing.",
|
||||
unit_path.display()
|
||||
);
|
||||
} else {
|
||||
eprintln!("Local web UI: an optional `maestro-web.service` runs `maestro-registry`");
|
||||
eprintln!(
|
||||
"on {bind} so you can open it in a browser. Useful for single-machine setups;",
|
||||
bind = args.web_bind
|
||||
);
|
||||
eprintln!("skip if you already have a remote registry deployed.");
|
||||
}
|
||||
|
||||
let want_unit = if already_installed || !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm(
|
||||
&format!(
|
||||
"Install local web UI unit at {} (also runs cargo install maestro-registry)?",
|
||||
unit_path.display()
|
||||
),
|
||||
false,
|
||||
)?
|
||||
};
|
||||
if !want_unit {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Install (or update) the registry binary first — the unit's ExecStart
|
||||
// points at it.
|
||||
let locked = !args.no_locked;
|
||||
install_crate(source, REGISTRY_CRATE, locked)?;
|
||||
|
||||
let exec_start = match &args.web_exec_start {
|
||||
Some(p) => p.clone(),
|
||||
None => default_web_exec_start()?,
|
||||
};
|
||||
if let Some(parent) = unit_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let body = render_web_user_unit(&exec_start, &args.web_bind);
|
||||
std::fs::write(&unit_path, body)?;
|
||||
eprintln!("wrote {}", unit_path.display());
|
||||
|
||||
run_systemctl(SystemctlAction::DaemonReload, WEB_UNIT_FILE_NAME)?;
|
||||
|
||||
if !already_installed {
|
||||
let want_enable = if !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm("Enable maestro-web.service (start on login)?", true)?
|
||||
};
|
||||
if want_enable {
|
||||
run_systemctl(SystemctlAction::Enable, WEB_UNIT_FILE_NAME)?;
|
||||
}
|
||||
}
|
||||
|
||||
let (action, want) = if already_installed {
|
||||
let want = if !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm(
|
||||
"Restart maestro-web.service to pick up the new binary?",
|
||||
true,
|
||||
)?
|
||||
};
|
||||
(SystemctlAction::Restart, want)
|
||||
} else {
|
||||
let want = if !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm("Start maestro-web.service now?", true)?
|
||||
};
|
||||
(SystemctlAction::Start, want)
|
||||
};
|
||||
if want {
|
||||
run_systemctl(action, WEB_UNIT_FILE_NAME)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_systemctl(action: SystemctlAction, unit: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = systemctl_user_args(action, unit);
|
||||
eprintln!("\n==> systemctl {}", args.join(" "));
|
||||
@@ -328,7 +455,7 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
fn print_summary(configured: bool, systemd_enabled: bool) {
|
||||
fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool, web_bind: &str) {
|
||||
eprintln!();
|
||||
eprintln!("Done.");
|
||||
eprintln!();
|
||||
@@ -343,4 +470,8 @@ fn print_summary(configured: bool, systemd_enabled: bool) {
|
||||
eprintln!(" • Start the daemon: `maestrod`");
|
||||
}
|
||||
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
|
||||
if web_installed {
|
||||
eprintln!(" • Open the local web UI: http://{web_bind}");
|
||||
eprintln!(" • Web status: `systemctl --user status maestro-web`");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system";
|
||||
/// Filename written under `~/.config/systemd/user/`.
|
||||
pub const UNIT_FILE_NAME: &str = "maestrod.service";
|
||||
|
||||
/// Optional second unit: a local `maestro-registry` instance that serves the
|
||||
/// web UI on loopback. Independent of the daemon process-wise, but useless on
|
||||
/// its own — `Wants=maestrod.service` + `After=` express that softly.
|
||||
pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
|
||||
|
||||
/// Default loopback bind for the local web UI. Picked outside the registry
|
||||
/// binary's own default (8080) to make it obvious which is which on hosts
|
||||
/// where both might coexist with a separately-deployed registry.
|
||||
pub const DEFAULT_WEB_BIND_ADDR: &str = "127.0.0.1:8888";
|
||||
|
||||
/// `true` when the given path exists. Production callers should pass
|
||||
/// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic.
|
||||
pub fn is_systemd_host(probe: &Path) -> bool {
|
||||
@@ -58,6 +68,53 @@ pub fn default_maestrod_path() -> Option<PathBuf> {
|
||||
)
|
||||
}
|
||||
|
||||
/// `~/.config/systemd/user/maestro-web.service`. `None` if `$HOME` / config
|
||||
/// dir is unresolvable.
|
||||
pub fn web_user_unit_path() -> Option<PathBuf> {
|
||||
let base = dirs::config_dir()?;
|
||||
Some(base.join("systemd").join("user").join(WEB_UNIT_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Render the web-UI unit body. `exec_start` is the absolute path to
|
||||
/// `maestro-registry`; `bind_addr` is the `host:port` the registry listens
|
||||
/// on (set via the `MAESTRO_REGISTRY_BIND_ADDR` env var).
|
||||
///
|
||||
/// `Wants=` rather than `Requires=`: the registry binary doesn't actually
|
||||
/// need maestrod to start — they're decoupled at the process level. But the
|
||||
/// web UI has nothing to show without a daemon connected, so we order this
|
||||
/// after maestrod and politely express the dependency.
|
||||
pub fn render_web_user_unit(exec_start: &Path, bind_addr: &str) -> String {
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=Maestro local web UI (maestro-registry on {bind})\n\
|
||||
Documentation=https://git.naps.pt/naps62/maestro\n\
|
||||
After=network-online.target maestrod.service\n\
|
||||
Wants=maestrod.service\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
Environment=MAESTRO_REGISTRY_BIND_ADDR={bind}\n\
|
||||
ExecStart={exec}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
exec = exec_start.display(),
|
||||
bind = bind_addr,
|
||||
)
|
||||
}
|
||||
|
||||
/// Default `ExecStart` for the web unit: `~/.cargo/bin/maestro-registry`.
|
||||
pub fn default_maestro_registry_path() -> Option<PathBuf> {
|
||||
Some(
|
||||
dirs::home_dir()?
|
||||
.join(".cargo")
|
||||
.join("bin")
|
||||
.join("maestro-registry"),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -99,4 +156,25 @@ mod tests {
|
||||
let p = user_unit_path().expect("config dir resolvable in test env");
|
||||
assert!(p.ends_with("systemd/user/maestrod.service"), "got {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_unit_renders_with_bind_and_exec() {
|
||||
let body = render_web_user_unit(
|
||||
Path::new("/home/alice/.cargo/bin/maestro-registry"),
|
||||
"127.0.0.1:8888",
|
||||
);
|
||||
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-registry"));
|
||||
assert!(body.contains("Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888"));
|
||||
assert!(body.contains("After=network-online.target maestrod.service"));
|
||||
assert!(body.contains("Wants=maestrod.service"));
|
||||
assert!(body.contains("WantedBy=default.target"));
|
||||
assert!(body.contains("Restart=on-failure"));
|
||||
assert!(body.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_unit_path_uses_correct_filename() {
|
||||
let p = web_user_unit_path().expect("config dir resolvable in test env");
|
||||
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -43,8 +43,13 @@ The wizard:
|
||||
3. On systemd hosts, optionally writes a user unit at
|
||||
`~/.config/systemd/user/maestrod.service`, reloads systemd, and offers to
|
||||
enable + start it.
|
||||
4. On systemd hosts, also offers a **local web UI** unit
|
||||
(`~/.config/systemd/user/maestro-web.service`). When accepted, this also
|
||||
`cargo install`s `maestro-registry` and runs it bound to `127.0.0.1:8888`
|
||||
(override with `--web-bind`). Skip with `--no-web` (or just answer "n"); a
|
||||
remote registry deployment is the alternative.
|
||||
|
||||
Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
|
||||
Binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -57,6 +62,9 @@ Both binaries land in `~/.cargo/bin` — make sure that's on your `$PATH`.
|
||||
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestrod`). |
|
||||
| `--no-systemd` | Skip the systemd step even if detected. |
|
||||
| `--no-configure` | Skip the `maestrod configure` step. |
|
||||
| `--no-web` | Skip the optional local web-UI unit + `maestro-registry` install. |
|
||||
| `--web-bind <addr>` | Loopback bind for the web UI (default `127.0.0.1:8888`). |
|
||||
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-registry`). |
|
||||
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). |
|
||||
|
||||
## What the systemd unit looks like
|
||||
@@ -87,6 +95,31 @@ systemctl --user status maestrod
|
||||
journalctl --user -u maestrod -f
|
||||
```
|
||||
|
||||
The optional `maestro-web.service` looks like:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Maestro local web UI (maestro-registry on 127.0.0.1:8888)
|
||||
Documentation=https://git.naps.pt/naps62/maestro
|
||||
After=network-online.target maestrod.service
|
||||
Wants=maestrod.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8888
|
||||
ExecStart=%h/.cargo/bin/maestro-registry
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
`Wants=maestrod.service` (not `Requires=`) — the registry process is
|
||||
technically independent of the daemon, but the web UI has nothing to show
|
||||
without one connected, so we order this after `maestrod` and politely
|
||||
encourage it.
|
||||
|
||||
## Running the wizard without the bootstrap script
|
||||
|
||||
If you'd rather skip the script:
|
||||
|
||||
Reference in New Issue
Block a user