1use std::{collections::HashMap, fs, path::Path};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{DeployError, DeployResult};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "camelCase")]
9pub struct DeploymentManifest {
10 pub agent: AgentConfig,
11 #[serde(default)]
12 pub build: BuildConfig,
13 #[serde(default)]
14 pub scaling: ScalingPolicy,
15 #[serde(default)]
16 pub health: HealthCheckConfig,
17 #[serde(default)]
18 pub strategy: DeploymentStrategyConfig,
19 #[serde(default)]
20 pub services: Vec<ServiceBinding>,
21 #[serde(default)]
22 pub secrets: Vec<SecretRef>,
23 #[serde(default)]
24 pub env: HashMap<String, EnvVarSpec>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub telemetry: Option<TelemetryConfig>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub auth: Option<AgentAuthConfig>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub guardrails: Option<GuardrailConfig>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub realtime: Option<RealtimeConfig>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub a2a: Option<A2aConfig>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub graph: Option<GraphConfig>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub plugins: Vec<PluginRef>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub skills: Option<SkillConfig>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub interaction: Option<InteractionConfig>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub source: Option<SourceInfo>,
45}
46
47impl DeploymentManifest {
48 pub fn from_path(path: &Path) -> DeployResult<Self> {
60 if !path.exists() {
61 return Err(DeployError::ManifestNotFound { path: path.to_path_buf() });
62 }
63 let raw = fs::read_to_string(path)?;
64 let manifest = toml::from_str::<DeploymentManifest>(&raw)
65 .map_err(|error| DeployError::ManifestParse { message: error.to_string() })?;
66 manifest.validate()?;
67 Ok(manifest)
68 }
69
70 pub fn to_toml_string(&self) -> DeployResult<String> {
85 self.validate()?;
86 toml::to_string_pretty(self)
87 .map_err(|error| DeployError::ManifestParse { message: error.to_string() })
88 }
89
90 pub fn validate(&self) -> DeployResult<()> {
92 use std::collections::BTreeSet;
93
94 if self.agent.name.trim().is_empty() {
95 return Err(DeployError::InvalidManifest {
96 message: "agent.name must not be empty".to_string(),
97 });
98 }
99 if self.agent.binary.trim().is_empty() {
100 return Err(DeployError::InvalidManifest {
101 message: "agent.binary must not be empty".to_string(),
102 });
103 }
104 if self.scaling.min_instances > self.scaling.max_instances {
105 return Err(DeployError::InvalidManifest {
106 message:
107 "scaling.min_instances must be less than or equal to scaling.max_instances"
108 .to_string(),
109 });
110 }
111 if self.strategy.kind == DeploymentStrategyKind::Canary {
112 let traffic = self.strategy.traffic_percent.unwrap_or(10);
113 if traffic == 0 || traffic > 100 {
114 return Err(DeployError::InvalidManifest {
115 message:
116 "strategy.traffic_percent must be between 1 and 100 for canary deployments"
117 .to_string(),
118 });
119 }
120 }
121 let mut binding_names = BTreeSet::new();
122 for binding in &self.services {
123 if !binding_names.insert(binding.name.clone()) {
124 return Err(DeployError::InvalidManifest {
125 message: format!("service binding names must be unique: '{}'", binding.name),
126 });
127 }
128 if binding.mode == BindingMode::External
129 && binding.connection_url.is_none()
130 && binding.secret_ref.is_none()
131 {
132 return Err(DeployError::InvalidManifest {
133 message: format!(
134 "external service binding '{}' requires connection_url or secret_ref",
135 binding.name
136 ),
137 });
138 }
139 }
140 let declared_secrets: BTreeSet<&str> =
141 self.secrets.iter().map(|secret| secret.key.as_str()).collect();
142 for (key, value) in &self.env {
143 if let EnvVarSpec::SecretRef { secret_ref } = value
144 && !declared_secrets.contains(secret_ref.as_str())
145 {
146 return Err(DeployError::InvalidManifest {
147 message: format!("env '{key}' references undeclared secret '{secret_ref}'"),
148 });
149 }
150 }
151 if let Some(auth) = &self.auth {
152 auth.validate()?;
153 }
154 if let Some(guardrails) = &self.guardrails {
155 guardrails.validate()?;
156 }
157 if let Some(realtime) = &self.realtime {
158 realtime.validate()?;
159 }
160 if let Some(graph) = &self.graph {
161 graph.validate(&self.services)?;
162 }
163 let mut plugin_names = BTreeSet::new();
164 for plugin in &self.plugins {
165 if plugin.name.trim().is_empty() {
166 return Err(DeployError::InvalidManifest {
167 message: "plugin.name must not be empty".to_string(),
168 });
169 }
170 if !plugin_names.insert(plugin.name.clone()) {
171 return Err(DeployError::InvalidManifest {
172 message: format!("plugin names must be unique: '{}'", plugin.name),
173 });
174 }
175 }
176 if let Some(skills) = &self.skills
177 && skills.directory.trim().is_empty()
178 {
179 return Err(DeployError::InvalidManifest {
180 message: "skills.directory must not be empty".to_string(),
181 });
182 }
183 if let Some(interaction) = &self.interaction {
184 interaction.validate()?;
185 }
186 Ok(())
187 }
188}
189
190impl Default for DeploymentManifest {
191 fn default() -> Self {
192 Self {
193 agent: AgentConfig::new("example-agent", "example-agent"),
194 build: BuildConfig::default(),
195 scaling: ScalingPolicy::default(),
196 health: HealthCheckConfig::default(),
197 strategy: DeploymentStrategyConfig::default(),
198 services: Vec::new(),
199 secrets: Vec::new(),
200 env: HashMap::new(),
201 telemetry: None,
202 auth: None,
203 guardrails: None,
204 realtime: None,
205 a2a: None,
206 graph: None,
207 plugins: Vec::new(),
208 skills: None,
209 interaction: None,
210 source: None,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "camelCase")]
217pub struct AgentConfig {
218 pub name: String,
219 pub binary: String,
220 #[serde(default = "default_version")]
221 pub version: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub description: Option<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub toolchain: Option<String>,
226}
227
228impl AgentConfig {
229 pub fn new(name: impl Into<String>, binary: impl Into<String>) -> Self {
230 Self {
231 name: name.into(),
232 binary: binary.into(),
233 version: default_version(),
234 description: None,
235 toolchain: None,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241#[serde(rename_all = "camelCase")]
242pub struct BuildConfig {
243 #[serde(default = "default_profile")]
244 pub profile: String,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub target: Option<String>,
247 #[serde(default)]
248 pub features: Vec<String>,
249 #[serde(default)]
250 pub system_deps: Vec<String>,
251 #[serde(default)]
252 pub assets: Vec<String>,
253}
254
255impl Default for BuildConfig {
256 fn default() -> Self {
257 Self {
258 profile: default_profile(),
259 target: None,
260 features: Vec::new(),
261 system_deps: Vec::new(),
262 assets: Vec::new(),
263 }
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268#[serde(rename_all = "camelCase")]
269pub struct ScalingPolicy {
270 #[serde(default = "default_min_instances")]
271 pub min_instances: u32,
272 #[serde(default = "default_max_instances")]
273 pub max_instances: u32,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub target_latency_ms: Option<u64>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub target_cpu_percent: Option<u8>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub target_concurrent_requests: Option<u32>,
280}
281
282impl Default for ScalingPolicy {
283 fn default() -> Self {
284 Self {
285 min_instances: default_min_instances(),
286 max_instances: default_max_instances(),
287 target_latency_ms: Some(500),
288 target_cpu_percent: Some(70),
289 target_concurrent_requests: None,
290 }
291 }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct HealthCheckConfig {
297 #[serde(default = "default_health_path")]
298 pub path: String,
299 #[serde(default = "default_health_interval")]
300 pub interval_secs: u64,
301 #[serde(default = "default_health_timeout")]
302 pub timeout_secs: u64,
303 #[serde(default = "default_failure_threshold")]
304 pub failure_threshold: u32,
305}
306
307impl Default for HealthCheckConfig {
308 fn default() -> Self {
309 Self {
310 path: default_health_path(),
311 interval_secs: default_health_interval(),
312 timeout_secs: default_health_timeout(),
313 failure_threshold: default_failure_threshold(),
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319#[serde(rename_all = "camelCase")]
320pub struct DeploymentStrategyConfig {
321 #[serde(rename = "type")]
322 pub kind: DeploymentStrategyKind,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub traffic_percent: Option<u8>,
325}
326
327impl Default for DeploymentStrategyConfig {
328 fn default() -> Self {
329 Self { kind: DeploymentStrategyKind::Rolling, traffic_percent: None }
330 }
331}
332
333#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(rename_all = "kebab-case")]
335pub enum DeploymentStrategyKind {
336 Rolling,
337 BlueGreen,
338 Canary,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
342#[serde(rename_all = "camelCase")]
343pub struct ServiceBinding {
344 pub name: String,
345 pub kind: ServiceKind,
346 #[serde(default)]
347 pub mode: BindingMode,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub connection_url: Option<String>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub secret_ref: Option<String>,
352}
353
354#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
355#[serde(rename_all = "kebab-case")]
356pub enum ServiceKind {
357 InMemory,
358 Postgres,
359 Redis,
360 Sqlite,
361 MongoDb,
362 Neo4j,
363 Firestore,
364 Pgvector,
365 RedisMemory,
366 MongoMemory,
367 Neo4jMemory,
368 ArtifactStorage,
369 McpServer,
370 CheckpointPostgres,
371 CheckpointRedis,
372}
373
374#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
375#[serde(rename_all = "kebab-case")]
376pub enum BindingMode {
377 #[default]
378 Managed,
379 External,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383#[serde(rename_all = "camelCase")]
384pub struct SecretRef {
385 pub key: String,
386 #[serde(default = "default_required")]
387 pub required: bool,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391#[serde(untagged)]
392pub enum EnvVarSpec {
393 Plain(String),
394 SecretRef { secret_ref: String },
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(rename_all = "camelCase")]
399pub struct SourceInfo {
400 pub kind: String,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub project_id: Option<String>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub project_name: Option<String>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409#[serde(rename_all = "camelCase")]
410pub struct InteractionConfig {
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub manual: Option<ManualInteractionConfig>,
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
414 pub triggers: Vec<TriggerInteractionConfig>,
415}
416
417impl InteractionConfig {
418 fn validate(&self) -> DeployResult<()> {
419 if let Some(manual) = &self.manual {
420 manual.validate()?;
421 }
422 for trigger in &self.triggers {
423 trigger.validate()?;
424 }
425 Ok(())
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
431#[serde(rename_all = "camelCase")]
432pub struct ManualInteractionConfig {
433 #[serde(default = "default_manual_input_label")]
434 pub input_label: String,
435 #[serde(default = "default_manual_prompt")]
436 pub default_prompt: String,
437}
438
439impl ManualInteractionConfig {
440 fn validate(&self) -> DeployResult<()> {
441 if self.input_label.trim().is_empty() {
442 return Err(DeployError::InvalidManifest {
443 message: "interaction.manual.input_label must not be empty".to_string(),
444 });
445 }
446 if self.default_prompt.trim().is_empty() {
447 return Err(DeployError::InvalidManifest {
448 message: "interaction.manual.default_prompt must not be empty".to_string(),
449 });
450 }
451 Ok(())
452 }
453}
454
455impl Default for ManualInteractionConfig {
456 fn default() -> Self {
457 Self { input_label: default_manual_input_label(), default_prompt: default_manual_prompt() }
458 }
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(rename_all = "camelCase")]
464pub struct TriggerInteractionConfig {
465 pub id: String,
466 pub name: String,
467 pub kind: TriggerKind,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub description: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub path: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub method: Option<String>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub auth: Option<String>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub default_prompt: Option<String>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub cron: Option<String>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub timezone: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub event_source: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub event_type: Option<String>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub filter: Option<String>,
488}
489
490impl TriggerInteractionConfig {
491 fn validate(&self) -> DeployResult<()> {
492 if self.id.trim().is_empty() {
493 return Err(DeployError::InvalidManifest {
494 message: "interaction.triggers[].id must not be empty".to_string(),
495 });
496 }
497 if self.name.trim().is_empty() {
498 return Err(DeployError::InvalidManifest {
499 message: "interaction.triggers[].name must not be empty".to_string(),
500 });
501 }
502 match self.kind {
503 TriggerKind::Webhook => {
504 if self.path.as_deref().map(str::trim).is_none_or(str::is_empty) {
505 return Err(DeployError::InvalidManifest {
506 message: "interaction.triggers[].path is required for webhook triggers"
507 .to_string(),
508 });
509 }
510 if self.method.as_deref().map(str::trim).is_none_or(str::is_empty) {
511 return Err(DeployError::InvalidManifest {
512 message: "interaction.triggers[].method is required for webhook triggers"
513 .to_string(),
514 });
515 }
516 }
517 TriggerKind::Schedule => {
518 if self.cron.as_deref().map(str::trim).is_none_or(str::is_empty) {
519 return Err(DeployError::InvalidManifest {
520 message: "interaction.triggers[].cron is required for schedule triggers"
521 .to_string(),
522 });
523 }
524 if self.timezone.as_deref().map(str::trim).is_none_or(str::is_empty) {
525 return Err(DeployError::InvalidManifest {
526 message:
527 "interaction.triggers[].timezone is required for schedule triggers"
528 .to_string(),
529 });
530 }
531 }
532 TriggerKind::Event => {
533 if self.event_source.as_deref().map(str::trim).is_none_or(str::is_empty) {
534 return Err(DeployError::InvalidManifest {
535 message:
536 "interaction.triggers[].event_source is required for event triggers"
537 .to_string(),
538 });
539 }
540 if self.event_type.as_deref().map(str::trim).is_none_or(str::is_empty) {
541 return Err(DeployError::InvalidManifest {
542 message: "interaction.triggers[].event_type is required for event triggers"
543 .to_string(),
544 });
545 }
546 }
547 }
548 Ok(())
549 }
550}
551
552#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
554#[serde(rename_all = "kebab-case")]
555pub enum TriggerKind {
556 Webhook,
557 Schedule,
558 Event,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562#[serde(rename_all = "camelCase")]
563pub struct TelemetryConfig {
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub otlp_endpoint: Option<String>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub service_name: Option<String>,
568 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
569 pub resource_attributes: HashMap<String, String>,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
573#[serde(rename_all = "camelCase")]
574pub struct AgentAuthConfig {
575 pub mode: AuthModeSpec,
576 #[serde(default)]
577 pub required_scopes: Vec<String>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub issuer: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub audience: Option<String>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub jwks_uri: Option<String>,
584}
585
586impl AgentAuthConfig {
587 fn validate(&self) -> DeployResult<()> {
588 if self.mode == AuthModeSpec::Disabled && !self.required_scopes.is_empty() {
589 return Err(DeployError::InvalidManifest {
590 message: "auth.required_scopes requires auth.mode != disabled".to_string(),
591 });
592 }
593 if self.mode == AuthModeSpec::Oidc
594 && (self.issuer.is_none() || self.audience.is_none() || self.jwks_uri.is_none())
595 {
596 return Err(DeployError::InvalidManifest {
597 message: "auth.mode = oidc requires auth.issuer, auth.audience, and auth.jwks_uri"
598 .to_string(),
599 });
600 }
601 Ok(())
602 }
603}
604
605#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
606#[serde(rename_all = "kebab-case")]
607pub enum AuthModeSpec {
608 Disabled,
609 Bearer,
610 Oidc,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
614#[serde(rename_all = "camelCase")]
615pub struct GuardrailConfig {
616 #[serde(default)]
617 pub pii_redaction: bool,
618 #[serde(default)]
619 pub content_filters: Vec<String>,
620}
621
622impl GuardrailConfig {
623 fn validate(&self) -> DeployResult<()> {
624 if !self.pii_redaction && self.content_filters.is_empty() {
625 return Err(DeployError::InvalidManifest {
626 message:
627 "guardrails must enable pii_redaction or declare at least one content_filter"
628 .to_string(),
629 });
630 }
631 Ok(())
632 }
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[serde(rename_all = "camelCase")]
637pub struct RealtimeConfig {
638 #[serde(default)]
639 pub features: Vec<String>,
640 #[serde(default)]
641 pub sticky_sessions: bool,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub drain_timeout_secs: Option<u64>,
644}
645
646impl RealtimeConfig {
647 fn validate(&self) -> DeployResult<()> {
648 const ALLOWED: &[&str] = &["openai", "gemini", "vertex-live", "livekit", "openai-webrtc"];
649 for feature in &self.features {
650 if !ALLOWED.iter().any(|candidate| candidate == feature) {
651 return Err(DeployError::InvalidManifest {
652 message: format!(
653 "unsupported realtime feature '{feature}'. valid values: {}",
654 ALLOWED.join(", ")
655 ),
656 });
657 }
658 }
659 Ok(())
660 }
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
664#[serde(rename_all = "camelCase")]
665pub struct A2aConfig {
666 #[serde(default)]
667 pub enabled: bool,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub advertise_url: Option<String>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673#[serde(rename_all = "camelCase")]
674pub struct GraphConfig {
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub checkpoint_binding: Option<String>,
677 #[serde(default)]
678 pub hitl_enabled: bool,
679}
680
681impl GraphConfig {
682 fn validate(&self, services: &[ServiceBinding]) -> DeployResult<()> {
683 if let Some(binding_name) = &self.checkpoint_binding {
684 let binding = services
685 .iter()
686 .find(|binding| binding.name == *binding_name)
687 .ok_or_else(|| DeployError::InvalidManifest {
688 message: format!(
689 "graph.checkpoint_binding references unknown service binding '{binding_name}'"
690 ),
691 })?;
692 if !matches!(
693 binding.kind,
694 ServiceKind::CheckpointPostgres | ServiceKind::CheckpointRedis
695 ) {
696 return Err(DeployError::InvalidManifest {
697 message: format!(
698 "graph.checkpoint_binding '{}' must reference checkpoint-postgres or checkpoint-redis",
699 binding_name
700 ),
701 });
702 }
703 } else if self.hitl_enabled {
704 return Err(DeployError::InvalidManifest {
705 message:
706 "graph.hitl_enabled requires graph.checkpoint_binding for resumable workflows"
707 .to_string(),
708 });
709 }
710 Ok(())
711 }
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
715#[serde(rename_all = "camelCase")]
716pub struct PluginRef {
717 pub name: String,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub path: Option<String>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
723#[serde(rename_all = "camelCase")]
724pub struct SkillConfig {
725 pub directory: String,
726 #[serde(default)]
727 pub hot_reload: bool,
728}
729
730fn default_version() -> String {
731 "0.1.0".to_string()
732}
733
734fn default_profile() -> String {
735 "release".to_string()
736}
737
738fn default_min_instances() -> u32 {
739 1
740}
741
742fn default_max_instances() -> u32 {
743 10
744}
745
746fn default_health_path() -> String {
747 "/api/health".to_string()
748}
749
750fn default_health_interval() -> u64 {
751 10
752}
753
754fn default_health_timeout() -> u64 {
755 5
756}
757
758fn default_failure_threshold() -> u32 {
759 3
760}
761
762fn default_required() -> bool {
763 true
764}
765
766fn default_manual_input_label() -> String {
767 "Enter your message".to_string()
768}
769
770fn default_manual_prompt() -> String {
771 "What can you help me build with ADK-Rust today?".to_string()
772}
773
774#[cfg(test)]
775mod tests {
776 use super::{
777 AgentAuthConfig, AuthModeSpec, DeploymentManifest, EnvVarSpec, GraphConfig,
778 InteractionConfig, ManualInteractionConfig, RealtimeConfig, ServiceBinding, ServiceKind,
779 TriggerInteractionConfig, TriggerKind,
780 };
781
782 #[test]
783 fn rejects_undeclared_secret_refs_in_env() {
784 let mut manifest = DeploymentManifest::default();
785 manifest.env.insert(
786 "OPENAI_API_KEY".to_string(),
787 EnvVarSpec::SecretRef { secret_ref: "missing".to_string() },
788 );
789
790 let error = manifest.validate().unwrap_err();
791 assert!(error.to_string().contains("undeclared secret"));
792 }
793
794 #[test]
795 fn rejects_invalid_realtime_feature() {
796 let manifest = DeploymentManifest {
797 realtime: Some(RealtimeConfig {
798 features: vec!["unsupported".to_string()],
799 sticky_sessions: true,
800 drain_timeout_secs: Some(30),
801 }),
802 ..Default::default()
803 };
804
805 let error = manifest.validate().unwrap_err();
806 assert!(error.to_string().contains("unsupported realtime feature"));
807 }
808
809 #[test]
810 fn requires_graph_checkpoint_binding_for_hitl() {
811 let manifest = DeploymentManifest {
812 graph: Some(GraphConfig { checkpoint_binding: None, hitl_enabled: true }),
813 ..Default::default()
814 };
815
816 let error = manifest.validate().unwrap_err();
817 assert!(error.to_string().contains("graph.hitl_enabled"));
818 }
819
820 #[test]
821 fn requires_oidc_fields_when_auth_mode_is_oidc() {
822 let manifest = DeploymentManifest {
823 auth: Some(AgentAuthConfig {
824 mode: AuthModeSpec::Oidc,
825 required_scopes: vec!["deploy:read".to_string()],
826 issuer: None,
827 audience: Some("adk-cli".to_string()),
828 jwks_uri: None,
829 }),
830 ..Default::default()
831 };
832
833 let error = manifest.validate().unwrap_err();
834 assert!(error.to_string().contains("auth.mode = oidc"));
835 }
836
837 #[test]
838 fn accepts_supported_graph_checkpoint_binding() {
839 let mut manifest = DeploymentManifest::default();
840 manifest.services.push(ServiceBinding {
841 name: "graph-checkpoint".to_string(),
842 kind: ServiceKind::CheckpointPostgres,
843 mode: super::BindingMode::Managed,
844 connection_url: None,
845 secret_ref: None,
846 });
847 manifest.graph = Some(GraphConfig {
848 checkpoint_binding: Some("graph-checkpoint".to_string()),
849 hitl_enabled: true,
850 });
851
852 manifest.validate().unwrap();
853 }
854
855 #[test]
856 fn rejects_invalid_webhook_interaction_trigger() {
857 let manifest = DeploymentManifest {
858 interaction: Some(InteractionConfig {
859 manual: Some(ManualInteractionConfig::default()),
860 triggers: vec![TriggerInteractionConfig {
861 id: "trigger_1".to_string(),
862 name: "Incoming webhook".to_string(),
863 kind: TriggerKind::Webhook,
864 description: None,
865 path: None,
866 method: Some("POST".to_string()),
867 auth: None,
868 default_prompt: None,
869 cron: None,
870 timezone: None,
871 event_source: None,
872 event_type: None,
873 filter: None,
874 }],
875 }),
876 ..Default::default()
877 };
878
879 let error = manifest.validate().unwrap_err();
880 assert!(error.to_string().contains("path is required"));
881 }
882}