1use super::{
2 ArtifactRef, IsolationLevel, ResourceLimits, RuntimeNetworkSpec, RuntimeProcessSpec,
3 SecretReference,
4};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::BTreeSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RuntimeUnitClass {
12 Task,
13 Service,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum MountKind {
19 Artifact,
20 Volume,
21 Tmpfs,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
26pub enum RuntimeMountSource {
27 Artifact { artifact: ArtifactRef },
28 Volume { volume_id: String },
29 Tmpfs { size_bytes: u64 },
30}
31
32impl RuntimeMountSource {
33 pub fn kind(&self) -> MountKind {
34 match self {
35 Self::Artifact { .. } => MountKind::Artifact,
36 Self::Volume { .. } => MountKind::Volume,
37 Self::Tmpfs { .. } => MountKind::Tmpfs,
38 }
39 }
40
41 fn validate(&self) -> Result<(), String> {
42 match self {
43 Self::Artifact { artifact } => artifact.validate(),
44 Self::Volume { volume_id } => super::validate_id("volume_id", volume_id, 255),
45 Self::Tmpfs { size_bytes } if *size_bytes == 0 => {
46 Err("tmpfs size_bytes must be positive".into())
47 }
48 Self::Tmpfs { .. } => Ok(()),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct RuntimeMount {
56 pub name: String,
57 pub source: RuntimeMountSource,
58 pub target: String,
59 pub read_only: bool,
60}
61
62impl RuntimeMount {
63 fn validate(&self) -> Result<(), String> {
64 super::validate_name("mount name", &self.name)?;
65 super::validate_absolute_path("mount target", &self.target)?;
66 self.source.validate()
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum HealthCheckKind {
73 Http,
74 Tcp,
75 Command,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
80pub enum HealthProbe {
81 Http {
82 port: String,
83 path: String,
84 expected_statuses: Vec<u16>,
85 },
86 Tcp {
87 port: String,
88 },
89 Command {
90 command: Vec<String>,
91 },
92}
93
94impl HealthProbe {
95 pub fn kind(&self) -> HealthCheckKind {
96 match self {
97 Self::Http { .. } => HealthCheckKind::Http,
98 Self::Tcp { .. } => HealthCheckKind::Tcp,
99 Self::Command { .. } => HealthCheckKind::Command,
100 }
101 }
102
103 fn validate(&self, network: &RuntimeNetworkSpec) -> Result<(), String> {
104 match self {
105 Self::Http {
106 port,
107 path,
108 expected_statuses,
109 } => {
110 super::validate_name("health port", port)?;
111 if !network.has_port(port) {
112 return Err(format!(
113 "HTTP health check references unknown port {port:?}"
114 ));
115 }
116 if !path.starts_with('/') || path.len() > 2048 || path.contains(['\0', '\r', '\n'])
117 {
118 return Err("HTTP health path must be a bounded absolute request path".into());
119 }
120 if expected_statuses.is_empty()
121 || expected_statuses.len() > 32
122 || expected_statuses
123 .iter()
124 .any(|status| !(100..=599).contains(status))
125 {
126 return Err("HTTP health expected_statuses are invalid".into());
127 }
128 Ok(())
129 }
130 Self::Tcp { port } => {
131 super::validate_name("health port", port)?;
132 if !network.has_port(port) {
133 return Err(format!("TCP health check references unknown port {port:?}"));
134 }
135 Ok(())
136 }
137 Self::Command { command } => {
138 if command.is_empty() || command.len() > 64 {
139 return Err("command health check requires 1 to 64 arguments".into());
140 }
141 for value in command {
142 if value.is_empty() || value.len() > 32 * 1024 || value.contains('\0') {
143 return Err("command health check contains an invalid argument".into());
144 }
145 }
146 Ok(())
147 }
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct RuntimeHealthCheck {
155 pub probe: HealthProbe,
156 pub interval_ms: u64,
157 pub timeout_ms: u64,
158 pub start_period_ms: u64,
159 pub success_threshold: u32,
160 pub failure_threshold: u32,
161}
162
163impl RuntimeHealthCheck {
164 fn validate(&self, network: &RuntimeNetworkSpec) -> Result<(), String> {
165 if self.interval_ms == 0
166 || self.timeout_ms == 0
167 || self.timeout_ms > self.interval_ms
168 || self.success_threshold == 0
169 || self.failure_threshold == 0
170 {
171 return Err("health timing and threshold values are invalid".into());
172 }
173 self.probe.validate(network)
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct RuntimeServiceLifecycle {
184 pub liveness: RuntimeHealthCheck,
185 pub shutdown_grace_seconds: u32,
186}
187
188impl RuntimeServiceLifecycle {
189 fn validate(&self, network: &RuntimeNetworkSpec) -> Result<(), String> {
190 self.liveness.validate(network)?;
191 if !(1..=3_600).contains(&self.shutdown_grace_seconds) {
192 return Err("Service shutdown_grace_seconds must be between 1 and 3600".into());
193 }
194 Ok(())
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
200pub enum RestartPolicy {
201 Never,
202 OnFailure { max_retries: u32 },
203 Always,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct RuntimeOutputSpec {
209 pub name: String,
210 pub path: String,
211 pub media_type: String,
212 pub max_bytes: u64,
213}
214
215impl RuntimeOutputSpec {
216 fn validate(&self) -> Result<(), String> {
217 super::validate_name("output name", &self.name)?;
218 super::validate_absolute_path("output path", &self.path)?;
219 super::validate_nonempty("output media_type", &self.media_type, 255)?;
220 if self.max_bytes == 0 {
221 return Err("output max_bytes must be positive".into());
222 }
223 Ok(())
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct RuntimeUnitSpec {
232 pub schema: String,
233 pub unit_id: String,
234 pub generation: u64,
235 pub class: RuntimeUnitClass,
236 pub artifact: ArtifactRef,
237 pub process: RuntimeProcessSpec,
238 pub mounts: Vec<RuntimeMount>,
239 pub secrets: Vec<SecretReference>,
240 pub network: RuntimeNetworkSpec,
241 pub resources: ResourceLimits,
242 pub isolation: IsolationLevel,
243 pub health: Option<RuntimeHealthCheck>,
246 pub service_lifecycle: Option<RuntimeServiceLifecycle>,
247 pub restart: RestartPolicy,
248 pub outputs: Vec<RuntimeOutputSpec>,
249 pub semantics_profile_digest: Option<String>,
250 pub identity_attachment_digest: Option<String>,
251}
252
253impl RuntimeUnitSpec {
254 pub const SCHEMA: &'static str = "a3s.runtime.unit-spec.v4";
255
256 pub fn validate(&self) -> Result<(), String> {
257 if self.schema != Self::SCHEMA {
258 return Err(format!("unsupported Runtime unit schema {:?}", self.schema));
259 }
260 super::validate_id("unit_id", &self.unit_id, 512)?;
261 if self.generation == 0 {
262 return Err("Runtime unit generation must be positive".into());
263 }
264 self.artifact.validate()?;
265 self.process.validate()?;
266 self.network.validate()?;
267 self.resources.validate()?;
268 if self.mounts.len() > 128 || self.secrets.len() > 128 || self.outputs.len() > 128 {
269 return Err("Runtime unit input or output count exceeds protocol limits".into());
270 }
271
272 let mut mount_names = BTreeSet::new();
273 let mut mount_targets = BTreeSet::new();
274 for mount in &self.mounts {
275 mount.validate()?;
276 if !mount_names.insert(&mount.name) || !mount_targets.insert(&mount.target) {
277 return Err("Runtime mount names and targets must be unique".into());
278 }
279 }
280
281 let mut secret_names = BTreeSet::new();
282 let mut secret_targets = BTreeSet::new();
283 for secret in &self.secrets {
284 secret.validate()?;
285 let target = serde_json::to_string(&secret.target)
286 .map_err(|error| format!("could not encode secret target: {error}"))?;
287 if !secret_names.insert(&secret.name) || !secret_targets.insert(target) {
288 return Err("Runtime secret names and targets must be unique".into());
289 }
290 }
291
292 let mut output_names = BTreeSet::new();
293 let mut output_paths = BTreeSet::new();
294 for output in &self.outputs {
295 output.validate()?;
296 if !output_names.insert(&output.name) || !output_paths.insert(&output.path) {
297 return Err("Runtime output names and paths must be unique".into());
298 }
299 }
300
301 if let Some(digest) = &self.semantics_profile_digest {
302 super::validate_digest(digest)?;
303 }
304 if let Some(digest) = &self.identity_attachment_digest {
305 super::validate_digest(digest)?;
306 }
307
308 match self.class {
309 RuntimeUnitClass::Task => {
310 if self.resources.execution_timeout_ms.is_none() {
311 return Err("Task requires execution_timeout_ms".into());
312 }
313 if self.health.is_some()
314 || self.service_lifecycle.is_some()
315 || matches!(self.restart, RestartPolicy::Always)
316 {
317 return Err(
318 "Task cannot use Service health, lifecycle, or an always restart policy"
319 .into(),
320 );
321 }
322 }
323 RuntimeUnitClass::Service => {
324 if self.resources.execution_timeout_ms.is_some() || !self.outputs.is_empty() {
325 return Err("Service cannot use an execution timeout or Task outputs".into());
326 }
327 if let Some(health) = &self.health {
328 health.validate(&self.network)?;
329 }
330 if let Some(lifecycle) = &self.service_lifecycle {
331 if self.health.is_none() {
332 return Err(
333 "Service lifecycle requires a separate readiness health policy".into(),
334 );
335 }
336 lifecycle.validate(&self.network)?;
337 }
338 }
339 }
340 Ok(())
341 }
342
343 pub fn digest(&self) -> Result<String, String> {
344 self.validate()?;
345 let bytes = serde_json::to_vec(self)
346 .map_err(|error| format!("could not encode Runtime unit spec: {error}"))?;
347 Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use std::collections::BTreeMap;
355
356 fn artifact() -> ArtifactRef {
357 ArtifactRef {
358 uri: format!("oci://registry.example/a3s/demo@sha256:{}", "a".repeat(64)),
359 digest: format!("sha256:{}", "a".repeat(64)),
360 media_type: "application/vnd.oci.image.manifest.v1+json".into(),
361 }
362 }
363
364 fn resources(timeout: Option<u64>) -> ResourceLimits {
365 ResourceLimits {
366 cpu_millis: 500,
367 memory_bytes: 128 * 1024 * 1024,
368 pids: 128,
369 ephemeral_storage_bytes: Some(1024 * 1024 * 1024),
370 execution_timeout_ms: timeout,
371 }
372 }
373
374 fn task() -> RuntimeUnitSpec {
375 RuntimeUnitSpec {
376 schema: RuntimeUnitSpec::SCHEMA.into(),
377 unit_id: "build-1".into(),
378 generation: 1,
379 class: RuntimeUnitClass::Task,
380 artifact: artifact(),
381 process: RuntimeProcessSpec {
382 command: vec!["/bin/build".into()],
383 args: vec![],
384 working_directory: Some("/workspace".into()),
385 environment: BTreeMap::new(),
386 },
387 mounts: vec![],
388 secrets: vec![],
389 network: RuntimeNetworkSpec {
390 mode: super::super::NetworkMode::Outbound,
391 ports: vec![],
392 },
393 resources: resources(Some(60_000)),
394 isolation: IsolationLevel::Container,
395 health: None,
396 service_lifecycle: None,
397 restart: RestartPolicy::OnFailure { max_retries: 1 },
398 outputs: vec![RuntimeOutputSpec {
399 name: "image".into(),
400 path: "/outputs/image.json".into(),
401 media_type: "application/json".into(),
402 max_bytes: 1024,
403 }],
404 semantics_profile_digest: None,
405 identity_attachment_digest: None,
406 }
407 }
408
409 fn service() -> RuntimeUnitSpec {
410 let mut spec = task();
411 spec.unit_id = "service-1".into();
412 spec.class = RuntimeUnitClass::Service;
413 spec.resources = resources(None);
414 spec.outputs.clear();
415 spec.restart = RestartPolicy::Always;
416 spec.network = RuntimeNetworkSpec {
417 mode: super::super::NetworkMode::Service,
418 ports: vec![super::super::RuntimePort {
419 name: "http".into(),
420 container_port: 8080,
421 protocol: super::super::TransportProtocol::Tcp,
422 }],
423 };
424 spec.health = Some(RuntimeHealthCheck {
425 probe: HealthProbe::Http {
426 port: "http".into(),
427 path: "/health".into(),
428 expected_statuses: vec![200],
429 },
430 interval_ms: 5_000,
431 timeout_ms: 1_000,
432 start_period_ms: 10_000,
433 success_threshold: 1,
434 failure_threshold: 3,
435 });
436 spec
437 }
438
439 #[test]
440 fn task_and_service_specs_are_general_and_digest_stable() {
441 let task = task();
442 let service = service();
443 task.validate().unwrap();
444 service.validate().unwrap();
445 assert_eq!(task.digest().unwrap(), task.digest().unwrap());
446 assert_ne!(task.digest().unwrap(), service.digest().unwrap());
447 }
448
449 #[test]
450 fn lifecycle_specific_fields_fail_closed() {
451 let mut task = task();
452 task.resources.execution_timeout_ms = None;
453 assert!(task.validate().is_err());
454
455 let mut service = service();
456 service.outputs.push(RuntimeOutputSpec {
457 name: "invalid".into(),
458 path: "/output".into(),
459 media_type: "text/plain".into(),
460 max_bytes: 1,
461 });
462 assert!(service.validate().is_err());
463 }
464
465 #[test]
466 fn health_checks_reference_declared_ports() {
467 let mut service = service();
468 let health = service.health.as_mut().unwrap();
469 health.probe = HealthProbe::Tcp {
470 port: "missing".into(),
471 };
472 assert!(service.validate().is_err());
473 }
474
475 #[test]
476 fn service_lifecycle_is_service_only_readiness_bound_and_bounded() {
477 let mut service = service();
478 service.service_lifecycle = Some(RuntimeServiceLifecycle {
479 liveness: RuntimeHealthCheck {
480 probe: HealthProbe::Http {
481 port: "http".into(),
482 path: "/health/live".into(),
483 expected_statuses: vec![200],
484 },
485 interval_ms: 5_000,
486 timeout_ms: 1_000,
487 start_period_ms: 10_000,
488 success_threshold: 1,
489 failure_threshold: 3,
490 },
491 shutdown_grace_seconds: 30,
492 });
493 service.validate().expect("Service lifecycle");
494
495 service.health = None;
496 assert!(service.validate().is_err());
497 service.health = Some(RuntimeHealthCheck {
498 probe: HealthProbe::Tcp {
499 port: "http".into(),
500 },
501 interval_ms: 5_000,
502 timeout_ms: 1_000,
503 start_period_ms: 0,
504 success_threshold: 1,
505 failure_threshold: 3,
506 });
507 service
508 .service_lifecycle
509 .as_mut()
510 .expect("lifecycle")
511 .shutdown_grace_seconds = 0;
512 assert!(service.validate().is_err());
513 service
514 .service_lifecycle
515 .as_mut()
516 .expect("lifecycle")
517 .shutdown_grace_seconds = 1;
518 service.validate().expect("minimum shutdown grace");
519 service
520 .service_lifecycle
521 .as_mut()
522 .expect("lifecycle")
523 .shutdown_grace_seconds = 3_600;
524 service.validate().expect("maximum shutdown grace");
525 service
526 .service_lifecycle
527 .as_mut()
528 .expect("lifecycle")
529 .shutdown_grace_seconds = 3_601;
530 assert!(service.validate().is_err());
531
532 let mut task = task();
533 task.service_lifecycle = service.service_lifecycle;
534 assert!(task.validate().is_err());
535 }
536
537 #[test]
538 fn registry_credential_is_a_typed_unique_secret_target() {
539 let mut service = service();
540 service.secrets.push(SecretReference {
541 name: "registry".into(),
542 reference: "secret://registry/7".into(),
543 target: super::super::SecretTarget::RegistryCredential,
544 });
545 service.validate().expect("registry credential");
546 assert_eq!(
547 serde_json::to_value(&service.secrets[0].target).expect("target JSON"),
548 serde_json::json!({"kind": "registry_credential"})
549 );
550
551 service.secrets.push(SecretReference {
552 name: "other-registry".into(),
553 reference: "secret://registry/8".into(),
554 target: super::super::SecretTarget::RegistryCredential,
555 });
556 assert!(service.validate().is_err());
557 }
558}