use std::path::PathBuf;
use crate::{
diag::{Diagnostic, SgCode},
selector::{ProjectMismatch, Target, resolve_target},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestartPlan {
Everything {
config: PathBuf,
},
Project {
config: PathBuf,
project: String,
},
Service {
config: PathBuf,
service: String,
project: Option<String>,
},
Recycle {
config: PathBuf,
},
}
pub fn resolve_plan(
config: PathBuf,
service: Option<&str>,
project: Option<&str>,
) -> Result<RestartPlan, ProjectMismatch> {
Ok(match resolve_target(service, project)? {
Target::Everything => RestartPlan::Everything { config },
Target::Project { project } => RestartPlan::Project { config, project },
Target::Service { service, project } => RestartPlan::Service {
config,
service,
project,
},
})
}
#[derive(Debug, Clone, Copy)]
pub struct World {
pub supervisor_running: bool,
pub version_drifted: bool,
}
#[derive(Debug)]
pub enum Preflight {
Ready(RestartPlan),
Refused(Box<Diagnostic>),
}
pub fn preflight(plan: RestartPlan, world: World) -> Preflight {
if let RestartPlan::Everything { config } = &plan
&& world.supervisor_running
&& world.version_drifted
{
return Preflight::Ready(RestartPlan::Recycle {
config: config.clone(),
});
}
Preflight::Ready(plan)
}
pub fn manifest_rejected(reason: impl Into<String>) -> Diagnostic {
Diagnostic::error(
SgCode::ManifestRejected,
"the new manifest is invalid; the restart was refused and nothing was changed",
)
.note(reason)
.note("fix the manifest and retry; the running services were left untouched")
.help_docs()
}
pub fn recycle_refused(
config: &std::path::Path,
reason: impl Into<String>,
) -> Diagnostic {
Diagnostic::error(
SgCode::SupervisorRecycleFailed,
"refused to recycle the supervisor: the replacement config is invalid",
)
.note(reason)
.note(format!(
"the existing supervisor was left running; {} was not applied",
config.display()
))
.help_docs()
}
pub fn recycle_failed(config: &std::path::Path, reason: impl Into<String>) -> Diagnostic {
Diagnostic::error(
SgCode::SupervisorRecycleFailed,
"supervisor recycle failed: the old daemon was stopped but the new one did not start",
)
.note(reason)
.note("the box is currently unsupervised")
.help_cmd(
"recover",
format!("sysg start --daemonize --config {}", config.display()),
)
.help_docs()
}
pub fn reconcile_incomplete(
failed: Option<&[String]>,
cause: Option<&str>,
) -> Diagnostic {
let note = match failed {
Some(failed) => format!(
"units that did not reach their target: {}",
failed.join(", ")
),
None => "the failure could not be attributed to a specific unit".to_string(),
};
let diag = Diagnostic::error(
SgCode::ReconcileIncomplete,
"the restart did not bring every unit to its target state",
)
.note(note);
let diag = match cause {
Some(cause) => diag.note(format!("cause: {cause}")),
None => diag,
};
diag.help_cmd("see what's running", "sysg status")
.help_docs()
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> PathBuf {
PathBuf::from("/x/systemg.yaml")
}
#[test]
fn recycle_refused_is_sg0303_and_names_the_untouched_stack() {
let diag = recycle_refused(std::path::Path::new("/x/stack.yaml"), "bad yaml");
assert_eq!(diag.code, SgCode::SupervisorRecycleFailed);
assert!(diag.notes.iter().any(|n| n.contains("bad yaml")));
assert!(diag.notes.iter().any(|n| n.contains("left running")));
}
#[test]
fn recycle_failed_carries_the_recovery_command() {
let diag = recycle_failed(std::path::Path::new("/x/stack.yaml"), "no port");
assert_eq!(diag.code, SgCode::SupervisorRecycleFailed);
assert!(diag.notes.iter().any(|n| n.contains("unsupervised")));
let help = format!("{diag}");
assert!(help.contains("sysg start --daemonize --config /x/stack.yaml"));
}
#[test]
fn sg0302_names_only_the_units_that_actually_failed() {
let failed = ["gamecast_draftkings_ingest".to_string()];
let diag = reconcile_incomplete(Some(&failed), Some("timed out"));
assert_eq!(diag.code, SgCode::ReconcileIncomplete);
let rendered = format!("{diag}");
assert!(rendered.contains("gamecast_draftkings_ingest"));
assert!(
!rendered.contains("gamecast_api"),
"healthy units must never be named as failures"
);
assert!(rendered.contains("timed out"));
}
#[test]
fn sg0302_says_indeterminate_rather_than_naming_every_unit() {
let diag = reconcile_incomplete(None, Some("monitor thread failed to spawn"));
assert_eq!(diag.code, SgCode::ReconcileIncomplete);
let rendered = format!("{diag}");
assert!(rendered.contains("could not be attributed"));
assert!(rendered.contains("monitor thread failed to spawn"));
}
#[test]
fn no_selectors_targets_everything() {
assert_eq!(
resolve_plan(cfg(), None, None).unwrap(),
RestartPlan::Everything { config: cfg() }
);
}
#[test]
fn project_and_service_selectors_resolve() {
assert_eq!(
resolve_plan(cfg(), None, Some("alpha")).unwrap(),
RestartPlan::Project {
config: cfg(),
project: "alpha".into()
}
);
assert_eq!(
resolve_plan(cfg(), Some("alpha/worker"), None).unwrap(),
RestartPlan::Service {
config: cfg(),
service: "worker".into(),
project: Some("alpha".into())
}
);
}
#[test]
fn mismatch_is_reported() {
let err = resolve_plan(cfg(), Some("beta/worker"), Some("alpha")).unwrap_err();
assert_eq!(err.flag, "alpha");
}
#[test]
fn preflight_upgrades_drifted_whole_config_to_recycle() {
let world = World {
supervisor_running: true,
version_drifted: true,
};
match preflight(RestartPlan::Everything { config: cfg() }, world) {
Preflight::Ready(RestartPlan::Recycle { config }) => {
assert_eq!(config, cfg())
}
other => panic!("expected recycle, got {other:?}"),
}
}
#[test]
fn preflight_leaves_a_matched_whole_config_alone() {
let world = World {
supervisor_running: true,
version_drifted: false,
};
match preflight(RestartPlan::Everything { config: cfg() }, world) {
Preflight::Ready(RestartPlan::Everything { .. }) => {}
other => panic!("expected everything, got {other:?}"),
}
}
#[test]
fn preflight_never_recycles_a_targeted_restart() {
let world = World {
supervisor_running: true,
version_drifted: true,
};
match preflight(
RestartPlan::Project {
config: cfg(),
project: "alpha".into(),
},
world,
) {
Preflight::Ready(RestartPlan::Project { .. }) => {}
other => panic!("targeted restart must not recycle, got {other:?}"),
}
}
}