clawft_kernel/workload_runtime/
cog_spec.rs1use std::collections::{BTreeMap, BTreeSet};
6use std::sync::Arc;
7
8use serde::Deserialize;
9
10use super::types::{Binary, RuntimeError, SignedPayload, VerifiedWorkload, WorkloadSource};
11use crate::workload_pkg::manifest::valid_cog_id;
12use crate::workload_pkg::verify::check_file;
13use crate::workload_pkg::{FileSource, VerifiedPackage};
14
15pub const DEFAULT_MAX_RUNTIME_SECS: u64 = 15;
17pub const MAX_RUNTIME_SECS: u64 = 3600;
19pub const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024;
21pub const MAX_OUTPUT_LIMIT: usize = 16 * 1024 * 1024;
23pub const DEFAULT_RAM_MB: u32 = 256;
25pub const MAX_RAM_MB: u32 = 64 * 1024;
27pub const MAX_ARG_LEN: usize = 128;
29pub const MAX_ARGS: usize = 32;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ConsoleLimits {
35 pub allowed_commands: Vec<String>,
37 pub max_runtime_secs: u64,
39 pub output_limit_bytes: usize,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Resources {
46 pub ram_mb: u32,
48 pub cpu_pct: u32,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct CogSpec {
55 pub id: String,
57 pub version: String,
59 pub console: ConsoleLimits,
61 pub resources: Resources,
63 pub value_args: BTreeSet<String>,
65 pub flag_args: BTreeSet<String>,
67}
68
69#[derive(Deserialize)]
70struct RawToml {
71 cog: RawCog,
72 #[serde(default)]
73 console: Option<RawConsole>,
74 #[serde(default)]
75 resources: Option<RawResources>,
76 #[serde(default)]
77 config: BTreeMap<String, toml::Value>,
78}
79
80#[derive(Deserialize)]
81struct RawCog {
82 id: String,
83 version: String,
84}
85
86#[derive(Deserialize)]
87struct RawConsole {
88 #[serde(default)]
89 allowed_commands: Vec<String>,
90 max_runtime_secs: Option<u64>,
91 output_limit_bytes: Option<usize>,
92}
93
94#[derive(Deserialize)]
95struct RawResources {
96 ram_mb: Option<u32>,
97 cpu_pct: Option<u32>,
98}
99
100fn bad(msg: impl Into<String>) -> RuntimeError {
101 RuntimeError::InvalidConfig(msg.into())
102}
103
104fn valid_flag(s: &str) -> bool {
105 s.len() > 2
106 && s.len() <= 64
107 && s.starts_with("--")
108 && s[2..]
109 .bytes()
110 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
111}
112
113pub fn valid_value(s: &str) -> bool {
115 !s.is_empty()
116 && s.len() <= MAX_ARG_LEN
117 && (!s.starts_with('-') || s.parse::<f64>().is_ok())
118 && s.bytes()
119 .all(|b| b.is_ascii_alphanumeric() || b"._:-+/".contains(&b))
120}
121
122impl CogSpec {
123 pub fn parse(bytes: &[u8]) -> Result<Self, RuntimeError> {
125 let text = std::str::from_utf8(bytes).map_err(|_| bad("cog.toml is not UTF-8"))?;
126 let raw: RawToml = toml::from_str(text).map_err(|e| bad(format!("cog.toml: {e}")))?;
127 if !valid_cog_id(&raw.cog.id) {
128 return Err(bad(format!("bad cog id {:?}", raw.cog.id)));
129 }
130 let console = raw.console.unwrap_or(RawConsole {
131 allowed_commands: Vec::new(),
132 max_runtime_secs: None,
133 output_limit_bytes: None,
134 });
135 let max_runtime_secs = console.max_runtime_secs.unwrap_or(DEFAULT_MAX_RUNTIME_SECS);
136 if !(1..=MAX_RUNTIME_SECS).contains(&max_runtime_secs) {
137 return Err(bad(format!(
138 "max_runtime_secs must be 1..={MAX_RUNTIME_SECS}"
139 )));
140 }
141 let output_limit_bytes = console.output_limit_bytes.unwrap_or(DEFAULT_OUTPUT_LIMIT);
142 if !(1..=MAX_OUTPUT_LIMIT).contains(&output_limit_bytes) {
143 return Err(bad(format!(
144 "output_limit_bytes must be 1..={MAX_OUTPUT_LIMIT}"
145 )));
146 }
147 let res = raw.resources.unwrap_or(RawResources {
148 ram_mb: None,
149 cpu_pct: None,
150 });
151 let ram_mb = res.ram_mb.unwrap_or(DEFAULT_RAM_MB);
152 let cpu_pct = res.cpu_pct.unwrap_or(100);
153 if !(1..=MAX_RAM_MB).contains(&ram_mb) || !(1..=400).contains(&cpu_pct) {
154 return Err(bad(
155 "resources out of range (ram_mb 1..=65536, cpu_pct 1..=400)",
156 ));
157 }
158 let (mut value_args, mut flag_args) = (BTreeSet::new(), BTreeSet::new());
159 for (key, v) in &raw.config {
160 let Some(arg) = v.get("cli_arg").and_then(|a| a.as_str()) else {
161 continue;
162 };
163 if !valid_flag(arg) {
164 return Err(bad(format!("config.{key}: bad cli_arg {arg:?}")));
165 }
166 if v.get("type").and_then(|t| t.as_str()) == Some("boolean") {
167 flag_args.insert(arg.to_string());
168 } else {
169 value_args.insert(arg.to_string());
170 }
171 }
172 let spec = Self {
173 id: raw.cog.id,
174 version: raw.cog.version,
175 console: ConsoleLimits {
176 allowed_commands: console.allowed_commands,
177 max_runtime_secs,
178 output_limit_bytes,
179 },
180 resources: Resources { ram_mb, cpu_pct },
181 value_args,
182 flag_args,
183 };
184 for c in &spec.console.allowed_commands {
185 spec.split_command(c)?;
186 }
187 Ok(spec)
188 }
189
190 pub fn validate_args(&self, args: &[String]) -> Result<(), RuntimeError> {
192 if args.len() > MAX_ARGS {
193 return Err(bad(format!("more than {MAX_ARGS} arguments")));
194 }
195 let mut it = args.iter();
196 while let Some(a) = it.next() {
197 if self.flag_args.contains(a) {
198 continue;
199 }
200 if a == "--once" || a == "--interval" {
201 return Err(bad(format!("{a} is set by the run mode, not by args")));
202 }
203 if !self.value_args.contains(a) {
204 return Err(bad(format!(
205 "argument {a:?} is not a config cli_arg of this cog"
206 )));
207 }
208 match it.next() {
209 Some(v) if valid_value(v) => {}
210 _ => return Err(bad(format!("{a} needs a plain value"))),
211 }
212 }
213 Ok(())
214 }
215
216 pub fn console_args(&self, command: &str) -> Result<Vec<String>, RuntimeError> {
219 if !self.console.allowed_commands.iter().any(|c| c == command) {
220 return Err(bad(format!(
221 "console command {command:?} is not in [console].allowed_commands"
222 )));
223 }
224 self.split_command(command)
225 }
226
227 fn split_command(&self, command: &str) -> Result<Vec<String>, RuntimeError> {
228 let parts: Vec<String> = command.split_whitespace().map(str::to_string).collect();
229 if parts.len() > MAX_ARGS
230 || parts
231 .iter()
232 .any(|p| p.len() > MAX_ARG_LEN || p.bytes().any(|b| b.is_ascii_control()))
233 {
234 return Err(bad(format!("unsafe console command {command:?}")));
235 }
236 Ok(parts)
237 }
238}
239
240impl VerifiedWorkload {
241 pub fn from_package(
246 pkg: &VerifiedPackage,
247 source: &dyn FileSource,
248 ) -> Result<Self, RuntimeError> {
249 let body = &pkg.body;
250 let read = |f| {
251 let bytes = source
252 .read(f)
253 .map_err(|e| RuntimeError::AdmissionRefused(e.to_string()))?;
254 check_file(f, &bytes).map_err(|e| RuntimeError::AdmissionRefused(e.to_string()))?;
255 Ok::<_, RuntimeError>(bytes)
256 };
257 let spec = CogSpec::parse(&read(&body.cog_toml)?)?;
258 if spec.id != body.id {
259 return Err(RuntimeError::AdmissionRefused(format!(
260 "cog.toml id {:?} does not match package id {:?}",
261 spec.id, body.id
262 )));
263 }
264 let mut binaries = BTreeMap::new();
265 for (arch, f) in &body.binaries {
266 binaries.insert(
267 arch.clone(),
268 Binary {
269 blake3: f.blake3.clone(),
270 bytes: Arc::new(read(f)?),
271 },
272 );
273 }
274 let signer_keys = pkg
275 .envelope
276 .signatures
277 .iter()
278 .filter(|s| pkg.signers.iter().any(|a| a.key_id == s.key_id))
279 .map(|s| s.public_key.clone())
280 .collect();
281 let mut artifact_hashes: Vec<String> = body.files().map(|f| f.blake3.clone()).collect();
282 artifact_hashes.sort();
283 artifact_hashes.dedup();
284 Ok(Self {
285 kind: pkg.envelope.kind.clone(),
286 id: body.id.clone(),
287 version: body.version.clone(),
288 source: WorkloadSource::SignedPackage(SignedPayload {
289 package_id: pkg.package_id.clone(),
290 signer_keys,
291 spec,
292 binaries,
293 artifact_hashes,
294 }),
295 })
296 }
297
298 pub fn store_pin(
301 registry: &str,
302 id: &str,
303 version: &str,
304 sha256: Option<&str>,
305 ) -> Result<Self, RuntimeError> {
306 if !valid_cog_id(id) || !valid_value(version) || !valid_cog_id(registry) {
307 return Err(bad("store pin needs a valid registry, cog id and version"));
308 }
309 if let Some(h) = sha256
310 && !crate::workload_pkg::codec::is_lower_hex(h, 64)
311 {
312 return Err(bad("store pin sha256 must be 64 lower-case hex"));
313 }
314 Ok(Self {
315 kind: crate::workload_pkg::KIND_COG.to_string(),
316 id: id.to_string(),
317 version: version.to_string(),
318 source: WorkloadSource::StorePin {
319 registry: registry.to_string(),
320 sha256: sha256.map(str::to_string),
321 },
322 })
323 }
324
325 pub fn revocation_refs(&self) -> (String, Vec<String>, Vec<String>) {
330 match &self.source {
331 WorkloadSource::SignedPackage(p) => (
332 p.package_id.clone(),
333 p.signer_keys.clone(),
334 p.artifact_hashes.clone(),
335 ),
336 WorkloadSource::StorePin { .. } => (
337 format!("store.{}.{}", self.id, self.version),
338 Vec::new(),
339 Vec::new(),
340 ),
341 WorkloadSource::Project(p) => (
342 format!("project.{}.{}", p.project_id, p.cert_serial),
343 Vec::new(),
344 Vec::new(),
345 ),
346 WorkloadSource::Inference(_) => (
347 format!("inference.{}.{}", self.id, self.version),
348 Vec::new(),
349 Vec::new(),
350 ),
351 }
352 }
353
354 pub fn signed(&self, runtime: &str) -> Result<&SignedPayload, RuntimeError> {
356 match &self.source {
357 WorkloadSource::SignedPackage(p) => Ok(p),
358 WorkloadSource::StorePin { .. } => Err(RuntimeError::AdmissionRefused(format!(
359 "{runtime} runs only signed packages; store pins go through the Seed adapter"
360 ))),
361 WorkloadSource::Project(_) => Err(RuntimeError::AdmissionRefused(format!(
362 "{runtime} runs only signed packages; project workloads are not supported yet"
363 ))),
364 WorkloadSource::Inference(_) => Err(RuntimeError::AdmissionRefused(format!(
365 "{runtime} runs only signed packages; inference workloads go through the infer.* adapters"
366 ))),
367 }
368 }
369}