Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 65 additions & 6 deletions crates/fastskill-cli/src/commands/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ pub fn group_metadata() -> GroupMetadata {
#[derive(Debug, Default)]
pub struct ManagedArgs {
pub json: bool,
/// `status` only: include the last apply's full report.
/// `status` only: include what the last apply did and the report body; with `--json`, print
/// only the report body.
pub report: bool,
}

Expand Down Expand Up @@ -111,6 +112,7 @@ managed_args!(StatusArgs, {
vec![
"fastskill managed status",
"fastskill managed status --report",
"fastskill managed status --json --report",
],
);
spec.args.push(ArgSpec {
Expand All @@ -119,7 +121,8 @@ managed_args!(StatusArgs, {
long: Some("report"),
value_type: ArgValueType::Bool,
cardinality: Cardinality::Optional,
help: "Include everything the last apply did",
help: "Include everything the last apply did and the report body; with --json, print \
only the report body",
..Default::default()
});
spec
Expand All @@ -142,6 +145,8 @@ pub struct Environment {
pub project_skills: Option<PathBuf>,
/// Whether a person is watching; a hook runs without a terminal.
pub interactive: bool,
/// FastSkill's configuration folder, where the credential command runs.
pub config_dir: PathBuf,
}

impl Environment {
Expand All @@ -151,6 +156,9 @@ impl Environment {
Ok(Self {
layout: ManagedLayout::for_current_user()?,
settings,
config_dir: user_file_path()
.and_then(|file| file.parent().map(PathBuf::from))
.unwrap_or_else(std::env::temp_dir),
user_settings_file: user_file_path(),
home: dirs::home_dir()
.ok_or_else(|| CliError::Config("can't determine the home folder".to_string()))?,
Expand All @@ -170,6 +178,8 @@ impl Environment {
project_skills: self.project_skills.clone(),
may_enroll,
now: chrono::Utc::now(),
interactive: self.interactive,
config_dir: self.config_dir.clone(),
})
}
}
Expand All @@ -183,13 +193,21 @@ fn not_configured() -> CliError {
}

pub async fn execute_enroll(args: EnrollArgs) -> CliResult<()> {
let env = Environment::current()?;
emit(run_enroll(&env, &args.0)?)
emit(off_runtime(move || run_enroll(&Environment::current()?, &args.0)).await?)
}

pub async fn execute_apply(args: ApplyArgs) -> CliResult<()> {
let env = Environment::current()?;
emit(run_apply(&env, &args.0)?)
emit(off_runtime(move || run_apply(&Environment::current()?, &args.0)).await?)
}

/// Run an apply on a blocking thread: it makes blocking https requests and runs the
/// credential command.
async fn off_runtime(
work: impl FnOnce() -> CliResult<Rendered> + Send + 'static,
) -> CliResult<Rendered> {
tokio::task::spawn_blocking(work)
.await
.map_err(|error| CliError::Config(format!("the apply stopped unexpectedly: {error}")))?
}

pub async fn execute_status(args: StatusArgs) -> CliResult<()> {
Expand Down Expand Up @@ -276,6 +294,9 @@ pub fn render_outcome(outcome: &ApplyOutcome) -> String {
if let Some(problem) = &outcome.state_problem {
lines.push(format!("Used the last accepted state: {problem}"));
}
if outcome.sign_in_needed {
lines.push(SIGN_IN_NEEDED.to_string());
}
if outcome.targets.is_empty() {
lines.push("No agent targets were found for this user.".to_string());
}
Expand Down Expand Up @@ -314,11 +335,29 @@ pub fn render_outcome(outcome: &ApplyOutcome) -> String {
for (what, why) in &outcome.failures {
lines.push(format!(" failed {what}: {why}"));
}
for warning in &outcome.warnings {
lines.push(format!(" warning {warning}"));
}
if let Some(report) = &outcome.report {
lines.push(match (&report.problem, report.accepted) {
(_, true) => format!(" report #{} accepted", report.sequence),
(Some(problem), _) => format!(" report not accepted: {problem}"),
(None, false) => " report not accepted".to_string(),
});
}
lines.join("\n")
}

const SIGN_IN_NEEDED: &str =
"Sign-in needed: the credential command didn't give a token the source accepts. Run \
`fastskill managed apply` in a terminal to sign in.";

pub fn run_status(env: &Environment, args: &ManagedArgs) -> CliResult<String> {
let status = managed::status(&env.layout, &env.settings, chrono::Utc::now())?;
if args.json && args.report {
// The exact body the next report carries, and nothing else (decision 15).
return Ok(serde_json::to_string_pretty(&status.report).unwrap_or_default());
}
if args.json {
return Ok(
serde_json::to_string_pretty(&status_json(&status, args.report)).unwrap_or_default(),
Expand All @@ -338,6 +377,10 @@ fn status_json(status: &ManagedStatus, report: bool) -> Value {
"expires_at": status.expires_at,
"expired": status.expired,
"state_problem": status.state_problem,
"sign_in_needed": status.sign_in_needed,
"ignored": status.ignored,
"report_to": status.report_note,
"warnings": last.map(|l| l.warnings.clone()).unwrap_or_default(),
"targets": last.map(|l| l.targets.clone()).unwrap_or_default(),
"collisions": last.map(|l| l.collisions.clone()).unwrap_or_default(),
"owned": status.owned,
Expand Down Expand Up @@ -377,6 +420,15 @@ pub fn render_status(status: &ManagedStatus, report: bool) -> String {
if let Some(problem) = &status.state_problem {
lines.push(format!("State: {problem}"));
}
if status.sign_in_needed {
lines.push(SIGN_IN_NEEDED.to_string());
}
for ignored in &status.ignored {
lines.push(format!("Ignored: {ignored}"));
}
if let Some(note) = &status.report_note {
lines.push(format!("Report: {note}"));
}
if let Some(last) = &status.last_apply {
lines.push(format!(
"Last apply: {} ({})",
Expand All @@ -393,10 +445,17 @@ pub fn render_status(status: &ManagedStatus, report: bool) -> String {
for path in &last.collisions {
lines.push(format!(" collision {}", path.display()));
}
for warning in &last.warnings {
lines.push(format!(" warning {warning}"));
}
if report {
lines.push(render_outcome(last));
}
}
if let (true, Some(body)) = (report, &status.report) {
lines.push("Report body:".to_string());
lines.push(serde_json::to_string_pretty(body).unwrap_or_default());
}
lines.push(format!("Managed entries: {}", status.owned.len()));
if status.quarantine.is_empty() {
lines.push("Quarantine: empty".to_string());
Expand Down
80 changes: 79 additions & 1 deletion crates/fastskill-cli/src/commands/managed_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::*;
use fastskill_core::core::managed::{
Enrollment, EntryChange, ManagedSource, PinnedKey, QuarantineReason, QuarantineRecord,
ReportDelivery,
};
use tempfile::TempDir;

Expand All @@ -21,6 +22,7 @@ fn environment(dir: &TempDir, configured: bool, interactive: bool) -> Environmen
home: dir.path().join("home"),
project_skills: None,
interactive,
config_dir: dir.path().to_path_buf(),
}
}

Expand Down Expand Up @@ -255,7 +257,83 @@ fn status_of_an_enrolled_user() {
report: true,
};
let value: Value = serde_json::from_str(&run_status(&env, &report).unwrap()).unwrap();
assert_eq!(value["last_apply"]["subject"], "team-a");
assert_eq!(value["format_version"], 1);
assert_eq!(value["sequence"], 1);
assert_eq!(value["machine_id"], value_of_machine(&env));
assert!(value.get("last_apply").is_none(), "{value}");
}

fn value_of_machine(env: &Environment) -> String {
let enrollment: Enrollment =
serde_json::from_slice(&std::fs::read(env.layout.enrollment_file()).unwrap()).unwrap();
enrollment.machine_id
}

#[test]
fn sign_in_warnings_and_the_report_are_shown() {
let mut last = outcome(true);
last.sign_in_needed = true;
last.warnings = vec!["report_url is elsewhere".to_string()];
for (delivery, expected) in [
(
ReportDelivery {
sequence: 4,
accepted: true,
problem: None,
},
"report #4 accepted",
),
(
ReportDelivery {
sequence: 4,
accepted: false,
problem: Some("HTTP 500".to_string()),
},
"report not accepted: HTTP 500",
),
(ReportDelivery::default(), "report not accepted"),
] {
last.report = Some(delivery);
let text = render_outcome(&last);
for expected in [
expected,
"Sign-in needed",
"warning report_url is elsewhere",
] {
assert!(text.contains(expected), "{expected} in {text}");
}
}

let status = ManagedStatus {
source: Some("/s/state.dsse".to_string()),
enrollment: Some(Enrollment::new("/s/state.dsse", chrono::Utc::now())),
sign_in_needed: true,
ignored: vec!["report_url: a file source never gets a report".to_string()],
report_note: Some("none: a file source never gets a report".to_string()),
report: Some(managed::Report::build(managed::report::ReportInput {
source: &ManagedSource::File(PathBuf::from("/s/state.dsse")),
enrollment: &Enrollment::new("/s/state.dsse", chrono::Utc::now()),
issued_at: None,
completed: true,
snapshot: &last.snapshot,
quarantine: &[],
refusals: &managed::report::Refusals::default(),
})),
last_apply: Some(last),
..ManagedStatus::default()
};
let text = render_status(&status, true);
for expected in [
"Sign-in needed",
"Ignored: report_url",
"Report: none: a file source",
" warning report_url is elsewhere",
"Report body:",
"\"format_version\": 1",
] {
assert!(text.contains(expected), "{expected} in {text}");
}
assert!(!render_status(&status, false).contains("Report body:"));
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions crates/fastskill-cli/src/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ where
let id: Arc<str> = Arc::from(path.leaf().unwrap_or(""));
let handler = Arc::new(handler);
let managed = follows_managed_policy(path);
let command_name: Arc<str> = Arc::from(path.0.join(" "));

Command {
id,
Expand All @@ -78,8 +79,11 @@ where
// `'static` and does not hold the borrow, which frees `ctx` for the
// drain below.
let fut = handler(ctx, typed);
let command_name = command_name.clone();
Box::pin(async move {
if managed {
// A refused install is kept for the managed report under this name.
fastskill_core::core::managed::set_current_command(&command_name);
managed_policy(Situation::for_current_user(Utc::now()).command_policy())?;
}
match output::mode() {
Expand Down
5 changes: 4 additions & 1 deletion crates/fastskill-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ walkdir.workspace = true
zip.workspace = true

# HTTP client for embedding API (rustls for musl compatibility)
reqwest.workspace = true
reqwest = { workspace = true, features = ["blocking"] }

# HTTP server
axum.workspace = true
Expand Down Expand Up @@ -97,6 +97,9 @@ insta = "1.47"
assert_cmd = "2.2"
predicates = "3.0"
proptest = "1.11"
# A local https server for the managed source tests
rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

[features]
default = ["filesystem-storage"]
Expand Down
Loading
Loading