use super::install_commands::{install_commands, not_started_notice, running_shepherd};
use super::privilege_check::{Privilege, privilege};
use super::target_resolution::{StartupPlan, plan};
use super::unit_layout::{remove_unit, secure_path_warning, unit_file_name, write_unit};
use crate::cli::{Init, StartupArgs};
use crate::exit::ExitCode;
use crate::output::{StartupStep, StartupSteps, Streams, emit, write_outcome};
use shep_daemon::boot::Shepherd;
use std::path::Path;
pub(super) const OK: &str = "ok";
pub(super) const ABSENT: &str = "absent";
#[derive(Debug)]
pub(super) struct Refusal {
pub(super) code: ExitCode,
pub(super) message: String,
}
pub fn startup(
streams: &mut Streams<'_>,
explicit_home: Option<&Path>,
args: &StartupArgs,
) -> ExitCode {
let plan = match plan(explicit_home, args) {
Ok(plan) => plan,
Err(refusal) => return refuse(streams, refusal.code, &refusal.message),
};
if let Some(paths) = plan.own_default_home.clone()
&& let Err(refusal) = crate::home::create_default_home(streams, paths)
{
return refuse(streams, refusal.code(), &refusal.to_string());
}
install(streams, &plan, privilege())
}
pub fn unstartup(streams: &mut Streams<'_>, args: &StartupArgs) -> ExitCode {
match plan(None, args) {
Ok(plan) => remove(streams, &plan, privilege()),
Err(refusal) => refuse(streams, refusal.code, &refusal.message),
}
}
pub(crate) fn install(
streams: &mut Streams<'_>,
plan: &StartupPlan,
privilege: Privilege,
) -> ExitCode {
if !plan.spec.home.is_dir() {
return refuse(
streams,
ExitCode::Usage,
&format!(
"no directory at {}; create it first (any shep verb run as {} creates that \
user's own ~/.shep), or pass --home with the $SHEP_HOME this unit should carry",
plan.spec.home.display(),
plan.spec.user,
),
);
}
if plan.unit_path.exists() {
return refuse(
streams,
ExitCode::Usage,
&format!(
"{} already exists; shep unstartup removes it first",
plan.unit_path.display()
),
);
}
if privilege == Privilege::Unprivileged {
return refuse(
streams,
ExitCode::Failure,
&format!(
"installing the unit needs root; run: sudo {} startup --user {} --home {}",
shell_quote(&plan.spec.exec.display().to_string()),
shell_quote(&plan.spec.user),
shell_quote(&plan.spec.home.display().to_string()),
),
);
}
if let Some(message) = secure_path_warning(plan.sudo_user.as_deref(), &plan.spec) {
streams.aside("secure_path", &message);
}
let shepherd = running_shepherd(&plan.spec.home).unwrap_or_else(|err| {
streams.aside(
"liveness",
&format!("could not tell whether a shepherd already runs; starting the unit: {err}"),
);
Shepherd::Absent
});
let start = shepherd == Shepherd::Absent;
if !start {
streams.aside("already_running", ¬_started_notice(plan, shepherd));
}
let mut steps = vec![write_unit(plan)];
steps.extend(install_commands(plan, start).iter().map(|(program, args)| {
run_step(
program,
&args.iter().map(String::as_str).collect::<Vec<_>>(),
)
}));
report(streams, "startup", steps)
}
pub(crate) fn remove(
streams: &mut Streams<'_>,
plan: &StartupPlan,
privilege: Privilege,
) -> ExitCode {
if !plan.unit_path.exists() {
return report(
streams,
"unstartup",
vec![StartupStep {
action: "removed",
target: plan.unit_path.display().to_string(),
result: ABSENT.to_string(),
}],
);
}
if privilege == Privilege::Unprivileged {
return refuse(
streams,
ExitCode::Failure,
&format!(
"removing the unit needs root; run: sudo {} unstartup --user {}",
shell_quote(&plan.spec.exec.display().to_string()),
shell_quote(&plan.spec.user),
),
);
}
let mut steps = Vec::new();
match plan.init {
Init::Systemd => {
steps.push(run_step(
"systemctl",
&["disable", "--now", &unit_file_name(plan)],
));
steps.push(remove_unit(plan));
steps.push(run_step("systemctl", &["daemon-reload"]));
}
Init::Launchd => {
steps.push(run_step(
"launchctl",
&["bootout", &format!("system/{}", plan.label)],
));
steps.push(remove_unit(plan));
}
Init::Openrc => {
steps.push(run_step("rc-service", &[&unit_file_name(plan), "stop"]));
steps.push(run_step(
"rc-update",
&["del", &unit_file_name(plan), "default"],
));
steps.push(remove_unit(plan));
}
Init::FreebsdRc => {
steps.push(run_step("service", &[&unit_file_name(plan), "stop"]));
steps.push(run_step(
"sysrc",
&["-x", &format!("{}_enable", unit_file_name(plan))],
));
steps.push(remove_unit(plan));
}
Init::OpenbsdRc => {
steps.push(run_step("rcctl", &["stop", &unit_file_name(plan)]));
steps.push(run_step("rcctl", &["disable", &unit_file_name(plan)]));
steps.push(remove_unit(plan));
}
}
report(streams, "unstartup", steps)
}
fn run_step(program: &str, args: &[&str]) -> StartupStep {
let target = format!("{program} {}", args.join(" "));
let result = match std::process::Command::new(program).args(args).output() {
Ok(output) if output.status.success() => OK.to_string(),
Ok(output) => failure_line(&output),
Err(err) => err.to_string(),
};
StartupStep {
action: "ran",
target,
result,
}
}
fn report(streams: &mut Streams<'_>, command: &str, steps: Vec<StartupStep>) -> ExitCode {
let failed = steps
.iter()
.any(|step| step.result != OK && step.result != ABSENT);
let written = write_outcome(emit(
&mut *streams.out,
streams.fmt,
command,
StartupSteps(steps),
streams.style,
));
if failed { ExitCode::Failure } else { written }
}
pub(crate) fn shell_quote(word: &str) -> String {
let safe = |b: &u8| b.is_ascii_alphanumeric() || b"_./:@%+=-".contains(b);
if !word.is_empty() && word.as_bytes().iter().all(safe) {
return word.to_string();
}
format!("'{}'", word.replace('\'', r"'\''"))
}
fn failure_line(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map_or_else(|| format!("failed: {}", output.status), ToString::to_string)
}
pub(super) fn refuse(streams: &mut Streams<'_>, code: ExitCode, message: &str) -> ExitCode {
streams.fail(code, message)
}
#[cfg(test)]
mod tests {
use super::super::privilege_check::Privilege;
use crate::cli::Init;
use crate::exit::ExitCode;
use crate::output::{StartupStep, Streams};
use super::super::testing::*;
use super::*;
use crate::cli::Format;
#[test]
fn an_unprivileged_startup_prints_the_command_and_exits_non_zero() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".shep");
std::fs::create_dir_all(&home).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
install(&mut streams, &plan_for_test(&home), Privilege::Unprivileged)
};
assert_ne!(code, ExitCode::Success);
let printed = String::from_utf8(err).unwrap();
assert!(printed.contains("sudo"), "{printed}");
assert!(printed.contains("--home"), "{printed}");
assert!(printed.contains(home.to_str().unwrap()), "{printed}");
assert!(
!plan_for_test(&home).unit_path.exists(),
"an unprivileged startup writes no unit"
);
}
#[test]
fn a_shep_home_that_does_not_exist_is_refused() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-created");
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
install(&mut streams, &plan_for_test(&missing), Privilege::Root)
};
assert_eq!(code, ExitCode::Usage);
let printed = String::from_utf8(err).unwrap();
assert!(printed.contains(missing.to_str().unwrap()), "{printed}");
assert!(
!plan_for_test(&missing).unit_path.exists(),
"a refused startup writes no unit"
);
}
#[test]
fn a_failed_step_fails_the_verb_and_still_prints_every_row() {
let step = |action, target: &str, result: &str| StartupStep {
action,
target: target.to_string(),
result: result.to_string(),
};
let steps = vec![
step("wrote", "/etc/systemd/system/shep-deploy.service", OK),
step(
"ran",
"systemctl daemon-reload",
"Failed to reload: read-only file system",
),
step("ran", "systemctl enable --now shep-deploy.service", OK),
];
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
report(&mut streams, "startup", steps)
};
assert_eq!(code, ExitCode::Failure);
let printed = String::from_utf8(out).unwrap();
for expected in [
"daemon-reload",
"read-only file system",
"enable --now shep-deploy.service",
] {
assert!(
printed.contains(expected),
"every step is reported, not only the ones before the failure: {printed}"
);
}
}
#[test]
fn a_printed_command_quotes_what_a_shell_would_split() {
assert_eq!(shell_quote("/home/ada/.shep"), "/home/ada/.shep");
assert_eq!(shell_quote("/opt/my shep"), "'/opt/my shep'");
assert_eq!(shell_quote("ada's"), r"'ada'\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn a_failed_step_reports_one_line_and_never_an_empty_one() {
use std::os::unix::process::ExitStatusExt as _;
let failed = std::process::ExitStatus::from_raw(256);
let output = |stderr: &str| std::process::Output {
status: failed,
stdout: Vec::new(),
stderr: stderr.as_bytes().to_vec(),
};
assert_eq!(
failure_line(&output(
"\nFailed to enable unit: Unit is masked.\nSee `journalctl`.\n"
)),
"Failed to enable unit: Unit is masked."
);
assert!(
!failure_line(&output("")).is_empty(),
"a command that failed silently still has to say so"
);
}
#[test]
fn an_explicit_init_beats_detection() {
use clap::Parser as _;
use crate::cli::{Cli, Commands};
let cli = Cli::try_parse_from(["shep", "startup", "--init", "openrc"]).unwrap();
match cli.command {
Commands::Startup(args) => assert_eq!(args.init, Some(Init::Openrc)),
other => panic!("expected Startup, got {other:?}"),
}
}
}