use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::AipCommands;
use crate::daemon::policy_poller::{PolicyRefreshOutcome, PolicyRefreshResult};
use crate::error::{OlError, ERR_BUNDLE_FETCH_FAILED, ERR_INVALID_CONFIG};
const DAEMON_WAIT: std::time::Duration = std::time::Duration::from_secs(17);
pub fn run(cmd: &AipCommands, output: &OutputConfig) -> Result<(), OlError> {
match cmd {
AipCommands::Refresh => refresh(output),
}
}
fn refresh(output: &OutputConfig) -> Result<(), OlError> {
let cfg = crate::config::Config::load(None, None, false)?;
let token_path = crate::config::openlatch_dir().join("daemon.token");
let token = std::fs::read_to_string(&token_path).map_err(|error| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read daemon.token: {error}"),
)
.with_suggestion("Run `openlatch init` to restore the local daemon credentials.")
})?;
let response = crate::egress::blocking_client()
.post(format!(
"http://127.0.0.1:{}/admin/policy/refresh",
cfg.port
))
.bearer_auth(token.trim())
.timeout(DAEMON_WAIT)
.send()
.map_err(|error| {
OlError::new(
ERR_BUNDLE_FETCH_FAILED,
format!("Cannot reach the running daemon to refresh AIP policy: {error}"),
)
.with_suggestion("Start the daemon with `openlatch start`, then retry.")
})?;
let status = response.status();
let body = response.json::<serde_json::Value>().map_err(|error| {
OlError::new(
ERR_BUNDLE_FETCH_FAILED,
format!("The daemon returned an unreadable AIP refresh result: {error}"),
)
.with_suggestion("Run `openlatch doctor`, then restart the daemon.")
})?;
if !status.is_success() {
return Err(daemon_error(status.as_u16(), &body));
}
let result: PolicyRefreshResult = serde_json::from_value(body).map_err(|error| {
OlError::new(
ERR_BUNDLE_FETCH_FAILED,
format!("The daemon returned an invalid AIP refresh result: {error}"),
)
.with_suggestion("Update and restart the daemon so the CLI and daemon versions match.")
})?;
render(&result, output);
let exit = outcome_exit_code(result.outcome);
if exit != 0 {
crate::cli::report::record_exit_code(exit);
}
Ok(())
}
fn outcome_exit_code(outcome: PolicyRefreshOutcome) -> i32 {
match outcome {
PolicyRefreshOutcome::Changed | PolicyRefreshOutcome::Unchanged => 0,
PolicyRefreshOutcome::Pending => crate::cli::report::EXIT_DEGRADED,
PolicyRefreshOutcome::Refused | PolicyRefreshOutcome::Offline => 1,
}
}
fn daemon_error(status: u16, body: &serde_json::Value) -> OlError {
let error = body.get("error").unwrap_or(body);
let message = error
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| format!("AIP refresh failed with daemon HTTP {status}"));
let mut result = OlError::new(ERR_BUNDLE_FETCH_FAILED, message);
if let Some(suggestion) = error.get("suggestion").and_then(serde_json::Value::as_str) {
result = result.with_suggestion(suggestion);
}
result
}
fn render(result: &PolicyRefreshResult, output: &OutputConfig) {
if output.format == OutputFormat::Json {
output.print_json(result);
return;
}
let headline = match result.outcome {
PolicyRefreshOutcome::Changed => "AIP bundle refreshed — changed",
PolicyRefreshOutcome::Unchanged if result.response_status == Some(304) => {
"AIP bundle refreshed — unchanged (304)"
}
PolicyRefreshOutcome::Unchanged => {
"AIP bundle refreshed — verified download matches the resident bundle"
}
PolicyRefreshOutcome::Pending => "AIP bundle refresh pending",
PolicyRefreshOutcome::Refused => "AIP bundle refresh refused",
PolicyRefreshOutcome::Offline => "AIP bundle refresh unavailable",
};
output.print_step(headline);
if let Some(reason) = result.reason.as_deref() {
output.print_substep(&format!("Reason: {reason}"));
}
if result.has_bundle {
output.print_substep(&format!(
"Resident: selected {}, schema {}, bundle {}",
display(result.selected_version),
display(result.schema_version),
display(result.bundle_revision)
));
output.print_substep(&format!(
"Digest: {}",
result.digest.as_deref().unwrap_or("unknown")
));
output.print_substep(&format!(
"Verified body received: {}",
result
.verified_body_received_at
.as_deref()
.unwrap_or("unknown")
));
match result.resident_aip_count {
Some(count) => output.print_substep(&format!("Resident AIPs: {count}")),
None => output.print_substep("Resident AIPs: unknown for this bundle"),
}
} else {
output.print_substep("Resident: none — no AIP bundle is currently enforcing");
}
output.print_substep(&format!(
"Last platform contact: {}",
result
.last_platform_contact_at
.as_deref()
.unwrap_or("never")
));
if matches!(
result.outcome,
PolicyRefreshOutcome::Refused | PolicyRefreshOutcome::Offline
) {
output.print_notice("Run `openlatch doctor` for the cause and remedy.");
}
}
fn display<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn update_is_a_visible_alias_of_refresh() {
for verb in ["refresh", "update"] {
let cli = crate::cli::Cli::try_parse_from(["openlatch", "system", "aip", verb])
.expect("command parses");
assert!(matches!(
cli.command,
Some(crate::cli::Commands::System {
cmd: crate::cli::SystemCommands::Aip {
cmd: crate::cli::AipCommands::Refresh
}
})
));
}
}
#[test]
fn daemon_error_preserves_structured_remedy() {
let error = daemon_error(
503,
&serde_json::json!({
"error": {
"code": ERR_BUNDLE_FETCH_FAILED,
"message": "poller unavailable",
"suggestion": "restart"
}
}),
);
assert_eq!(error.code, ERR_BUNDLE_FETCH_FAILED);
assert_eq!(error.message, "poller unavailable");
assert_eq!(error.suggestion.as_deref(), Some("restart"));
}
#[test]
fn refresh_outcomes_have_scriptable_exit_codes() {
assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Changed), 0);
assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Unchanged), 0);
assert_eq!(
outcome_exit_code(PolicyRefreshOutcome::Pending),
crate::cli::report::EXIT_DEGRADED
);
assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Refused), 1);
assert_eq!(outcome_exit_code(PolicyRefreshOutcome::Offline), 1);
}
}