//! Job types: one `once` invocation, its live log, and its terminal outcome.
use std::fmt;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::app::{Host, ImageRef};
/// Identifier for a single `once` invocation tracked by the server.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct JobId(Uuid);
impl JobId {
/// Mints an identifier for a newly accepted job.
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for JobId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for JobId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::str::FromStr for JobId {
type Err = uuid::Error;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
Ok(Self(Uuid::parse_str(raw)?))
}
}
/// The operations `twice` is willing to perform. This enum is the whole write
/// surface of the API: it has no variant that removes, resets, restores, or
/// executes anything.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum JobKind {
/// `once start <host>`
Start {
/// Application to start.
host: Host,
},
/// `once stop <host>`
Stop {
/// Application to stop.
host: Host,
},
/// `once deploy <image> --host <host>`
Deploy {
/// New application hostname.
host: Host,
/// Pinned image assembled from the declared source and version.
image: ImageRef,
},
/// `once update <host> --image <image>`
UpdateImage {
/// Application to update.
host: Host,
/// Image to roll the application onto.
image: ImageRef,
/// Explicit `--auto-update` value; omitted means "leave the flag off
/// the command line and inherit whatever `once` defaults to".
auto_update: Option<bool>,
},
}
impl JobKind {
/// The application this job acts on.
pub const fn host(&self) -> &Host {
match self {
Self::Start { host }
| Self::Stop { host }
| Self::Deploy { host, .. }
| Self::UpdateImage { host, .. } => host,
}
}
/// Short label for UI lists.
pub const fn label(&self) -> &'static str {
match self {
Self::Start { .. } => "start",
Self::Stop { .. } => "stop",
Self::Deploy { .. } => "deploy infrastructure",
Self::UpdateImage { .. } => "update image",
}
}
}
/// Terminal or in-flight result of a job.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum JobOutcome {
/// The child process is still running.
Running,
/// The child exited zero.
Succeeded,
/// The child exited non-zero, was signalled, or could not be spawned.
Failed {
/// Exit status, absent when the process was signalled or never started.
exit_code: Option<i32>,
/// Operator-facing explanation.
message: String,
},
}
impl JobOutcome {
/// Whether the job has stopped producing output.
pub const fn is_terminal(&self) -> bool {
match self {
Self::Running => false,
Self::Succeeded | Self::Failed { .. } => true,
}
}
}
/// Which pipe a log line came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogStream {
/// Child stdout.
Stdout,
/// Child stderr.
Stderr,
}
/// A single line of child output.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine {
/// Monotonic per-job sequence number, so a reconnecting client can resume.
pub seq: u64,
/// Originating pipe.
pub stream: LogStream,
/// Line contents with ANSI escapes already stripped.
pub text: String,
}
/// Full server-side view of a job.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobView {
/// Job identifier.
pub id: JobId,
/// What the job does.
pub kind: JobKind,
/// Current outcome.
pub outcome: JobOutcome,
/// When the server accepted the job.
pub started_at: Timestamp,
/// When the child exited, if it has.
pub finished_at: Option<Timestamp>,
/// Buffered output produced so far.
pub log: Vec<LogLine>,
}
#[cfg(test)]
mod tests {
use super::*;
fn host() -> Host {
Host::parse("writebook.example.com").unwrap()
}
#[test]
fn job_kind_round_trips_through_json() {
let kind = JobKind::UpdateImage {
host: host(),
image: ImageRef::parse("ghcr.io/basecamp/writebook:1.4.0").unwrap(),
auto_update: Some(false),
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(serde_json::from_str::<JobKind>(&json).unwrap(), kind);
}
#[test]
fn job_kind_deserialization_rejects_a_hostile_host() {
let json = r#"{"kind":"start","host":"--image"}"#;
assert!(serde_json::from_str::<JobKind>(json).is_err());
}
#[test]
fn running_jobs_are_not_terminal() {
assert!(!JobOutcome::Running.is_terminal());
assert!(JobOutcome::Succeeded.is_terminal());
}
}