diff --git a/Cargo.lock b/Cargo.lock index 8563b05..4ad1905 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5034,6 +5034,7 @@ dependencies = [ "pwd", "rand_core 0.6.4", "rumors", + "serde_json", "sha3", "sled-hardware-types", "slog", diff --git a/server/src/executor.rs b/server/src/executor.rs index 1fbf2da..ba51127 100644 --- a/server/src/executor.rs +++ b/server/src/executor.rs @@ -22,6 +22,7 @@ use rustix::io::close; use rustix::process::{Pid, Signal, ioctl_tiocsctty, kill_process_group, setsid}; use slog::{Logger, debug, error, o, warn}; use tokio::fs::{DirBuilder, OpenOptions}; +use tokio::io::AsyncWriteExt as _; use tokio::process::{Child, Command}; use tokio::spawn; use tokio::sync::{mpsc, watch}; @@ -258,6 +259,27 @@ async fn job_spawn( format!("creating job stderr file `{}`", stderr_path.display()) ); + // Record the signed request beside the output it produces, so + // the job directory attests what ran even after gossip forgets. + let job_path = job_dir.join("job.json"); + let json = with_io_err!( + serde_json::to_vec_pretty(&*request).map_err(io::Error::other), + format!("encoding job request file `{}`", job_path.display()) + ); + let mut job_file = with_io_err!( + OpenOptions::new() + .create_new(true) + .write(true) + .mode(file_mode) + .open(&job_path) + .await, + format!("creating job request file `{}`", job_path.display()) + ); + with_io_err!( + job_file.write_all(&json).await, + format!("writing job request file `{}`", job_path.display()) + ); + // Set up the job command. let mut cmd = Command::new("bash"); cmd.kill_on_drop(true); diff --git a/tests/Cargo.toml b/tests/Cargo.toml index f528e61..9d49115 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -20,6 +20,7 @@ libc.workspace = true pwd.workspace = true rand_core.workspace = true rumors.workspace = true +serde_json.workspace = true sha3.workspace = true sled-hardware-types.workspace = true slog.workspace = true diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index c0c81d6..8eac120 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -18,7 +18,7 @@ use pwd::Passwd; use sled_hardware_types::BaseboardId; use slog::{Discard, Logger, o}; use tempfile::TempDir; -use tokio::fs::{metadata, write}; +use tokio::fs::{metadata, read, write}; use tokio::sync::watch; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; @@ -29,7 +29,7 @@ use sush_client::context::Authz; use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; use sush_common::jobs::{ Access, JobId, JobLimits, JobOutputState, JobOutputStream::*, JobStartRequest, JobStatus, - ProcessError, Session, SessionId, + ProcessError, Session, SessionId, SignedJob, }; use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _, pem_cert_chain}; use sush_common::targets::{Cubbies, Target}; @@ -2130,3 +2130,37 @@ async fn interactive_target_required() { Err(JobError::InteractiveTarget) )); } + +/// Each job's directory records the signed request beside its output. +#[named] +#[tokio::test] +async fn job_json() { + let log = test_logger(function_name!()); + let (mgr, mut root, dir, _shutdown) = manager_and_test_root(log).await; + let authn = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); + + let job_id = session.next_job_id(); + let job = root.sign_job_request(&job_id, "true", false).await; + mgr.job_start( + &authn, + job.clone().into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + session.job_started(job.clone().into_signed()); + + let path = dir + .path() + .join("jobs") + .join(job_id.to_string()) + .join("job.json"); + let recorded: SignedJob = serde_json::from_slice(&read(&path).await.unwrap()).unwrap(); + assert_eq!(recorded, job.into_signed()); +}