use std::sync::{Arc, Mutex, PoisonError};
use super::{
CallResult, CallValue, CapabilityCall, CapabilityRuntime, DenialReason, PluginRow, check_row,
};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct PluginJob {
pub plugin: String,
pub artifact_sha256: String,
pub job_type: String,
pub tenant: Option<String>,
pub payload: PluginRow,
}
impl PluginJob {
#[must_use]
pub fn is_for_artifact(&self, installed_sha256: &str) -> bool {
!self.artifact_sha256.is_empty() && self.artifact_sha256 == installed_sha256
}
}
pub trait JobSink: Send + Sync + 'static {
fn enqueue(&self, job: PluginJob) -> Result<String, String>;
}
pub(super) fn validate(call: &CapabilityCall) -> Result<(), CallResult> {
match call {
CapabilityCall::JobEnqueue { id, payload, .. } => check_row(payload)
.map_err(|detail| CallResult::denied(*id, DenialReason::Malformed, detail)),
_ => Ok(()),
}
}
pub(super) fn perform(
runtime: &CapabilityRuntime,
call: &CapabilityCall,
job_type: &str,
) -> CallResult {
let id = call.id();
let CapabilityCall::JobEnqueue { payload, .. } = call else {
return CallResult::denied(id, DenialReason::Malformed, "not a job call");
};
let Some(sink) = runtime.services.jobs.clone() else {
return CallResult::denied(
id,
DenialReason::Unavailable,
"this host has no job queue wired for sandboxed plugins",
);
};
if let Err(detail) = check_row(payload) {
return CallResult::denied(id, DenialReason::Malformed, detail);
}
let job = PluginJob {
plugin: runtime.plugin.clone(),
artifact_sha256: runtime.artifact_sha256.clone(),
job_type: job_type.to_owned(),
tenant: runtime.tenant().map(str::to_owned),
payload: payload.clone(),
};
match sink.enqueue(job) {
Ok(job_id) if job_id.is_empty() || job_id.len() > super::MAX_JOB_ID_BYTES => {
CallResult::denied(
id,
DenialReason::ResultRejected,
format!(
"the job was queued, but the queue assigned an id outside 1..={} bytes; do \
not retry",
super::MAX_JOB_ID_BYTES
),
)
}
Ok(job_id) => CallResult::Ok {
id,
value: CallValue::JobId { job_id },
},
Err(detail) => CallResult::denied(id, DenialReason::BackendError, detail),
}
}
#[derive(Debug)]
pub struct MemoryJobSink {
queued: Mutex<QueuedJobs>,
depth: usize,
byte_capacity: usize,
}
#[derive(Debug, Default)]
struct QueuedJobs {
jobs: Vec<PluginJob>,
bytes: usize,
}
pub const DEFAULT_JOB_DEPTH: usize = 1024;
pub const DEFAULT_JOB_BYTE_CAPACITY: usize = 16 * 1024 * 1024;
impl Default for MemoryJobSink {
fn default() -> Self {
Self {
queued: Mutex::new(QueuedJobs::default()),
depth: DEFAULT_JOB_DEPTH,
byte_capacity: DEFAULT_JOB_BYTE_CAPACITY,
}
}
}
impl MemoryJobSink {
#[must_use]
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
#[must_use]
pub fn bounded(depth: usize) -> Arc<Self> {
Arc::new(Self {
queued: Mutex::new(QueuedJobs::default()),
depth,
byte_capacity: DEFAULT_JOB_BYTE_CAPACITY,
})
}
fn job_weight(job: &PluginJob) -> usize {
const PER_ENTRY: usize = 96;
job.plugin
.len()
.saturating_add(job.job_type.len())
.saturating_add(job.tenant.as_ref().map_or(0, String::len))
.saturating_add(super::row_weight(&job.payload))
.saturating_add(PER_ENTRY)
}
#[must_use]
pub fn with_capacities(depth: usize, byte_capacity: usize) -> Arc<Self> {
Arc::new(Self {
queued: Mutex::new(QueuedJobs::default()),
depth,
byte_capacity,
})
}
#[must_use]
pub fn queued(&self) -> Vec<PluginJob> {
self.queued
.lock()
.unwrap_or_else(PoisonError::into_inner)
.jobs
.clone()
}
#[must_use]
pub fn queued_bytes(&self) -> usize {
self.queued
.lock()
.unwrap_or_else(PoisonError::into_inner)
.bytes
}
}
impl JobSink for MemoryJobSink {
#[allow(
clippy::significant_drop_tightening,
reason = "the body is one critical section over the shared map; releasing early would \
either split a ceiling check from the write it guards or let the snapshot \
this returns be torn"
)]
fn enqueue(&self, job: PluginJob) -> Result<String, String> {
let mut queued = self.queued.lock().unwrap_or_else(PoisonError::into_inner);
if queued.jobs.len() >= self.depth {
return Err(format!(
"the plugin job queue is at its depth ceiling of {}",
self.depth
));
}
let incoming = Self::job_weight(&job);
if queued.bytes.saturating_add(incoming) > self.byte_capacity {
return Err(format!(
"the plugin job queue is at its {}-byte ceiling",
self.byte_capacity
));
}
queued.bytes = queued.bytes.saturating_add(incoming);
queued.jobs.push(job);
Ok(format!("j{}", queued.jobs.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job(sha: &str) -> PluginJob {
PluginJob {
plugin: "shop".to_owned(),
artifact_sha256: sha.to_owned(),
job_type: "send".to_owned(),
tenant: None,
payload: PluginRow::default(),
}
}
#[test]
fn a_job_runs_only_under_the_artifact_that_queued_it() {
let queued = job("aaaa");
assert!(queued.is_for_artifact("aaaa"));
assert!(!queued.is_for_artifact("bbbb"), "an upgraded artifact");
assert!(!queued.is_for_artifact(""), "no installed digest");
assert!(!job("").is_for_artifact(""), "an unsealed job never runs");
}
}