use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde::Deserialize;
use super::types::{Binary, RuntimeError, SignedPayload, VerifiedWorkload, WorkloadSource};
use crate::workload_pkg::manifest::valid_cog_id;
use crate::workload_pkg::verify::check_file;
use crate::workload_pkg::{FileSource, VerifiedPackage};
pub const DEFAULT_MAX_RUNTIME_SECS: u64 = 15;
pub const MAX_RUNTIME_SECS: u64 = 3600;
pub const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024;
pub const MAX_OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
pub const DEFAULT_RAM_MB: u32 = 256;
pub const MAX_RAM_MB: u32 = 64 * 1024;
pub const MAX_ARG_LEN: usize = 128;
pub const MAX_ARGS: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsoleLimits {
pub allowed_commands: Vec<String>,
pub max_runtime_secs: u64,
pub output_limit_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources {
pub ram_mb: u32,
pub cpu_pct: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CogSpec {
pub id: String,
pub version: String,
pub console: ConsoleLimits,
pub resources: Resources,
pub value_args: BTreeSet<String>,
pub flag_args: BTreeSet<String>,
}
#[derive(Deserialize)]
struct RawToml {
cog: RawCog,
#[serde(default)]
console: Option<RawConsole>,
#[serde(default)]
resources: Option<RawResources>,
#[serde(default)]
config: BTreeMap<String, toml::Value>,
}
#[derive(Deserialize)]
struct RawCog {
id: String,
version: String,
}
#[derive(Deserialize)]
struct RawConsole {
#[serde(default)]
allowed_commands: Vec<String>,
max_runtime_secs: Option<u64>,
output_limit_bytes: Option<usize>,
}
#[derive(Deserialize)]
struct RawResources {
ram_mb: Option<u32>,
cpu_pct: Option<u32>,
}
fn bad(msg: impl Into<String>) -> RuntimeError {
RuntimeError::InvalidConfig(msg.into())
}
fn valid_flag(s: &str) -> bool {
s.len() > 2
&& s.len() <= 64
&& s.starts_with("--")
&& s[2..]
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub fn valid_value(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_ARG_LEN
&& (!s.starts_with('-') || s.parse::<f64>().is_ok())
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._:-+/".contains(&b))
}
impl CogSpec {
pub fn parse(bytes: &[u8]) -> Result<Self, RuntimeError> {
let text = std::str::from_utf8(bytes).map_err(|_| bad("cog.toml is not UTF-8"))?;
let raw: RawToml = toml::from_str(text).map_err(|e| bad(format!("cog.toml: {e}")))?;
if !valid_cog_id(&raw.cog.id) {
return Err(bad(format!("bad cog id {:?}", raw.cog.id)));
}
let console = raw.console.unwrap_or(RawConsole {
allowed_commands: Vec::new(),
max_runtime_secs: None,
output_limit_bytes: None,
});
let max_runtime_secs = console.max_runtime_secs.unwrap_or(DEFAULT_MAX_RUNTIME_SECS);
if !(1..=MAX_RUNTIME_SECS).contains(&max_runtime_secs) {
return Err(bad(format!(
"max_runtime_secs must be 1..={MAX_RUNTIME_SECS}"
)));
}
let output_limit_bytes = console.output_limit_bytes.unwrap_or(DEFAULT_OUTPUT_LIMIT);
if !(1..=MAX_OUTPUT_LIMIT).contains(&output_limit_bytes) {
return Err(bad(format!(
"output_limit_bytes must be 1..={MAX_OUTPUT_LIMIT}"
)));
}
let res = raw.resources.unwrap_or(RawResources {
ram_mb: None,
cpu_pct: None,
});
let ram_mb = res.ram_mb.unwrap_or(DEFAULT_RAM_MB);
let cpu_pct = res.cpu_pct.unwrap_or(100);
if !(1..=MAX_RAM_MB).contains(&ram_mb) || !(1..=400).contains(&cpu_pct) {
return Err(bad(
"resources out of range (ram_mb 1..=65536, cpu_pct 1..=400)",
));
}
let (mut value_args, mut flag_args) = (BTreeSet::new(), BTreeSet::new());
for (key, v) in &raw.config {
let Some(arg) = v.get("cli_arg").and_then(|a| a.as_str()) else {
continue;
};
if !valid_flag(arg) {
return Err(bad(format!("config.{key}: bad cli_arg {arg:?}")));
}
if v.get("type").and_then(|t| t.as_str()) == Some("boolean") {
flag_args.insert(arg.to_string());
} else {
value_args.insert(arg.to_string());
}
}
let spec = Self {
id: raw.cog.id,
version: raw.cog.version,
console: ConsoleLimits {
allowed_commands: console.allowed_commands,
max_runtime_secs,
output_limit_bytes,
},
resources: Resources { ram_mb, cpu_pct },
value_args,
flag_args,
};
for c in &spec.console.allowed_commands {
spec.split_command(c)?;
}
Ok(spec)
}
pub fn validate_args(&self, args: &[String]) -> Result<(), RuntimeError> {
if args.len() > MAX_ARGS {
return Err(bad(format!("more than {MAX_ARGS} arguments")));
}
let mut it = args.iter();
while let Some(a) = it.next() {
if self.flag_args.contains(a) {
continue;
}
if a == "--once" || a == "--interval" {
return Err(bad(format!("{a} is set by the run mode, not by args")));
}
if !self.value_args.contains(a) {
return Err(bad(format!(
"argument {a:?} is not a config cli_arg of this cog"
)));
}
match it.next() {
Some(v) if valid_value(v) => {}
_ => return Err(bad(format!("{a} needs a plain value"))),
}
}
Ok(())
}
pub fn console_args(&self, command: &str) -> Result<Vec<String>, RuntimeError> {
if !self.console.allowed_commands.iter().any(|c| c == command) {
return Err(bad(format!(
"console command {command:?} is not in [console].allowed_commands"
)));
}
self.split_command(command)
}
fn split_command(&self, command: &str) -> Result<Vec<String>, RuntimeError> {
let parts: Vec<String> = command.split_whitespace().map(str::to_string).collect();
if parts.len() > MAX_ARGS
|| parts
.iter()
.any(|p| p.len() > MAX_ARG_LEN || p.bytes().any(|b| b.is_ascii_control()))
{
return Err(bad(format!("unsafe console command {command:?}")));
}
Ok(parts)
}
}
impl VerifiedWorkload {
pub fn from_package(
pkg: &VerifiedPackage,
source: &dyn FileSource,
) -> Result<Self, RuntimeError> {
let body = &pkg.body;
let read = |f| {
let bytes = source
.read(f)
.map_err(|e| RuntimeError::AdmissionRefused(e.to_string()))?;
check_file(f, &bytes).map_err(|e| RuntimeError::AdmissionRefused(e.to_string()))?;
Ok::<_, RuntimeError>(bytes)
};
let spec = CogSpec::parse(&read(&body.cog_toml)?)?;
if spec.id != body.id {
return Err(RuntimeError::AdmissionRefused(format!(
"cog.toml id {:?} does not match package id {:?}",
spec.id, body.id
)));
}
let mut binaries = BTreeMap::new();
for (arch, f) in &body.binaries {
binaries.insert(
arch.clone(),
Binary {
blake3: f.blake3.clone(),
bytes: Arc::new(read(f)?),
},
);
}
let signer_keys = pkg
.envelope
.signatures
.iter()
.filter(|s| pkg.signers.iter().any(|a| a.key_id == s.key_id))
.map(|s| s.public_key.clone())
.collect();
let mut artifact_hashes: Vec<String> = body.files().map(|f| f.blake3.clone()).collect();
artifact_hashes.sort();
artifact_hashes.dedup();
Ok(Self {
kind: pkg.envelope.kind.clone(),
id: body.id.clone(),
version: body.version.clone(),
source: WorkloadSource::SignedPackage(SignedPayload {
package_id: pkg.package_id.clone(),
signer_keys,
spec,
binaries,
artifact_hashes,
}),
})
}
pub fn store_pin(
registry: &str,
id: &str,
version: &str,
sha256: Option<&str>,
) -> Result<Self, RuntimeError> {
if !valid_cog_id(id) || !valid_value(version) || !valid_cog_id(registry) {
return Err(bad("store pin needs a valid registry, cog id and version"));
}
if let Some(h) = sha256
&& !crate::workload_pkg::codec::is_lower_hex(h, 64)
{
return Err(bad("store pin sha256 must be 64 lower-case hex"));
}
Ok(Self {
kind: crate::workload_pkg::KIND_COG.to_string(),
id: id.to_string(),
version: version.to_string(),
source: WorkloadSource::StorePin {
registry: registry.to_string(),
sha256: sha256.map(str::to_string),
},
})
}
pub fn revocation_refs(&self) -> (String, Vec<String>, Vec<String>) {
match &self.source {
WorkloadSource::SignedPackage(p) => (
p.package_id.clone(),
p.signer_keys.clone(),
p.artifact_hashes.clone(),
),
WorkloadSource::StorePin { .. } => (
format!("store.{}.{}", self.id, self.version),
Vec::new(),
Vec::new(),
),
WorkloadSource::Project(p) => (
format!("project.{}.{}", p.project_id, p.cert_serial),
Vec::new(),
Vec::new(),
),
WorkloadSource::Inference(_) => (
format!("inference.{}.{}", self.id, self.version),
Vec::new(),
Vec::new(),
),
}
}
pub fn signed(&self, runtime: &str) -> Result<&SignedPayload, RuntimeError> {
match &self.source {
WorkloadSource::SignedPackage(p) => Ok(p),
WorkloadSource::StorePin { .. } => Err(RuntimeError::AdmissionRefused(format!(
"{runtime} runs only signed packages; store pins go through the Seed adapter"
))),
WorkloadSource::Project(_) => Err(RuntimeError::AdmissionRefused(format!(
"{runtime} runs only signed packages; project workloads are not supported yet"
))),
WorkloadSource::Inference(_) => Err(RuntimeError::AdmissionRefused(format!(
"{runtime} runs only signed packages; inference workloads go through the infer.* adapters"
))),
}
}
}