Skip to main content

adk_deploy/
manifest.rs

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    /// Load a deployment manifest from disk and validate it.
49    ///
50    /// # Example
51    ///
52    /// ```no_run
53    /// use adk_deploy::DeploymentManifest;
54    /// use std::path::Path;
55    ///
56    /// let manifest = DeploymentManifest::from_path(Path::new("adk-deploy.toml")).unwrap();
57    /// assert!(!manifest.agent.binary.is_empty());
58    /// ```
59    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    /// Serialize the manifest to TOML.
71    ///
72    /// # Example
73    ///
74    /// ```no_run
75    /// use adk_deploy::{AgentConfig, DeploymentManifest};
76    ///
77    /// let manifest = DeploymentManifest {
78    ///     agent: AgentConfig::new("demo", "demo"),
79    ///     ..DeploymentManifest::default()
80    /// };
81    /// let toml = manifest.to_toml_string().unwrap();
82    /// assert!(toml.contains("[agent]"));
83    /// ```
84    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    /// Validate manifest semantics before build or push.
91    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/// Declares how operators can interact with a deployed agent in the control plane.
408#[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/// Defines the default operator input experience for chat or manual-run agents.
430#[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/// Describes a non-chat trigger so the console can show how the deployed agent is activated.
462#[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/// Identifies the operator-visible trigger type for a deployment.
553#[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}