1use std::collections::{BTreeMap, BTreeSet};
18use std::path::PathBuf;
19
20use greentic_deploy_spec::CapabilitySlot;
21use greentic_deploy_spec::GUI_DEFAULT_ENV_ID;
22use qa_spec::spec::ListSpec;
23use qa_spec::spec::question::QuestionPolicy;
24use qa_spec::{AnswerSet, Expr, FormSpec, QuestionSpec, QuestionType};
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28use greentic_deploy_spec::BundleDeploymentStatus;
29
30use super::OpError;
31use super::bundles::{RevenueShareEntryPayload, RouteBindingPayload, TenantSelectorPayload};
32
33pub const ENV_MANIFEST_SCHEMA_V1: &str = "greentic.env-manifest.v1";
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct EnvManifest {
41 pub schema: String,
43 pub environment: ManifestEnvironment,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub trust_root: Option<TrustRootDirective>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub secrets: Vec<ManifestSecret>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub packs: Vec<ManifestPack>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub bundles: Vec<ManifestBundle>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub extensions: Vec<ManifestExtension>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub messaging_endpoints: Vec<ManifestEndpoint>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct ManifestEnvironment {
70 pub id: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub public_base_url: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub name: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub region: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub tenant_org_id: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub listen_addr: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub gui_enabled: Option<bool>,
100}
101
102impl ManifestEnvironment {
103 pub(crate) fn declares_host_config(&self) -> bool {
107 self.name.is_some()
108 || self.region.is_some()
109 || self.tenant_org_id.is_some()
110 || self.listen_addr.is_some()
111 || self.gui_enabled.is_some()
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum TrustRootDirective {
121 Bootstrap,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct ManifestSecret {
127 pub path: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub from_env: Option<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct ManifestPack {
143 pub slot: CapabilitySlot,
145 pub kind: String,
147 pub pack_ref: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub answers_ref: Option<PathBuf>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ManifestExtension {
158 pub kind: String,
160 pub pack_ref: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub instance_id: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub answers_ref: Option<PathBuf>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct ManifestBundle {
173 pub bundle_id: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub bundle_path: Option<PathBuf>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub revisions: Option<Vec<ManifestRevision>>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub customer_id: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub revenue_share: Option<Vec<RevenueShareEntryPayload>>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub status: Option<BundleDeploymentStatus>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub route_binding: Option<RouteBindingPayload>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub bundle_source_uri: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub bundle_digest: Option<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ManifestRevision {
233 pub name: String,
235 pub bundle_path: PathBuf,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub weight_percent: Option<u32>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub weight_bps: Option<u32>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub drain_seconds: Option<u32>,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub abort_metrics: Vec<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub bundle_source_uri: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub bundle_digest: Option<String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct ManifestEndpoint {
269 pub name: String,
273 pub provider_type: String,
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub links: Vec<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub welcome_flow: Option<ManifestWelcomeFlow>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub secret_refs: Vec<String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct ManifestWelcomeFlow {
290 pub bundle_id: String,
291 pub pack_id: String,
292 pub flow_id: String,
293}
294
295impl EnvManifest {
296 pub fn validate_shape(&self) -> Result<(), OpError> {
300 if self.schema != ENV_MANIFEST_SCHEMA_V1 {
301 return Err(OpError::InvalidArgument(format!(
302 "manifest schema `{}` is not the expected `{ENV_MANIFEST_SCHEMA_V1}`",
303 self.schema
304 )));
305 }
306 if self.environment.id.trim().is_empty() {
307 return Err(OpError::InvalidArgument(
308 "environment.id must not be empty".to_string(),
309 ));
310 }
311 if let Some(raw) = &self.environment.listen_addr {
313 raw.parse::<std::net::SocketAddr>().map_err(|e| {
314 OpError::InvalidArgument(format!(
315 "environment.listen_addr `{raw}` is not a valid socket address: {e}"
316 ))
317 })?;
318 }
319
320 let mut pack_slots = BTreeSet::new();
323 for p in &self.packs {
324 if !p.slot.binds_in_packs() {
325 return Err(OpError::InvalidArgument(format!(
326 "packs[]: slot `{}` does not bind in packs — use \
327 messaging_endpoints[] or extensions[] instead",
328 p.slot
329 )));
330 }
331 if !pack_slots.insert(p.slot) {
332 return Err(OpError::InvalidArgument(format!(
333 "duplicate slot `{}` in manifest packs[]",
334 p.slot
335 )));
336 }
337 greentic_deploy_spec::PackDescriptor::try_new(&p.kind).map_err(|e| {
338 OpError::InvalidArgument(format!("packs[] slot `{}`: kind: {e}", p.slot))
339 })?;
340 if p.pack_ref.trim().is_empty() {
341 return Err(OpError::InvalidArgument(format!(
342 "packs[] slot `{}`: pack_ref must not be empty",
343 p.slot
344 )));
345 }
346 }
347
348 let mut ext_keys = BTreeSet::new();
351 for ext in &self.extensions {
352 let descriptor =
353 greentic_deploy_spec::PackDescriptor::try_new(&ext.kind).map_err(|e| {
354 OpError::InvalidArgument(format!("extensions[]: kind `{}`: {e}", ext.kind))
355 })?;
356 if ext.pack_ref.trim().is_empty() {
357 return Err(OpError::InvalidArgument(format!(
358 "extensions[] kind `{}`: pack_ref must not be empty",
359 ext.kind
360 )));
361 }
362 if let Some(inst) = &ext.instance_id
363 && (inst.is_empty()
364 || !inst
365 .chars()
366 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
367 {
368 return Err(OpError::InvalidArgument(format!(
369 "extensions[] kind `{}`: instance_id `{inst}` must be non-empty \
370 and contain only [a-z0-9-]",
371 ext.kind
372 )));
373 }
374 let key = (
375 descriptor.path().to_string(),
376 ext.instance_id.as_deref().unwrap_or("").to_string(),
377 );
378 if !ext_keys.insert(key) {
379 return Err(OpError::InvalidArgument(format!(
380 "duplicate extension (path `{}`, instance_id {:?}) in manifest extensions[]",
381 descriptor.path(),
382 ext.instance_id
383 )));
384 }
385 }
386
387 let mut secret_paths = BTreeSet::new();
393 for s in &self.secrets {
394 let rel_path = s.path.trim_start_matches('/');
395 super::secrets::validate_dev_store_secret_path(rel_path)?;
396 if !secret_paths.insert(rel_path) {
397 return Err(OpError::InvalidArgument(format!(
398 "duplicate secret path `{rel_path}` in manifest secrets[] \
399 (order-dependent last-write-wins is never what you want)"
400 )));
401 }
402 if let Some(from_env) = &s.from_env
403 && from_env.trim().is_empty()
404 {
405 return Err(OpError::InvalidArgument(format!(
406 "secret `{rel_path}`: from_env, when present, must name an environment \
407 variable — omit it entirely for a pasted (interactively-supplied) secret"
408 )));
409 }
410 }
411
412 let mut bundle_ids = BTreeSet::new();
413 for b in &self.bundles {
414 if b.bundle_id.trim().is_empty() {
415 return Err(OpError::InvalidArgument(
416 "bundles[].bundle_id must not be empty".to_string(),
417 ));
418 }
419 if !bundle_ids.insert(b.bundle_id.as_str()) {
420 return Err(OpError::InvalidArgument(format!(
421 "duplicate bundle_id `{}` in manifest bundles[]",
422 b.bundle_id
423 )));
424 }
425 match (&b.bundle_path, &b.revisions) {
430 (Some(_), Some(_)) => {
431 return Err(OpError::InvalidArgument(format!(
432 "bundle `{}`: `bundle_path` and `revisions` are mutually exclusive \
433 — use `bundle_path` for single-revision (100 %) or `revisions` \
434 for a traffic split",
435 b.bundle_id
436 )));
437 }
438 (None, Some(_)) => {
439 if b.bundle_source_uri.is_some() {
443 return Err(OpError::InvalidArgument(format!(
444 "bundle `{}`: bundle-level `bundle_source_uri` is for the \
445 single-revision form — declare a per-revision \
446 `bundle_source_uri` inside `revisions[]` for a traffic split",
447 b.bundle_id
448 )));
449 }
450 if b.bundle_digest.is_some() {
451 return Err(OpError::InvalidArgument(format!(
452 "bundle `{}`: bundle-level `bundle_digest` is for the \
453 single-revision form — declare a per-revision \
454 `bundle_digest` inside `revisions[]` for a traffic split",
455 b.bundle_id
456 )));
457 }
458 }
459 (Some(_), None) => {}
460 (None, None) => {
461 if b.bundle_source_uri.is_none() {
464 return Err(OpError::InvalidArgument(format!(
465 "bundle `{}`: a single-revision bundle needs a `bundle_path` \
466 or a `bundle_source_uri`",
467 b.bundle_id
468 )));
469 }
470 }
471 }
472 validate_digest_pin(&b.bundle_id, None, b.bundle_digest.as_deref())?;
474
475 if let Some(revisions) = &b.revisions {
477 if revisions.is_empty() {
478 return Err(OpError::InvalidArgument(format!(
479 "bundle `{}`: `revisions` must not be empty",
480 b.bundle_id
481 )));
482 }
483 let mut rev_names = BTreeSet::new();
484 for rev in revisions {
485 if rev.name.trim().is_empty() {
486 return Err(OpError::InvalidArgument(format!(
487 "bundle `{}`: revision name must not be empty",
488 b.bundle_id
489 )));
490 }
491 if !rev_names.insert(rev.name.as_str()) {
492 return Err(OpError::InvalidArgument(format!(
493 "bundle `{}`: duplicate revision name `{}`",
494 b.bundle_id, rev.name
495 )));
496 }
497 if rev.weight_percent.is_some() && rev.weight_bps.is_some() {
499 return Err(OpError::InvalidArgument(format!(
500 "bundle `{}`, revision `{}`: `weight_percent` and `weight_bps` \
501 are mutually exclusive",
502 b.bundle_id, rev.name
503 )));
504 }
505 validate_digest_pin(
507 &b.bundle_id,
508 Some(&rev.name),
509 rev.bundle_digest.as_deref(),
510 )?;
511 }
512 validate_revision_weights(&b.bundle_id, revisions)?;
515 }
516
517 if let Some(shares) = &b.revenue_share {
522 if shares.is_empty() {
523 return Err(OpError::InvalidArgument(format!(
524 "bundle `{}`: `revenue_share` must not be empty",
525 b.bundle_id
526 )));
527 }
528 let mut parties = BTreeSet::new();
529 let mut sum: u64 = 0;
530 for entry in shares {
531 if entry.party_id.trim().is_empty() {
532 return Err(OpError::InvalidArgument(format!(
533 "bundle `{}`: revenue_share party_id must not be empty",
534 b.bundle_id
535 )));
536 }
537 if !parties.insert(entry.party_id.as_str()) {
538 return Err(OpError::InvalidArgument(format!(
539 "bundle `{}`: duplicate revenue_share party_id `{}`",
540 b.bundle_id, entry.party_id
541 )));
542 }
543 sum += u64::from(entry.basis_points);
544 }
545 if sum != u64::from(FULL_TRAFFIC_BPS) {
546 return Err(OpError::InvalidArgument(format!(
547 "bundle `{}`: revenue_share basis_points sum to {sum}, must be exactly \
548 {FULL_TRAFFIC_BPS}",
549 b.bundle_id
550 )));
551 }
552 }
553
554 if let Some(rb) = &b.route_binding {
555 rb.validate()?;
556 for prefix in &rb.path_prefixes {
557 if !prefix.starts_with('/') {
558 return Err(OpError::InvalidArgument(format!(
559 "bundle `{}` route_binding.path_prefixes entry `{prefix}` \
560 must start with `/`",
561 b.bundle_id
562 )));
563 }
564 }
565 }
566 }
567
568 let mut endpoint_names = BTreeSet::new();
569 for ep in &self.messaging_endpoints {
570 if ep.name.trim().is_empty() {
571 return Err(OpError::InvalidArgument(
572 "messaging_endpoints[].name must not be empty".to_string(),
573 ));
574 }
575 if ep.provider_type.trim().is_empty() {
576 return Err(OpError::InvalidArgument(format!(
577 "endpoint `{}`: provider_type must not be empty",
578 ep.name
579 )));
580 }
581 if !endpoint_names.insert(ep.name.as_str()) {
582 return Err(OpError::InvalidArgument(format!(
583 "duplicate endpoint name `{}` in manifest messaging_endpoints[]",
584 ep.name
585 )));
586 }
587 let mut link_set = BTreeSet::new();
588 for link in &ep.links {
589 if !link_set.insert(link.as_str()) {
590 return Err(OpError::InvalidArgument(format!(
591 "endpoint `{}`: duplicate link `{link}` in links[]",
592 ep.name
593 )));
594 }
595 }
596 }
597 Ok(())
598 }
599}
600
601pub(crate) const FULL_TRAFFIC_BPS: u32 = 10_000;
603
604fn validate_digest_pin(
608 bundle_id: &str,
609 revision: Option<&str>,
610 digest: Option<&str>,
611) -> Result<(), OpError> {
612 let Some(digest) = digest else {
613 return Ok(());
614 };
615 if digest.starts_with("sha256:") && digest.len() > "sha256:".len() {
616 return Ok(());
617 }
618 let location = match revision {
619 Some(name) => format!("bundle `{bundle_id}`, revision `{name}`"),
620 None => format!("bundle `{bundle_id}`"),
621 };
622 Err(OpError::InvalidArgument(format!(
623 "{location}: bundle_digest `{digest}` must be a `sha256:<hex>` string"
624 )))
625}
626
627fn validate_revision_weights(
636 bundle_id: &str,
637 revisions: &[ManifestRevision],
638) -> Result<(), OpError> {
639 let has_weight: Vec<bool> = revisions
640 .iter()
641 .map(|r| r.weight_percent.is_some() || r.weight_bps.is_some())
642 .collect();
643 let all_set = has_weight.iter().all(|&w| w);
644 let none_set = has_weight.iter().all(|&w| !w);
645 if !all_set && !none_set {
646 return Err(OpError::InvalidArgument(format!(
647 "bundle `{bundle_id}`: either ALL revisions must declare a weight or NONE \
648 (equal split) — mixing set and unset weights is not allowed"
649 )));
650 }
651 if all_set {
652 let sum: u32 = revisions
653 .iter()
654 .map(|r| effective_bps_single(r).expect("all_set guarantees a weight"))
655 .sum();
656 if sum != FULL_TRAFFIC_BPS {
657 return Err(OpError::InvalidArgument(format!(
658 "bundle `{bundle_id}`: revision weights sum to {sum} bps, must be exactly \
659 {FULL_TRAFFIC_BPS} (100 %)"
660 )));
661 }
662 }
663 Ok(())
664}
665
666fn effective_bps_single(rev: &ManifestRevision) -> Option<u32> {
669 if let Some(pct) = rev.weight_percent {
670 Some(pct * 100)
672 } else {
673 rev.weight_bps
674 }
675}
676
677pub(crate) fn compute_effective_weights_bps(revisions: &[ManifestRevision]) -> Vec<u32> {
685 let n = revisions.len() as u32;
686 assert!(n > 0, "validated: revisions is non-empty");
687 if revisions[0].weight_percent.is_none() && revisions[0].weight_bps.is_none() {
688 let base = FULL_TRAFFIC_BPS / n;
690 let remainder = FULL_TRAFFIC_BPS - base * n;
691 (0..n)
692 .map(|i| if i == 0 { base + remainder } else { base })
693 .collect()
694 } else {
695 revisions
696 .iter()
697 .map(|r| effective_bps_single(r).expect("validated: all-set"))
698 .collect()
699 }
700}
701
702pub const MANIFEST_TEMPLATE_JSON: &str = r#"{
712 "schema": "greentic.env-manifest.v1",
713 "environment": {
714 "id": "local",
715 "public_base_url": null
716 },
717 "trust_root": "bootstrap",
718 "secrets": [
719 {
720 "path": "default/_/messaging-telegram/telegram_bot_token",
721 "from_env": "TELEGRAM_BOT_TOKEN"
722 }
723 ],
724 "bundles": [
725 {
726 "bundle_id": "example-bundle",
727 "bundle_path": "example-bundle.gtbundle",
728 "route_binding": {
729 "path_prefixes": ["/example"],
730 "tenant_selector": { "tenant": "default", "team": "default" }
731 }
732 }
733 ],
734 "messaging_endpoints": [
735 {
736 "name": "example-endpoint",
737 "provider_type": "messaging.telegram.bot",
738 "links": ["example-bundle"],
739 "welcome_flow": {
740 "bundle_id": "example-bundle",
741 "pack_id": "example-pack",
742 "flow_id": "main"
743 }
744 }
745 ]
746}
747"#;
748
749pub fn manifest_schema() -> Value {
752 serde_json::json!({
753 "$schema": "https://json-schema.org/draft/2020-12/schema",
754 "title": "EnvManifest",
755 "description": "greentic.env-manifest.v1 — declarative environment wiring for `gtc op env apply`",
756 "type": "object",
757 "required": ["schema", "environment"],
758 "additionalProperties": false,
759 "properties": {
760 "schema": {"const": ENV_MANIFEST_SCHEMA_V1},
761 "environment": {
762 "type": "object",
763 "required": ["id"],
764 "additionalProperties": false,
765 "properties": {
766 "id": {"type": "string", "description": "Environment id; `local` bootstraps via env init; any other id must already exist (apply reconciles, the operator store creates)"},
767 "public_base_url": {"type": ["string", "null"], "description": "origin-only URL; absent = leave untouched"},
768 "name": {"type": ["string", "null"], "description": "display name; absent = leave untouched (or default to id on create)"},
769 "region": {"type": ["string", "null"], "description": "cloud region tag; absent = leave untouched"},
770 "tenant_org_id": {"type": ["string", "null"], "description": "tenant organization id; absent = leave untouched"},
771 "listen_addr": {"type": ["string", "null"], "description": "bind address (SocketAddr); absent = leave untouched"},
772 "gui_enabled": {"type": ["boolean", "null"], "description": "serve the built-in webchat GUI; absent/null = leave the stored value unchanged (upsert) — the env-id default (on for local, off elsewhere) applies only when the stored value is unset"}
773 }
774 },
775 "trust_root": {"enum": ["bootstrap", null], "description": "`bootstrap` seeds the operator key (idempotent)"},
776 "secrets": {
777 "type": "array",
778 "description": "dev-store secret entries; always-put (values cannot be diffed until A9)",
779 "items": {
780 "type": "object",
781 "required": ["path"],
782 "additionalProperties": false,
783 "properties": {
784 "path": {"type": "string", "description": "<tenant>/<team>/<pack>/<name>"},
785 "from_env": {"type": "string", "description": "env var holding the value; omit for a pasted (interactively-supplied) secret. Values never appear in the manifest either way"}
786 }
787 }
788 },
789 "packs": {
790 "type": "array",
791 "description": "env-pack bindings (core capability slots); applied after trust-root, before secrets",
792 "items": {
793 "type": "object",
794 "required": ["slot", "kind", "pack_ref"],
795 "additionalProperties": false,
796 "properties": {
797 "slot": {"type": "string", "description": "capability slot (must satisfy binds_in_packs)"},
798 "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`"},
799 "pack_ref": {"type": "string", "description": "pack reference (registry id or local path)"},
800 "answers_ref": {"type": ["string", "null"], "description": "optional answers file relative to the manifest"}
801 }
802 }
803 },
804 "bundles": {
805 "type": "array",
806 "items": {
807 "type": "object",
808 "required": ["bundle_id"],
809 "additionalProperties": false,
810 "properties": {
811 "bundle_id": {"type": "string"},
812 "bundle_path": {"type": ["string", "null"], "description": "single-revision form: local .gtbundle; relative to the manifest file; mutually exclusive with `revisions`"},
813 "revisions": {
814 "type": "array",
815 "description": "multi-revision / traffic-split form; mutually exclusive with `bundle_path`",
816 "items": {
817 "type": "object",
818 "required": ["name", "bundle_path"],
819 "additionalProperties": false,
820 "properties": {
821 "name": {"type": "string", "description": "manifest-local handle, unique within the bundle"},
822 "bundle_path": {"type": "string", "description": "local .gtbundle; relative to the manifest file"},
823 "weight_percent": {"type": ["integer", "null"], "description": "0..100; mutually exclusive with weight_bps"},
824 "weight_bps": {"type": ["integer", "null"], "description": "0..10000; mutually exclusive with weight_percent"},
825 "drain_seconds": {"type": ["integer", "null"], "description": "per-revision drain window override"},
826 "abort_metrics": {"type": "array", "items": {"type": "string"}, "description": "reserved for canary evaluation"},
827 "bundle_source_uri": {"type": ["string", "null"], "description": "oci://repo://store:// pull ref for K8s boot; rides alongside bundle_path (digest source); absent = local-serve only"},
828 "bundle_digest": {"type": ["string", "null"], "description": "optional sha256:<hex> integrity pin for bundle_path; verified at apply"}
829 }
830 }
831 },
832 "customer_id": {"type": ["string", "null"], "description": "required for non-local envs (B10)"},
833 "revenue_share": {
834 "type": ["array", "null"],
835 "description": "G2: billing split; basis_points must sum to 10000; absent=untouched (greentic@10000)",
836 "items": {
837 "type": "object",
838 "required": ["party_id", "basis_points"],
839 "additionalProperties": false,
840 "properties": {
841 "party_id": {"type": "string"},
842 "basis_points": {"type": "integer", "description": "0..10000; all entries sum to 10000"}
843 }
844 }
845 },
846 "status": {"type": ["string", "null"], "enum": ["active", "paused", "archived", null], "description": "G3: deployment status; absent=untouched; reconciled against an existing deployment"},
847 "config_overrides": {"type": ["object", "null"], "description": "<pack_id> -> <key> -> <json>; absent=untouched, {}=clear, map=replace"},
848 "route_binding": {
849 "type": ["object", "null"],
850 "properties": {
851 "hosts": {"type": "array", "items": {"type": "string"}},
852 "path_prefixes": {"type": "array", "items": {"type": "string"}},
853 "tenant_selector": {
854 "type": ["object", "null"],
855 "required": ["tenant", "team"],
856 "properties": {"tenant": {"type": "string"}, "team": {"type": "string"}}
857 }
858 }
859 },
860 "bundle_source_uri": {"type": ["string", "null"], "description": "single-revision oci:// pull ref for K8s boot; may stand alone (apply fetches it once for the digest) or ride alongside bundle_path; absent = local bundle_path required"},
861 "bundle_digest": {"type": ["string", "null"], "description": "optional sha256:<hex> integrity pin for the resolved artifact; verified at apply, else the computed digest is recorded"}
862 }
863 }
864 },
865 "extensions": {
866 "type": "array",
867 "description": "extension bindings (N-per-env open namespace); applied after bundles, before endpoints",
868 "items": {
869 "type": "object",
870 "required": ["kind", "pack_ref"],
871 "additionalProperties": false,
872 "properties": {
873 "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`"},
874 "pack_ref": {"type": "string", "description": "pack reference (registry id or local path)"},
875 "instance_id": {"type": ["string", "null"], "description": "instance selector for N instances of the same type; [a-z0-9-]"},
876 "answers_ref": {"type": ["string", "null"], "description": "optional answers file relative to the manifest"}
877 }
878 }
879 },
880 "messaging_endpoints": {
881 "type": "array",
882 "items": {
883 "type": "object",
884 "required": ["name", "provider_type"],
885 "additionalProperties": false,
886 "properties": {
887 "name": {"type": "string", "description": "natural key: matches existing endpoints by (provider_type, display_name)"},
888 "provider_type": {"type": "string"},
889 "links": {"type": "array", "items": {"type": "string"}},
890 "welcome_flow": {
891 "type": ["object", "null"],
892 "required": ["bundle_id", "pack_id", "flow_id"],
893 "additionalProperties": false,
894 "properties": {
895 "bundle_id": {"type": "string"},
896 "pack_id": {"type": "string"},
897 "flow_id": {"type": "string"}
898 }
899 },
900 "secret_refs": {"type": "array", "items": {"type": "string"}}
901 }
902 }
903 }
904 }
905 })
906}
907
908pub const ENV_MANIFEST_FORM_ID: &str = "greentic.env-manifest";
910
911pub const ENV_MANIFEST_FORM_VERSION: &str = "1";
916
917pub fn manifest_form_spec() -> FormSpec {
922 manifest_form_spec_for_env(GUI_DEFAULT_ENV_ID)
923}
924
925pub fn manifest_form_spec_for_env(env_id: &str) -> FormSpec {
957 let mut environment_id = question(
958 "environment_id",
959 QuestionType::String,
960 "Environment id",
961 "Environment to apply to. `local` bootstraps with default env-pack \
962 bindings; any other id must already exist (apply reconciles it; \
963 non-local env creation is reserved for the operator store).",
964 true,
965 );
966 environment_id.default_value = Some("local".to_string());
967
968 let public_base_url = question(
969 "public_base_url",
970 QuestionType::String,
971 "Public base URL",
972 "Origin-only URL persisted on the environment (e.g. \
973 https://bots.example.com). Leave empty to keep the current value.",
974 false,
975 );
976
977 let mut trust_root_bootstrap = question(
978 "trust_root_bootstrap",
979 QuestionType::Boolean,
980 "Bootstrap the trust root?",
981 "Seed the environment trust root with the local operator key \
982 (idempotent; required once before bundles can be staged).",
983 true,
984 );
985 trust_root_bootstrap.default_value = Some("true".to_string());
986
987 let mut webchat_gui = question(
988 "webchat_gui",
989 QuestionType::Boolean,
990 "Add a webchat GUI?",
991 "Serve the built-in webchat console so you can chat with this \
992 environment by opening its URL in a browser. On by default for \
993 `local`; the chat path is loopback-only and unauthenticated, so \
994 keep it off for environments exposed on a public URL unless you \
995 intend it.",
996 true,
997 );
998 webchat_gui.default_value = Some((env_id == GUI_DEFAULT_ENV_ID).to_string());
1003
1004 let mut secrets = question(
1005 "secrets",
1006 QuestionType::List,
1007 "Secrets",
1008 "Dev-store secret entries. Each secret's value comes either from a \
1009 named environment variable or from a value you paste in — values \
1010 never go into a manifest.",
1011 false,
1012 );
1013 let mut secret_source = question(
1016 "source",
1017 QuestionType::Enum,
1018 "Secret source",
1019 "`env` reads the value from a named environment variable at apply \
1020 time; `paste` lets you enter the value interactively — it is stored \
1021 in the env's secrets store, never in the manifest.",
1022 false,
1023 );
1024 secret_source.choices = Some(vec!["env".to_string(), "paste".to_string()]);
1025 secret_source.default_value = Some("env".to_string());
1026
1027 let mut secret_from_env = question(
1028 "from_env",
1029 QuestionType::String,
1030 "Environment variable name",
1031 "Name of the variable holding the secret value (e.g. \
1032 TELEGRAM_BOT_TOKEN) — the name, never the value. Required when the \
1033 source is `env`.",
1034 false,
1035 );
1036 secret_from_env.visible_if = Some(Expr::Eq {
1037 left: Box::new(Expr::Var {
1038 path: "source".to_string(),
1039 }),
1040 right: Box::new(Expr::Literal {
1041 value: Value::String("env".to_string()),
1042 }),
1043 });
1044
1045 secrets.list = Some(ListSpec {
1046 min_items: None,
1047 max_items: None,
1048 fields: vec![
1049 question(
1050 "path",
1051 QuestionType::String,
1052 "Secret path",
1053 "`<tenant>/<team>/<pack>/<name>`, e.g. \
1054 default/_/messaging-telegram/telegram_bot_token",
1055 true,
1056 ),
1057 secret_source,
1058 secret_from_env,
1059 ],
1060 item_label: Some("secret".to_string()),
1061 });
1062
1063 let mut bundles = question(
1064 "bundles",
1065 QuestionType::List,
1066 "Bundles",
1067 "Bundle deployments for this environment.",
1068 false,
1069 );
1070 bundles.list = Some(ListSpec {
1071 min_items: None,
1072 max_items: None,
1073 fields: vec![
1074 question(
1075 "bundle_id",
1076 QuestionType::String,
1077 "Bundle id",
1078 "Natural key — unique within the manifest.",
1079 true,
1080 ),
1081 question(
1082 "bundle_path",
1083 QuestionType::String,
1084 "Bundle path",
1085 "Local `.gtbundle`. Relative paths resolve against the \
1086 manifest file's directory.",
1087 true,
1088 ),
1089 question(
1090 "customer_id",
1091 QuestionType::String,
1092 "Customer id",
1093 "Billing principal — required by apply for non-`local` \
1094 environments.",
1095 false,
1096 ),
1097 question(
1098 "config_overrides",
1099 QuestionType::String,
1100 "Config overrides (JSON)",
1101 "JSON object `{\"<pack_id>\": {\"<key>\": <value>}}`. Empty \
1102 = leave untouched; `{}` = explicit clear.",
1103 false,
1104 ),
1105 question(
1106 "route_hosts",
1107 QuestionType::String,
1108 "Route hosts",
1109 "Comma-separated host names for the route binding.",
1110 false,
1111 ),
1112 {
1113 let mut q = question(
1118 "route_path_prefixes",
1119 QuestionType::String,
1120 "Route path prefixes",
1121 "Comma-separated HTTP path prefixes, each starting with `/` \
1122 (e.g. /legal).",
1123 false,
1124 );
1125 q.computed = Some(Expr::Concat {
1126 parts: vec![
1127 Expr::Literal {
1128 value: Value::String("/".to_string()),
1129 },
1130 Expr::Var {
1131 path: "bundle_id".to_string(),
1132 },
1133 ],
1134 });
1135 q.computed_overridable = true;
1136 q
1137 },
1138 {
1139 let mut q = question(
1142 "route_tenant",
1143 QuestionType::String,
1144 "Route tenant",
1145 "Tenant for the route binding's tenant selector — set \
1146 together with `route_team`.",
1147 false,
1148 );
1149 q.computed = Some(Expr::Var {
1150 path: "bundle_id".to_string(),
1151 });
1152 q.computed_overridable = true;
1153 q
1154 },
1155 {
1156 let mut q = question(
1159 "route_team",
1160 QuestionType::String,
1161 "Route team",
1162 "Team for the route binding's tenant selector — set \
1163 together with `route_tenant`.",
1164 false,
1165 );
1166 q.default_value = Some("default".to_string());
1167 q
1168 },
1169 ],
1170 item_label: Some("bundle".to_string()),
1171 });
1172
1173 let mut messaging_endpoints = question(
1174 "messaging_endpoints",
1175 QuestionType::List,
1176 "Messaging endpoints",
1177 "Messaging endpoints and their bundle links.",
1178 false,
1179 );
1180 messaging_endpoints.list = Some(ListSpec {
1181 min_items: None,
1182 max_items: None,
1183 fields: vec![
1184 question(
1185 "name",
1186 QuestionType::String,
1187 "Endpoint name",
1188 "Manifest-local handle and display name. Upsert key \
1189 together with the provider type.",
1190 true,
1191 ),
1192 question(
1193 "provider_type",
1194 QuestionType::String,
1195 "Provider type",
1196 "Provider class, e.g. messaging.telegram.bot.",
1197 true,
1198 ),
1199 {
1200 let mut q = question(
1204 "links",
1205 QuestionType::String,
1206 "Linked bundle ids",
1207 "Comma-separated `bundle_id`s this endpoint admits.",
1208 false,
1209 );
1210 q.computed = Some(Expr::Var {
1211 path: "name".to_string(),
1212 });
1213 q.computed_overridable = true;
1214 q
1215 },
1216 question(
1217 "welcome_bundle_id",
1218 QuestionType::String,
1219 "Welcome flow: bundle id",
1220 "Set the three welcome_* fields together (or none).",
1221 false,
1222 ),
1223 question(
1224 "welcome_pack_id",
1225 QuestionType::String,
1226 "Welcome flow: pack id",
1227 "Set the three welcome_* fields together (or none).",
1228 false,
1229 ),
1230 question(
1231 "welcome_flow_id",
1232 QuestionType::String,
1233 "Welcome flow: flow id",
1234 "Set the three welcome_* fields together (or none).",
1235 false,
1236 ),
1237 question(
1238 "secret_refs",
1239 QuestionType::String,
1240 "Secret refs",
1241 "Comma-separated secret refs forwarded on endpoint create.",
1242 false,
1243 ),
1244 ],
1245 item_label: Some("Messaging endpoint".to_string()),
1246 });
1247
1248 FormSpec {
1249 id: ENV_MANIFEST_FORM_ID.to_string(),
1250 title: "Environment setup".to_string(),
1251 version: ENV_MANIFEST_FORM_VERSION.to_string(),
1252 description: Some(format!(
1253 "Authors a `{ENV_MANIFEST_SCHEMA_V1}` manifest — the durable, \
1254 re-appliable desired-state document for one environment."
1255 )),
1256 presentation: None,
1257 progress_policy: None,
1258 secrets_policy: None,
1259 store: Vec::new(),
1260 validations: Vec::new(),
1261 includes: Vec::new(),
1262 questions: vec![
1267 environment_id,
1268 public_base_url,
1269 trust_root_bootstrap,
1270 webchat_gui,
1271 bundles,
1272 messaging_endpoints,
1273 secrets,
1274 ],
1275 }
1276}
1277
1278fn question(
1283 id: &str,
1284 kind: QuestionType,
1285 title: &str,
1286 description: &str,
1287 required: bool,
1288) -> QuestionSpec {
1289 QuestionSpec {
1290 id: id.to_string(),
1291 kind,
1292 title: title.to_string(),
1293 title_i18n: None,
1294 description: Some(description.to_string()),
1295 description_i18n: None,
1296 required,
1297 choices: None,
1298 default_value: None,
1299 secret: false,
1300 visible_if: None,
1301 constraint: None,
1302 list: None,
1303 computed: None,
1304 policy: QuestionPolicy::default(),
1305 computed_overridable: false,
1306 }
1307}
1308
1309pub fn answers_to_manifest(answers: &AnswerSet) -> Result<EnvManifest, OpError> {
1325 if answers.form_id != ENV_MANIFEST_FORM_ID {
1326 return Err(OpError::InvalidArgument(format!(
1327 "answers form_id `{}` is not `{ENV_MANIFEST_FORM_ID}`",
1328 answers.form_id
1329 )));
1330 }
1331 if answers.spec_version != ENV_MANIFEST_FORM_VERSION {
1332 return Err(OpError::InvalidArgument(format!(
1333 "answers spec_version `{}` is not `{ENV_MANIFEST_FORM_VERSION}` \
1334 — re-run the wizard against the current form",
1335 answers.spec_version
1336 )));
1337 }
1338 let map = answers
1339 .answers
1340 .as_object()
1341 .ok_or_else(|| OpError::InvalidArgument("answers must be a JSON object".to_string()))?;
1342
1343 let environment_id = opt_string(map, "environment_id")?.ok_or_else(|| {
1344 OpError::InvalidArgument("answers: environment_id must be a non-empty string".to_string())
1345 })?;
1346 let public_base_url = opt_string(map, "public_base_url")?;
1347 let trust_root = match map.get("trust_root_bootstrap") {
1348 None | Some(Value::Null) | Some(Value::Bool(false)) => None,
1349 Some(Value::Bool(true)) => Some(TrustRootDirective::Bootstrap),
1350 Some(other) => {
1351 return Err(OpError::InvalidArgument(format!(
1352 "answers: trust_root_bootstrap must be a boolean, got {other}"
1353 )));
1354 }
1355 };
1356 let gui_enabled = match map.get("webchat_gui") {
1360 None | Some(Value::Null) => None,
1361 Some(Value::Bool(b)) => Some(*b),
1362 Some(other) => {
1363 return Err(OpError::InvalidArgument(format!(
1364 "answers: webchat_gui must be a boolean, got {other}"
1365 )));
1366 }
1367 };
1368
1369 let mut secrets = Vec::new();
1370 for (idx, row) in rows(map, "secrets")?.iter().enumerate() {
1371 let row = row_object("secrets", idx, row)?;
1372 let path = req_row_string("secrets", idx, row, "path")?;
1373 let source =
1376 opt_row_string("secrets", idx, row, "source")?.unwrap_or_else(|| "env".to_string());
1377 let from_env = match source.as_str() {
1378 "env" => Some(req_row_string("secrets", idx, row, "from_env")?),
1379 "paste" => None,
1380 other => {
1381 return Err(OpError::InvalidArgument(format!(
1382 "answers: secrets[{idx}]: source must be `env` or `paste`, got `{other}`"
1383 )));
1384 }
1385 };
1386 secrets.push(ManifestSecret { path, from_env });
1387 }
1388
1389 let mut bundles = Vec::new();
1390 for (idx, row) in rows(map, "bundles")?.iter().enumerate() {
1391 let row = row_object("bundles", idx, row)?;
1392 let bundle_id = req_row_string("bundles", idx, row, "bundle_id")?;
1393 let config_overrides = match opt_row_string("bundles", idx, row, "config_overrides")? {
1394 None => None,
1395 Some(raw) => Some(
1396 serde_json::from_str::<BTreeMap<String, BTreeMap<String, Value>>>(&raw).map_err(
1397 |err| {
1398 OpError::InvalidArgument(format!(
1399 "answers: bundles[{idx}] (`{bundle_id}`): config_overrides is \
1400 not a `<pack_id> -> <key> -> <value>` JSON object: {err}"
1401 ))
1402 },
1403 )?,
1404 ),
1405 };
1406 let hosts = split_csv(opt_row_string("bundles", idx, row, "route_hosts")?);
1407 let path_prefixes = split_csv(opt_row_string("bundles", idx, row, "route_path_prefixes")?);
1408 let tenant_selector = match (
1409 opt_row_string("bundles", idx, row, "route_tenant")?,
1410 opt_row_string("bundles", idx, row, "route_team")?,
1411 ) {
1412 (Some(tenant), Some(team)) => Some(TenantSelectorPayload { tenant, team }),
1413 (None, None) => None,
1414 _ => {
1415 return Err(OpError::InvalidArgument(format!(
1416 "answers: bundles[{idx}] (`{bundle_id}`): set route_tenant and \
1417 route_team together (or neither)"
1418 )));
1419 }
1420 };
1421 let route_binding =
1422 if hosts.is_empty() && path_prefixes.is_empty() && tenant_selector.is_none() {
1423 None
1424 } else {
1425 Some(RouteBindingPayload {
1426 hosts,
1427 path_prefixes,
1428 tenant_selector,
1429 })
1430 };
1431 bundles.push(ManifestBundle {
1432 bundle_id,
1433 bundle_path: Some(PathBuf::from(req_row_string(
1434 "bundles",
1435 idx,
1436 row,
1437 "bundle_path",
1438 )?)),
1439 revisions: None,
1440 customer_id: opt_row_string("bundles", idx, row, "customer_id")?,
1441 revenue_share: None,
1442 status: None,
1443 config_overrides,
1444 route_binding,
1445 bundle_source_uri: None,
1448 bundle_digest: None,
1449 });
1450 }
1451
1452 let mut messaging_endpoints = Vec::new();
1453 for (idx, row) in rows(map, "messaging_endpoints")?.iter().enumerate() {
1454 let row = row_object("messaging_endpoints", idx, row)?;
1455 let name = req_row_string("messaging_endpoints", idx, row, "name")?;
1456 let welcome_flow = match (
1457 opt_row_string("messaging_endpoints", idx, row, "welcome_bundle_id")?,
1458 opt_row_string("messaging_endpoints", idx, row, "welcome_pack_id")?,
1459 opt_row_string("messaging_endpoints", idx, row, "welcome_flow_id")?,
1460 ) {
1461 (Some(bundle_id), Some(pack_id), Some(flow_id)) => Some(ManifestWelcomeFlow {
1462 bundle_id,
1463 pack_id,
1464 flow_id,
1465 }),
1466 (None, None, None) => None,
1467 _ => {
1468 return Err(OpError::InvalidArgument(format!(
1469 "answers: messaging_endpoints[{idx}] (`{name}`): set \
1470 welcome_bundle_id, welcome_pack_id and welcome_flow_id \
1471 together (or none)"
1472 )));
1473 }
1474 };
1475 messaging_endpoints.push(ManifestEndpoint {
1476 name,
1477 provider_type: req_row_string("messaging_endpoints", idx, row, "provider_type")?,
1478 links: split_csv(opt_row_string("messaging_endpoints", idx, row, "links")?),
1479 welcome_flow,
1480 secret_refs: split_csv(opt_row_string(
1481 "messaging_endpoints",
1482 idx,
1483 row,
1484 "secret_refs",
1485 )?),
1486 });
1487 }
1488
1489 Ok(EnvManifest {
1490 schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
1491 environment: ManifestEnvironment {
1492 id: environment_id,
1493 public_base_url,
1494 name: None,
1495 region: None,
1496 tenant_org_id: None,
1497 listen_addr: None,
1498 gui_enabled,
1499 },
1500 trust_root,
1501 secrets,
1502 packs: Vec::new(),
1503 bundles,
1504 extensions: Vec::new(),
1505 messaging_endpoints,
1506 })
1507}
1508
1509fn rows<'a>(map: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a [Value], OpError> {
1511 const EMPTY: &[Value] = &[];
1512 match map.get(key) {
1513 None | Some(Value::Null) => Ok(EMPTY),
1514 Some(Value::Array(items)) => Ok(items.as_slice()),
1515 Some(other) => Err(OpError::InvalidArgument(format!(
1516 "answers: {key} must be an array, got {other}"
1517 ))),
1518 }
1519}
1520
1521fn row_object<'a>(
1522 section: &str,
1523 idx: usize,
1524 row: &'a Value,
1525) -> Result<&'a serde_json::Map<String, Value>, OpError> {
1526 row.as_object().ok_or_else(|| {
1527 OpError::InvalidArgument(format!(
1528 "answers: {section}[{idx}] must be an object, got {row}"
1529 ))
1530 })
1531}
1532
1533fn opt_string(map: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OpError> {
1535 opt_string_at(map, key, key)
1536}
1537
1538fn opt_string_at(
1541 map: &serde_json::Map<String, Value>,
1542 key: &str,
1543 label: &str,
1544) -> Result<Option<String>, OpError> {
1545 match map.get(key) {
1546 None | Some(Value::Null) => Ok(None),
1547 Some(Value::String(s)) => {
1548 let trimmed = s.trim();
1549 Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
1550 }
1551 Some(other) => Err(OpError::InvalidArgument(format!(
1552 "answers: {label} must be a string, got {other}"
1553 ))),
1554 }
1555}
1556
1557fn opt_row_string(
1558 section: &str,
1559 idx: usize,
1560 row: &serde_json::Map<String, Value>,
1561 key: &str,
1562) -> Result<Option<String>, OpError> {
1563 opt_string_at(row, key, &format!("{section}[{idx}].{key}"))
1564}
1565
1566fn req_row_string(
1567 section: &str,
1568 idx: usize,
1569 row: &serde_json::Map<String, Value>,
1570 key: &str,
1571) -> Result<String, OpError> {
1572 opt_row_string(section, idx, row, key)?.ok_or_else(|| {
1573 OpError::InvalidArgument(format!(
1574 "answers: {section}[{idx}].{key} must be a non-empty string"
1575 ))
1576 })
1577}
1578
1579fn split_csv(value: Option<String>) -> Vec<String> {
1581 value
1582 .map(|raw| {
1583 raw.split(',')
1584 .map(str::trim)
1585 .filter(|entry| !entry.is_empty())
1586 .map(str::to_string)
1587 .collect()
1588 })
1589 .unwrap_or_default()
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594 use super::*;
1595
1596 fn minimal(schema: &str) -> EnvManifest {
1597 serde_json::from_value(serde_json::json!({
1598 "schema": schema,
1599 "environment": {"id": "local"}
1600 }))
1601 .expect("minimal manifest parses")
1602 }
1603
1604 #[test]
1605 fn schema_mismatch_rejected() {
1606 let err = minimal("greentic.env-manifest.v2")
1607 .validate_shape()
1608 .unwrap_err();
1609 assert!(matches!(err, OpError::InvalidArgument(_)), "{err}");
1610 }
1611
1612 #[test]
1613 fn unknown_top_level_field_rejected_at_parse() {
1614 let err = serde_json::from_value::<EnvManifest>(serde_json::json!({
1615 "schema": ENV_MANIFEST_SCHEMA_V1,
1616 "environment": {"id": "local"},
1617 "bundlez": []
1618 }))
1619 .unwrap_err();
1620 assert!(err.to_string().contains("bundlez"), "{err}");
1621 }
1622
1623 #[test]
1624 fn valid_secrets_pass_shape_validation() {
1625 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1626 "schema": ENV_MANIFEST_SCHEMA_V1,
1627 "environment": {"id": "local"},
1628 "secrets": [
1629 {"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "A"},
1630 {"path": "accounting/_/messaging-telegram/telegram_bot_token", "from_env": "B"}
1631 ]
1632 }))
1633 .unwrap();
1634 manifest.validate_shape().expect("valid");
1635 }
1636
1637 #[test]
1638 fn non_canonical_secret_path_rejected_at_shape() {
1639 for path in [
1642 "credentials/aws",
1643 "legal/default/messaging-telegram/telegram_bot_token",
1644 "legal/_/messaging-telegram/BOT-TOKEN",
1645 ] {
1646 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1647 "schema": ENV_MANIFEST_SCHEMA_V1,
1648 "environment": {"id": "local"},
1649 "secrets": [{"path": path, "from_env": "X"}]
1650 }))
1651 .unwrap();
1652 let err = manifest.validate_shape().unwrap_err();
1653 assert!(
1654 matches!(err, OpError::InvalidArgument(_)),
1655 "path `{path}` got {err}"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn duplicate_secret_path_rejected() {
1662 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1665 "schema": ENV_MANIFEST_SCHEMA_V1,
1666 "environment": {"id": "local"},
1667 "secrets": [
1668 {"path": "legal/_/p/tok", "from_env": "A"},
1669 {"path": "/legal/_/p/tok", "from_env": "B"}
1670 ]
1671 }))
1672 .unwrap();
1673 let err = manifest.validate_shape().unwrap_err();
1674 assert!(err.to_string().contains("duplicate secret path"), "{err}");
1675 }
1676
1677 #[test]
1678 fn empty_from_env_rejected() {
1679 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1680 "schema": ENV_MANIFEST_SCHEMA_V1,
1681 "environment": {"id": "local"},
1682 "secrets": [{"path": "legal/_/p/tok", "from_env": " "}]
1683 }))
1684 .unwrap();
1685 let err = manifest.validate_shape().unwrap_err();
1686 assert!(err.to_string().contains("from_env"), "{err}");
1687 }
1688
1689 #[test]
1690 fn paste_secret_omits_from_env_and_validates() {
1691 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1694 "schema": ENV_MANIFEST_SCHEMA_V1,
1695 "environment": {"id": "local"},
1696 "secrets": [{"path": "legal/_/p/tok"}]
1697 }))
1698 .unwrap();
1699 manifest
1700 .validate_shape()
1701 .expect("paste secret is shape-valid");
1702 assert_eq!(manifest.secrets[0].from_env, None);
1703 let json = serde_json::to_value(&manifest).unwrap();
1704 assert!(
1705 json["secrets"][0].get("from_env").is_none(),
1706 "absent from_env is omitted, not serialized as null"
1707 );
1708 }
1709
1710 #[test]
1711 fn answers_to_manifest_maps_secret_source() {
1712 let env_set = answers(serde_json::json!({
1714 "environment_id": "local",
1715 "secrets": [{"path": "legal/_/p/tok", "source": "env", "from_env": "LEGAL_TOK"}]
1716 }));
1717 assert_eq!(
1718 answers_to_manifest(&env_set).unwrap().secrets[0]
1719 .from_env
1720 .as_deref(),
1721 Some("LEGAL_TOK")
1722 );
1723
1724 let paste_set = answers(serde_json::json!({
1726 "environment_id": "local",
1727 "secrets": [{"path": "legal/_/p/tok", "source": "paste"}]
1728 }));
1729 assert_eq!(
1730 answers_to_manifest(&paste_set).unwrap().secrets[0].from_env,
1731 None
1732 );
1733
1734 let legacy_set = answers(serde_json::json!({
1736 "environment_id": "local",
1737 "secrets": [{"path": "legal/_/p/tok", "from_env": "LEGACY"}]
1738 }));
1739 assert_eq!(
1740 answers_to_manifest(&legacy_set).unwrap().secrets[0]
1741 .from_env
1742 .as_deref(),
1743 Some("LEGACY")
1744 );
1745
1746 let bad_set = answers(serde_json::json!({
1748 "environment_id": "local",
1749 "secrets": [{"path": "legal/_/p/tok", "source": "vault"}]
1750 }));
1751 let err = answers_to_manifest(&bad_set).unwrap_err();
1752 assert!(err.to_string().contains("source must be"), "{err}");
1753 }
1754
1755 #[test]
1756 fn form_spec_secrets_models_env_or_paste() {
1757 let spec = manifest_form_spec();
1758 let secrets = spec
1759 .questions
1760 .iter()
1761 .find(|q| q.id == "secrets")
1762 .expect("secrets question");
1763 let list = secrets.list.as_ref().expect("secrets is a list");
1764
1765 let source = list
1766 .fields
1767 .iter()
1768 .find(|f| f.id == "source")
1769 .expect("source column");
1770 assert_eq!(source.kind, QuestionType::Enum);
1771 assert_eq!(
1772 source.choices.as_deref(),
1773 Some(&["env".to_string(), "paste".to_string()][..])
1774 );
1775 assert_eq!(source.default_value.as_deref(), Some("env"));
1776 assert!(!source.required, "source defaults to env, never required");
1777
1778 let from_env = list
1779 .fields
1780 .iter()
1781 .find(|f| f.id == "from_env")
1782 .expect("from_env column");
1783 assert!(
1784 !from_env.required,
1785 "from_env is needed only for env-sourced secrets"
1786 );
1787 assert_eq!(
1788 from_env.visible_if,
1789 Some(Expr::Eq {
1790 left: Box::new(Expr::Var {
1791 path: "source".to_string()
1792 }),
1793 right: Box::new(Expr::Literal {
1794 value: Value::String("env".to_string())
1795 }),
1796 }),
1797 "from_env is shown only when source == env"
1798 );
1799 }
1800
1801 #[test]
1802 fn duplicate_bundle_id_rejected() {
1803 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1804 "schema": ENV_MANIFEST_SCHEMA_V1,
1805 "environment": {"id": "local"},
1806 "bundles": [
1807 {"bundle_id": "a", "bundle_path": "a.gtbundle"},
1808 {"bundle_id": "a", "bundle_path": "b.gtbundle"}
1809 ]
1810 }))
1811 .unwrap();
1812 let err = manifest.validate_shape().unwrap_err();
1813 assert!(err.to_string().contains("duplicate bundle_id"), "{err}");
1814 }
1815
1816 #[test]
1817 fn duplicate_endpoint_name_rejected() {
1818 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1819 "schema": ENV_MANIFEST_SCHEMA_V1,
1820 "environment": {"id": "local"},
1821 "messaging_endpoints": [
1822 {"name": "n", "provider_type": "messaging.telegram.bot"},
1823 {"name": "n", "provider_type": "messaging.telegram.bot"}
1824 ]
1825 }))
1826 .unwrap();
1827 let err = manifest.validate_shape().unwrap_err();
1828 assert!(err.to_string().contains("duplicate endpoint name"), "{err}");
1829 }
1830
1831 #[test]
1832 fn tenant_selector_without_matcher_rejected() {
1833 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1834 "schema": ENV_MANIFEST_SCHEMA_V1,
1835 "environment": {"id": "local"},
1836 "bundles": [{
1837 "bundle_id": "a",
1838 "bundle_path": "a.gtbundle",
1839 "route_binding": {"tenant_selector": {"tenant": "t", "team": "d"}}
1840 }]
1841 }))
1842 .unwrap();
1843 let err = manifest.validate_shape().unwrap_err();
1844 assert!(err.to_string().contains("tenant_selector"), "{err}");
1845 }
1846
1847 #[test]
1848 fn path_prefix_must_start_with_slash() {
1849 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1850 "schema": ENV_MANIFEST_SCHEMA_V1,
1851 "environment": {"id": "local"},
1852 "bundles": [{
1853 "bundle_id": "a",
1854 "bundle_path": "a.gtbundle",
1855 "route_binding": {"path_prefixes": ["legal"]}
1856 }]
1857 }))
1858 .unwrap();
1859 let err = manifest.validate_shape().unwrap_err();
1860 assert!(err.to_string().contains("must start with `/`"), "{err}");
1861 }
1862
1863 #[test]
1864 fn duplicate_link_in_endpoint_rejected() {
1865 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1866 "schema": ENV_MANIFEST_SCHEMA_V1,
1867 "environment": {"id": "local"},
1868 "messaging_endpoints": [{
1869 "name": "n",
1870 "provider_type": "messaging.telegram.bot",
1871 "links": ["bundle-a", "bundle-a"]
1872 }]
1873 }))
1874 .unwrap();
1875 let err = manifest.validate_shape().unwrap_err();
1876 assert!(err.to_string().contains("duplicate link"), "{err}");
1877 assert!(err.to_string().contains("bundle-a"), "{err}");
1878 }
1879
1880 #[test]
1881 fn trust_root_bootstrap_parses() {
1882 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1883 "schema": ENV_MANIFEST_SCHEMA_V1,
1884 "environment": {"id": "local"},
1885 "trust_root": "bootstrap"
1886 }))
1887 .unwrap();
1888 assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
1889 manifest.validate_shape().expect("valid");
1890 }
1891
1892 #[test]
1893 fn template_round_trips_through_manifest_and_shape_validation() {
1894 let manifest: EnvManifest =
1899 serde_json::from_str(MANIFEST_TEMPLATE_JSON).expect("template parses as EnvManifest");
1900 manifest
1901 .validate_shape()
1902 .expect("template passes validate_shape");
1903 assert_eq!(manifest.schema, ENV_MANIFEST_SCHEMA_V1);
1904 assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
1906 assert!(!manifest.secrets.is_empty());
1907 assert!(!manifest.bundles.is_empty());
1908 assert!(!manifest.messaging_endpoints.is_empty());
1909 }
1910
1911 #[test]
1912 fn two_dept_worked_example_parses() {
1913 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
1915 "schema": ENV_MANIFEST_SCHEMA_V1,
1916 "environment": {"id": "local", "public_base_url": null},
1917 "trust_root": "bootstrap",
1918 "secrets": [
1919 {
1920 "path": "legal/_/messaging-telegram/telegram_bot_token",
1921 "from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
1922 },
1923 {
1924 "path": "accounting/_/messaging-telegram/telegram_bot_token",
1925 "from_env": "TELEGRAM_ACCOUNTING_BOT_TOKEN"
1926 }
1927 ],
1928 "bundles": [
1929 {
1930 "bundle_id": "realbot-legal",
1931 "bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
1932 "route_binding": {
1933 "hosts": [],
1934 "path_prefixes": ["/legal"],
1935 "tenant_selector": {"tenant": "legal", "team": "default"}
1936 }
1937 },
1938 {
1939 "bundle_id": "realbot-accounting",
1940 "bundle_path": "bundle-workspace-accounting/realbot-accounting.gtbundle",
1941 "route_binding": {
1942 "hosts": [],
1943 "path_prefixes": ["/accounting"],
1944 "tenant_selector": {"tenant": "accounting", "team": "default"}
1945 }
1946 }
1947 ],
1948 "messaging_endpoints": [
1949 {
1950 "name": "realbot-legal",
1951 "provider_type": "messaging.telegram.bot",
1952 "links": ["realbot-legal"]
1953 },
1954 {
1955 "name": "realbot-accounting",
1956 "provider_type": "messaging.telegram.bot",
1957 "links": ["realbot-accounting"]
1958 }
1959 ]
1960 }))
1961 .unwrap();
1962 manifest.validate_shape().expect("worked example is valid");
1963 assert_eq!(manifest.secrets.len(), 2);
1964 assert_eq!(manifest.bundles.len(), 2);
1965 assert_eq!(manifest.messaging_endpoints.len(), 2);
1966 }
1967
1968 fn question_ids(spec: &FormSpec) -> BTreeSet<String> {
1971 let mut ids = BTreeSet::new();
1972 for q in &spec.questions {
1973 match &q.list {
1974 Some(list) => {
1975 for field in &list.fields {
1976 assert!(
1977 ids.insert(format!("{}.{}", q.id, field.id)),
1978 "duplicate question id {}.{}",
1979 q.id,
1980 field.id
1981 );
1982 }
1983 }
1984 None => {
1985 assert!(ids.insert(q.id.clone()), "duplicate question id {}", q.id);
1986 }
1987 }
1988 }
1989 ids
1990 }
1991
1992 fn answers(value: Value) -> AnswerSet {
1993 AnswerSet {
1994 form_id: ENV_MANIFEST_FORM_ID.to_string(),
1995 spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
1996 answers: value,
1997 meta: None,
1998 }
1999 }
2000
2001 #[test]
2002 fn form_spec_never_asks_for_secret_values() {
2003 let spec = manifest_form_spec();
2007 for q in &spec.questions {
2008 assert!(!q.secret, "`{}` must not be a secret question", q.id);
2009 match q.kind {
2010 QuestionType::List => {
2011 let list = q.list.as_ref().unwrap_or_else(|| {
2012 panic!("List question `{}` is missing its row definition", q.id)
2013 });
2014 assert!(!list.fields.is_empty(), "`{}` has no row fields", q.id);
2015 for field in &list.fields {
2016 assert!(!field.secret, "`{}.{}` must not be secret", q.id, field.id);
2017 }
2018 }
2019 _ => assert!(q.list.is_none(), "`{}` is not a List but has rows", q.id),
2020 }
2021 }
2022 }
2023
2024 fn webchat_gui_default(spec: &FormSpec) -> Option<&str> {
2025 spec.questions
2026 .iter()
2027 .find(|q| q.id == "webchat_gui")
2028 .expect("webchat_gui question")
2029 .default_value
2030 .as_deref()
2031 }
2032
2033 #[test]
2034 fn webchat_gui_default_is_env_aware() {
2035 assert_eq!(
2039 webchat_gui_default(&manifest_form_spec_for_env(GUI_DEFAULT_ENV_ID)),
2040 Some("true")
2041 );
2042 assert_eq!(
2043 webchat_gui_default(&manifest_form_spec_for_env("prod")),
2044 Some("false")
2045 );
2046 assert_eq!(webchat_gui_default(&manifest_form_spec()), Some("true"));
2048 }
2049
2050 #[test]
2051 fn required_marks_the_normal_mode_surface() {
2052 let spec = manifest_form_spec();
2056 let mut required = BTreeSet::new();
2057 for q in &spec.questions {
2058 if q.required {
2059 required.insert(q.id.clone());
2060 }
2061 for field in q.list.iter().flat_map(|l| &l.fields) {
2062 if field.required {
2063 required.insert(format!("{}.{}", q.id, field.id));
2064 }
2065 }
2066 }
2067 let expected: BTreeSet<String> = [
2068 "environment_id",
2069 "trust_root_bootstrap",
2070 "webchat_gui",
2071 "secrets.path",
2075 "bundles.bundle_id",
2076 "bundles.bundle_path",
2077 "messaging_endpoints.name",
2078 "messaging_endpoints.provider_type",
2079 ]
2080 .into_iter()
2081 .map(str::to_string)
2082 .collect();
2083 assert_eq!(required, expected);
2084 }
2085
2086 #[test]
2087 fn derived_row_defaults_evaluate_from_sibling_columns() {
2088 let spec = manifest_form_spec();
2094
2095 fn list_fields<'a>(spec: &'a FormSpec, id: &str) -> &'a [QuestionSpec] {
2096 spec.questions
2097 .iter()
2098 .find(|q| q.id == id)
2099 .and_then(|q| q.list.as_ref())
2100 .map(|l| l.fields.as_slice())
2101 .unwrap_or_else(|| panic!("list `{id}` missing"))
2102 }
2103 fn field<'a>(fields: &'a [QuestionSpec], id: &str) -> &'a QuestionSpec {
2104 fields
2105 .iter()
2106 .find(|f| f.id == id)
2107 .unwrap_or_else(|| panic!("field `{id}` missing"))
2108 }
2109 let bundle_row = serde_json::json!({ "bundle_id": "legal" });
2111 let endpoint_row = serde_json::json!({ "name": "legal" });
2112
2113 let bundles = list_fields(&spec, "bundles");
2114 let prefixes = field(bundles, "route_path_prefixes");
2115 assert!(prefixes.computed_overridable);
2116 assert_eq!(
2117 prefixes
2118 .computed
2119 .as_ref()
2120 .and_then(|e| e.evaluate_value(&bundle_row)),
2121 Some(serde_json::json!("/legal"))
2122 );
2123 let tenant = field(bundles, "route_tenant");
2124 assert!(tenant.computed_overridable);
2125 assert_eq!(
2126 tenant
2127 .computed
2128 .as_ref()
2129 .and_then(|e| e.evaluate_value(&bundle_row)),
2130 Some(serde_json::json!("legal"))
2131 );
2132 assert_eq!(
2133 field(bundles, "route_team").default_value.as_deref(),
2134 Some("default")
2135 );
2136
2137 let endpoints = list_fields(&spec, "messaging_endpoints");
2138 let links = field(endpoints, "links");
2139 assert!(links.computed_overridable);
2140 assert_eq!(
2141 links
2142 .computed
2143 .as_ref()
2144 .and_then(|e| e.evaluate_value(&endpoint_row)),
2145 Some(serde_json::json!("legal"))
2146 );
2147
2148 let label = |id: &str| {
2150 spec.questions
2151 .iter()
2152 .find(|q| q.id == id)
2153 .and_then(|q| q.list.as_ref())
2154 .and_then(|l| l.item_label.clone())
2155 };
2156 assert_eq!(label("bundles").as_deref(), Some("bundle"));
2157 assert_eq!(
2158 label("messaging_endpoints").as_deref(),
2159 Some("Messaging endpoint")
2160 );
2161 }
2162
2163 #[test]
2164 fn form_questions_and_manifest_fields_cover_each_other() {
2165 const FIELD_TO_QUESTION: &[(&str, &str)] = &[
2172 ("schema", ""),
2173 ("environment.id", "environment_id"),
2174 ("environment.public_base_url", "public_base_url"),
2175 ("environment.name", ""),
2176 ("environment.region", ""),
2177 ("environment.tenant_org_id", ""),
2178 ("environment.listen_addr", ""),
2179 ("environment.gui_enabled", "webchat_gui"),
2180 ("trust_root", "trust_root_bootstrap"),
2181 ("secrets[].path", "secrets.path"),
2182 ("secrets[].from_env", "secrets.from_env"),
2183 ("packs[].slot", ""),
2184 ("packs[].kind", ""),
2185 ("packs[].pack_ref", ""),
2186 ("packs[].answers_ref", ""),
2187 ("bundles[].bundle_id", "bundles.bundle_id"),
2188 ("bundles[].bundle_path", "bundles.bundle_path"),
2189 ("bundles[].bundle_source_uri", ""),
2191 ("bundles[].bundle_digest", ""),
2192 ("bundles[].revisions[].name", ""),
2194 ("bundles[].revisions[].bundle_path", ""),
2195 ("bundles[].revisions[].weight_percent", ""),
2196 ("bundles[].revisions[].weight_bps", ""),
2197 ("bundles[].revisions[].drain_seconds", ""),
2198 ("bundles[].revisions[].abort_metrics", ""),
2199 ("bundles[].revisions[].bundle_source_uri", ""),
2200 ("bundles[].revisions[].bundle_digest", ""),
2201 ("bundles[].customer_id", "bundles.customer_id"),
2202 ("bundles[].revenue_share[].party_id", ""),
2204 ("bundles[].revenue_share[].basis_points", ""),
2205 ("bundles[].status", ""),
2206 ("bundles[].config_overrides", "bundles.config_overrides"),
2207 ("bundles[].route_binding.hosts", "bundles.route_hosts"),
2208 (
2209 "bundles[].route_binding.path_prefixes",
2210 "bundles.route_path_prefixes",
2211 ),
2212 (
2213 "bundles[].route_binding.tenant_selector.tenant",
2214 "bundles.route_tenant",
2215 ),
2216 (
2217 "bundles[].route_binding.tenant_selector.team",
2218 "bundles.route_team",
2219 ),
2220 ("extensions[].kind", ""),
2221 ("extensions[].pack_ref", ""),
2222 ("extensions[].instance_id", ""),
2223 ("extensions[].answers_ref", ""),
2224 ("messaging_endpoints[].name", "messaging_endpoints.name"),
2225 (
2226 "messaging_endpoints[].provider_type",
2227 "messaging_endpoints.provider_type",
2228 ),
2229 ("messaging_endpoints[].links", "messaging_endpoints.links"),
2230 (
2231 "messaging_endpoints[].welcome_flow.bundle_id",
2232 "messaging_endpoints.welcome_bundle_id",
2233 ),
2234 (
2235 "messaging_endpoints[].welcome_flow.pack_id",
2236 "messaging_endpoints.welcome_pack_id",
2237 ),
2238 (
2239 "messaging_endpoints[].welcome_flow.flow_id",
2240 "messaging_endpoints.welcome_flow_id",
2241 ),
2242 (
2243 "messaging_endpoints[].secret_refs",
2244 "messaging_endpoints.secret_refs",
2245 ),
2246 ];
2247
2248 fn collect_leaves(node: &Value, prefix: &str, out: &mut BTreeSet<String>) {
2249 if let Some(items) = node.get("items") {
2250 if items.get("properties").is_some() {
2251 collect_leaves(items, &format!("{prefix}[]"), out);
2252 } else {
2253 out.insert(prefix.to_string());
2254 }
2255 return;
2256 }
2257 if let Some(props) = node.get("properties").and_then(Value::as_object) {
2258 for (key, sub) in props {
2259 let path = if prefix.is_empty() {
2260 key.clone()
2261 } else {
2262 format!("{prefix}.{key}")
2263 };
2264 collect_leaves(sub, &path, out);
2265 }
2266 return;
2267 }
2268 out.insert(prefix.to_string());
2269 }
2270
2271 let mut schema_leaves = BTreeSet::new();
2272 collect_leaves(&manifest_schema(), "", &mut schema_leaves);
2273 let mapped_fields: BTreeSet<String> = FIELD_TO_QUESTION
2274 .iter()
2275 .map(|(field, _)| field.to_string())
2276 .collect();
2277 assert_eq!(
2278 schema_leaves, mapped_fields,
2279 "manifest fields and the coverage table drifted — map every \
2280 schema leaf to a question (or `\"\"` for constants)"
2281 );
2282
2283 const FORM_ONLY_QUESTIONS: &[&str] = &["secrets.source"];
2288 let mut mapped_questions: BTreeSet<String> = FIELD_TO_QUESTION
2289 .iter()
2290 .filter(|(_, q)| !q.is_empty())
2291 .map(|(_, q)| q.to_string())
2292 .collect();
2293 mapped_questions.extend(FORM_ONLY_QUESTIONS.iter().map(|q| q.to_string()));
2294 assert_eq!(
2295 question_ids(&manifest_form_spec()),
2296 mapped_questions,
2297 "form questions and the coverage table drifted — every question \
2298 must map to a manifest field (or be a declared form-only discriminator)"
2299 );
2300 }
2301
2302 #[test]
2303 fn answers_round_trip_to_valid_manifest() {
2304 let spec = manifest_form_spec();
2305 let set = answers(serde_json::json!({
2306 "environment_id": "local",
2307 "public_base_url": "https://bots.example.com",
2308 "trust_root_bootstrap": true,
2309 "webchat_gui": true,
2310 "secrets": [
2311 {
2312 "path": "legal/_/messaging-telegram/telegram_bot_token",
2313 "from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
2314 }
2315 ],
2316 "bundles": [
2317 {
2318 "bundle_id": "realbot-legal",
2319 "bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
2320 "customer_id": "acme",
2321 "config_overrides": "{\"realbot\": {\"mode\": \"prod\"}}",
2322 "route_path_prefixes": "/legal, /legal-archive",
2323 "route_tenant": "legal",
2324 "route_team": "default"
2325 }
2326 ],
2327 "messaging_endpoints": [
2328 {
2329 "name": "realbot-legal",
2330 "provider_type": "messaging.telegram.bot",
2331 "links": "realbot-legal, realbot-audit",
2332 "welcome_bundle_id": "realbot-legal",
2333 "welcome_pack_id": "realbot",
2334 "welcome_flow_id": "main"
2335 }
2336 ]
2337 }));
2338
2339 let report = qa_spec::validate(&spec, &set.answers);
2340 assert!(report.valid, "answers must pass the form spec: {report:?}");
2341
2342 let manifest = answers_to_manifest(&set).expect("converts");
2343 manifest.validate_shape().expect("round-trip passes shape");
2344
2345 assert_eq!(manifest.environment.id, "local");
2346 assert_eq!(
2347 manifest.environment.public_base_url.as_deref(),
2348 Some("https://bots.example.com")
2349 );
2350 assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
2351 assert_eq!(manifest.secrets.len(), 1);
2352 assert_eq!(
2353 manifest.secrets[0].from_env.as_deref(),
2354 Some("TELEGRAM_LEGAL_BOT_TOKEN"),
2355 "from_env carries the variable NAME"
2356 );
2357 let bundle = &manifest.bundles[0];
2358 assert_eq!(bundle.customer_id.as_deref(), Some("acme"));
2359 assert_eq!(
2360 bundle.config_overrides.as_ref().unwrap()["realbot"]["mode"],
2361 serde_json::json!("prod")
2362 );
2363 let rb = bundle.route_binding.as_ref().expect("route binding built");
2364 assert_eq!(rb.path_prefixes, ["/legal", "/legal-archive"]);
2365 assert!(rb.hosts.is_empty());
2366 let selector = rb.tenant_selector.as_ref().expect("selector built");
2367 assert_eq!(
2368 (selector.tenant.as_str(), selector.team.as_str()),
2369 ("legal", "default")
2370 );
2371 let ep = &manifest.messaging_endpoints[0];
2372 assert_eq!(ep.links, ["realbot-legal", "realbot-audit"]);
2373 assert_eq!(
2374 ep.welcome_flow,
2375 Some(ManifestWelcomeFlow {
2376 bundle_id: "realbot-legal".to_string(),
2377 pack_id: "realbot".to_string(),
2378 flow_id: "main".to_string(),
2379 })
2380 );
2381 assert!(ep.secret_refs.is_empty());
2382 }
2383
2384 #[test]
2385 fn minimal_answers_convert_leniently() {
2386 let manifest = answers_to_manifest(&answers(serde_json::json!({
2390 "environment_id": "demo",
2391 "trust_root_bootstrap": false
2392 })))
2393 .expect("converts");
2394 manifest.validate_shape().expect("valid shape");
2395 assert_eq!(manifest.environment.id, "demo");
2396 assert_eq!(manifest.environment.public_base_url, None);
2397 assert_eq!(manifest.trust_root, None);
2398 assert!(manifest.secrets.is_empty());
2399 assert!(manifest.bundles.is_empty());
2400 assert!(manifest.messaging_endpoints.is_empty());
2401 }
2402
2403 #[test]
2404 fn minimal_answers_pass_form_validation() {
2405 let result = qa_spec::validate(
2410 &manifest_form_spec(),
2411 &serde_json::json!({
2412 "environment_id": "local",
2413 "trust_root_bootstrap": true,
2414 "webchat_gui": true
2415 }),
2416 );
2417 assert!(
2418 result.valid,
2419 "errors: {:?}, missing: {:?}, unknown: {:?}",
2420 result.errors, result.missing_required, result.unknown_fields
2421 );
2422 }
2423
2424 #[test]
2425 fn answers_conversion_errors_name_the_gap() {
2426 for (label, value, needle) in [
2427 (
2428 "missing environment_id",
2429 serde_json::json!({}),
2430 "environment_id",
2431 ),
2432 (
2433 "tenant without team",
2434 serde_json::json!({
2435 "environment_id": "local",
2436 "bundles": [{
2437 "bundle_id": "b", "bundle_path": "b.gtbundle",
2438 "route_tenant": "legal"
2439 }]
2440 }),
2441 "route_team",
2442 ),
2443 (
2444 "partial welcome flow",
2445 serde_json::json!({
2446 "environment_id": "local",
2447 "messaging_endpoints": [{
2448 "name": "n", "provider_type": "messaging.telegram.bot",
2449 "welcome_bundle_id": "b"
2450 }]
2451 }),
2452 "welcome_pack_id",
2453 ),
2454 (
2455 "config_overrides not an object",
2456 serde_json::json!({
2457 "environment_id": "local",
2458 "bundles": [{
2459 "bundle_id": "b", "bundle_path": "b.gtbundle",
2460 "config_overrides": "[1, 2]"
2461 }]
2462 }),
2463 "config_overrides",
2464 ),
2465 (
2466 "row field of the wrong type",
2467 serde_json::json!({
2468 "environment_id": "local",
2469 "secrets": [{"path": "a/_/p/tok", "from_env": 7}]
2470 }),
2471 "secrets[0].from_env",
2472 ),
2473 ] {
2474 let err = answers_to_manifest(&answers(value)).unwrap_err();
2475 assert!(
2476 err.to_string().contains(needle),
2477 "{label}: expected `{needle}` in `{err}`"
2478 );
2479 }
2480 }
2481
2482 #[test]
2483 fn answers_form_identity_is_checked() {
2484 let mut set = answers(serde_json::json!({"environment_id": "local"}));
2485 set.form_id = "something.else".to_string();
2486 let err = answers_to_manifest(&set).unwrap_err();
2487 assert!(err.to_string().contains(ENV_MANIFEST_FORM_ID), "{err}");
2488
2489 let mut set = answers(serde_json::json!({"environment_id": "local"}));
2490 set.spec_version = "0".to_string();
2491 let err = answers_to_manifest(&set).unwrap_err();
2492 assert!(err.to_string().contains("spec_version"), "{err}");
2493 }
2494
2495 #[test]
2496 fn form_spec_enforces_required_row_fields() {
2497 let spec = manifest_form_spec();
2502 let report = qa_spec::validate(
2503 &spec,
2504 &serde_json::json!({
2505 "environment_id": "local",
2506 "trust_root_bootstrap": false,
2507 "secrets": [{"source": "env", "from_env": "X"}],
2508 "bundles": [],
2509 "messaging_endpoints": []
2510 }),
2511 );
2512 assert!(!report.valid);
2513 assert!(
2514 report
2515 .errors
2516 .iter()
2517 .any(|e| format!("{e:?}").contains("path")),
2518 "missing row field must be reported: {report:?}"
2519 );
2520 }
2521
2522 #[test]
2525 fn multi_revision_deserialize_and_validate() {
2526 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2527 "schema": ENV_MANIFEST_SCHEMA_V1,
2528 "environment": {"id": "local"},
2529 "bundles": [{
2530 "bundle_id": "canary-test",
2531 "revisions": [
2532 {"name": "stable", "bundle_path": "stable.gtbundle", "weight_bps": 9000},
2533 {"name": "canary", "bundle_path": "canary.gtbundle", "weight_bps": 1000}
2534 ]
2535 }]
2536 }))
2537 .unwrap();
2538 manifest.validate_shape().expect("valid multi-revision");
2539 let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2540 assert_eq!(revs.len(), 2);
2541 assert_eq!(revs[0].name, "stable");
2542 assert_eq!(revs[1].weight_bps, Some(1000));
2543 }
2544
2545 #[test]
2546 fn multi_revision_equal_split_no_weights() {
2547 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2548 "schema": ENV_MANIFEST_SCHEMA_V1,
2549 "environment": {"id": "local"},
2550 "bundles": [{
2551 "bundle_id": "ab-test",
2552 "revisions": [
2553 {"name": "a", "bundle_path": "a.gtbundle"},
2554 {"name": "b", "bundle_path": "b.gtbundle"},
2555 {"name": "c", "bundle_path": "c.gtbundle"}
2556 ]
2557 }]
2558 }))
2559 .unwrap();
2560 manifest.validate_shape().expect("valid equal-split");
2561 let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2562 let weights = compute_effective_weights_bps(revs);
2563 assert_eq!(weights, vec![3334, 3333, 3333]);
2565 assert_eq!(weights.iter().sum::<u32>(), FULL_TRAFFIC_BPS);
2566 }
2567
2568 #[test]
2569 fn multi_revision_weight_percent_converts_to_bps() {
2570 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2571 "schema": ENV_MANIFEST_SCHEMA_V1,
2572 "environment": {"id": "local"},
2573 "bundles": [{
2574 "bundle_id": "pct-test",
2575 "revisions": [
2576 {"name": "a", "bundle_path": "a.gtbundle", "weight_percent": 70},
2577 {"name": "b", "bundle_path": "b.gtbundle", "weight_percent": 30}
2578 ]
2579 }]
2580 }))
2581 .unwrap();
2582 manifest.validate_shape().expect("valid percent weights");
2583 let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2584 let weights = compute_effective_weights_bps(revs);
2585 assert_eq!(weights, vec![7000, 3000]);
2586 }
2587
2588 #[test]
2589 fn multi_revision_weight_sum_not_10000_fails() {
2590 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2591 "schema": ENV_MANIFEST_SCHEMA_V1,
2592 "environment": {"id": "local"},
2593 "bundles": [{
2594 "bundle_id": "bad-sum",
2595 "revisions": [
2596 {"name": "a", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2597 {"name": "b", "bundle_path": "b.gtbundle", "weight_bps": 3000}
2598 ]
2599 }]
2600 }))
2601 .unwrap();
2602 let err = manifest.validate_shape().unwrap_err();
2603 assert!(err.to_string().contains("8000 bps"), "{err}");
2604 assert!(err.to_string().contains("10000"), "{err}");
2605 }
2606
2607 #[test]
2608 fn both_bundle_path_and_revisions_fails() {
2609 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2610 "schema": ENV_MANIFEST_SCHEMA_V1,
2611 "environment": {"id": "local"},
2612 "bundles": [{
2613 "bundle_id": "both",
2614 "bundle_path": "both.gtbundle",
2615 "revisions": [
2616 {"name": "a", "bundle_path": "a.gtbundle"}
2617 ]
2618 }]
2619 }))
2620 .unwrap();
2621 let err = manifest.validate_shape().unwrap_err();
2622 assert!(err.to_string().contains("mutually exclusive"), "{err}");
2623 }
2624
2625 #[test]
2626 fn bundle_source_uri_round_trips_single_and_multi() {
2627 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2628 "schema": ENV_MANIFEST_SCHEMA_V1,
2629 "environment": {"id": "local"},
2630 "bundles": [
2631 {"bundle_id": "single", "bundle_path": "x.gtbundle",
2632 "bundle_source_uri": "oci://ex/single:v1"},
2633 {"bundle_id": "multi", "revisions": [
2634 {"name": "a", "bundle_path": "a.gtbundle",
2635 "bundle_source_uri": "oci://ex/multi:a"}
2636 ]}
2637 ]
2638 }))
2639 .unwrap();
2640 manifest.validate_shape().unwrap();
2641 assert_eq!(
2642 manifest.bundles[0].bundle_source_uri.as_deref(),
2643 Some("oci://ex/single:v1")
2644 );
2645 assert_eq!(
2646 manifest.bundles[1].revisions.as_ref().unwrap()[0]
2647 .bundle_source_uri
2648 .as_deref(),
2649 Some("oci://ex/multi:a")
2650 );
2651 let back: EnvManifest =
2653 serde_json::from_value(serde_json::to_value(&manifest).unwrap()).unwrap();
2654 assert_eq!(
2655 back.bundles[0].bundle_source_uri.as_deref(),
2656 Some("oci://ex/single:v1")
2657 );
2658 }
2659
2660 #[test]
2661 fn bundle_with_no_path_no_uri_no_revisions_fails() {
2662 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2663 "schema": ENV_MANIFEST_SCHEMA_V1,
2664 "environment": {"id": "local"},
2665 "bundles": [{
2666 "bundle_id": "neither"
2667 }]
2668 }))
2669 .unwrap();
2670 let err = manifest.validate_shape().unwrap_err();
2671 assert!(
2672 err.to_string()
2673 .contains("needs a `bundle_path` or a `bundle_source_uri`"),
2674 "{err}"
2675 );
2676 }
2677
2678 #[test]
2679 fn single_revision_bundle_source_uri_only_is_valid() {
2680 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2681 "schema": ENV_MANIFEST_SCHEMA_V1,
2682 "environment": {"id": "local"},
2683 "bundles": [{
2684 "bundle_id": "remote-only",
2685 "bundle_source_uri": "oci://ex/remote-only:v1",
2686 "bundle_digest": "sha256:abc123"
2687 }]
2688 }))
2689 .unwrap();
2690 manifest.validate_shape().unwrap();
2691 assert!(manifest.bundles[0].bundle_path.is_none());
2692 assert_eq!(
2693 manifest.bundles[0].bundle_source_uri.as_deref(),
2694 Some("oci://ex/remote-only:v1")
2695 );
2696 assert_eq!(
2697 manifest.bundles[0].bundle_digest.as_deref(),
2698 Some("sha256:abc123")
2699 );
2700 let back: EnvManifest =
2702 serde_json::from_value(serde_json::to_value(&manifest).unwrap()).unwrap();
2703 assert_eq!(
2704 back.bundles[0].bundle_digest.as_deref(),
2705 Some("sha256:abc123")
2706 );
2707 }
2708
2709 #[test]
2710 fn bundle_level_source_uri_with_revisions_fails() {
2711 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2712 "schema": ENV_MANIFEST_SCHEMA_V1,
2713 "environment": {"id": "local"},
2714 "bundles": [{
2715 "bundle_id": "split",
2716 "bundle_source_uri": "oci://ex/split:v1",
2717 "revisions": [
2718 {"name": "a", "bundle_path": "a.gtbundle"}
2719 ]
2720 }]
2721 }))
2722 .unwrap();
2723 let err = manifest.validate_shape().unwrap_err();
2724 assert!(err.to_string().contains("single-revision form"), "{err}");
2725 }
2726
2727 #[test]
2728 fn bundle_level_digest_with_revisions_fails() {
2729 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2730 "schema": ENV_MANIFEST_SCHEMA_V1,
2731 "environment": {"id": "local"},
2732 "bundles": [{
2733 "bundle_id": "split-digest",
2734 "bundle_digest": "sha256:abc123",
2735 "revisions": [
2736 {"name": "a", "bundle_path": "a.gtbundle"}
2737 ]
2738 }]
2739 }))
2740 .unwrap();
2741 let err = manifest.validate_shape().unwrap_err();
2742 assert!(err.to_string().contains("single-revision form"), "{err}");
2743 }
2744
2745 #[test]
2746 fn malformed_bundle_digest_fails() {
2747 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2748 "schema": ENV_MANIFEST_SCHEMA_V1,
2749 "environment": {"id": "local"},
2750 "bundles": [{
2751 "bundle_id": "bad-digest",
2752 "bundle_path": "x.gtbundle",
2753 "bundle_digest": "deadbeef"
2754 }]
2755 }))
2756 .unwrap();
2757 let err = manifest.validate_shape().unwrap_err();
2758 assert!(
2759 err.to_string().contains("must be a `sha256:<hex>` string"),
2760 "{err}"
2761 );
2762 }
2763
2764 #[test]
2765 fn malformed_per_revision_bundle_digest_fails() {
2766 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2767 "schema": ENV_MANIFEST_SCHEMA_V1,
2768 "environment": {"id": "local"},
2769 "bundles": [{
2770 "bundle_id": "split-bad-digest",
2771 "revisions": [
2772 {"name": "a", "bundle_path": "a.gtbundle", "bundle_digest": "nope"}
2773 ]
2774 }]
2775 }))
2776 .unwrap();
2777 let err = manifest.validate_shape().unwrap_err();
2778 assert!(
2779 err.to_string().contains("must be a `sha256:<hex>` string"),
2780 "{err}"
2781 );
2782 }
2783
2784 #[test]
2785 fn duplicate_revision_name_fails() {
2786 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2787 "schema": ENV_MANIFEST_SCHEMA_V1,
2788 "environment": {"id": "local"},
2789 "bundles": [{
2790 "bundle_id": "dups",
2791 "revisions": [
2792 {"name": "same", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2793 {"name": "same", "bundle_path": "b.gtbundle", "weight_bps": 5000}
2794 ]
2795 }]
2796 }))
2797 .unwrap();
2798 let err = manifest.validate_shape().unwrap_err();
2799 assert!(err.to_string().contains("duplicate revision name"), "{err}");
2800 }
2801
2802 #[test]
2803 fn mixed_set_unset_weights_fails() {
2804 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2805 "schema": ENV_MANIFEST_SCHEMA_V1,
2806 "environment": {"id": "local"},
2807 "bundles": [{
2808 "bundle_id": "mixed",
2809 "revisions": [
2810 {"name": "a", "bundle_path": "a.gtbundle", "weight_bps": 5000},
2811 {"name": "b", "bundle_path": "b.gtbundle"}
2812 ]
2813 }]
2814 }))
2815 .unwrap();
2816 let err = manifest.validate_shape().unwrap_err();
2817 assert!(err.to_string().contains("mixing set and unset"), "{err}");
2818 }
2819
2820 #[test]
2821 fn weight_percent_and_bps_on_same_revision_fails() {
2822 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2823 "schema": ENV_MANIFEST_SCHEMA_V1,
2824 "environment": {"id": "local"},
2825 "bundles": [{
2826 "bundle_id": "clash",
2827 "revisions": [
2828 {"name": "a", "bundle_path": "a.gtbundle",
2829 "weight_percent": 50, "weight_bps": 5000},
2830 {"name": "b", "bundle_path": "b.gtbundle",
2831 "weight_percent": 50, "weight_bps": 5000}
2832 ]
2833 }]
2834 }))
2835 .unwrap();
2836 let err = manifest.validate_shape().unwrap_err();
2837 assert!(err.to_string().contains("mutually exclusive"), "{err}");
2838 }
2839
2840 #[test]
2841 fn empty_revisions_array_fails() {
2842 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2843 "schema": ENV_MANIFEST_SCHEMA_V1,
2844 "environment": {"id": "local"},
2845 "bundles": [{
2846 "bundle_id": "empty-revs",
2847 "revisions": []
2848 }]
2849 }))
2850 .unwrap();
2851 let err = manifest.validate_shape().unwrap_err();
2852 assert!(err.to_string().contains("must not be empty"), "{err}");
2853 }
2854
2855 #[test]
2856 fn answers_to_manifest_stays_single_revision() {
2857 let set = answers(serde_json::json!({
2859 "environment_id": "local",
2860 "trust_root_bootstrap": false,
2861 "bundles": [{
2862 "bundle_id": "b",
2863 "bundle_path": "b.gtbundle"
2864 }]
2865 }));
2866 let manifest = answers_to_manifest(&set).expect("converts");
2867 manifest.validate_shape().expect("valid shape");
2868 assert!(manifest.bundles[0].bundle_path.is_some());
2869 assert!(manifest.bundles[0].revisions.is_none());
2870 }
2871
2872 #[test]
2873 fn single_revision_equal_split_is_full_traffic() {
2874 let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
2875 "schema": ENV_MANIFEST_SCHEMA_V1,
2876 "environment": {"id": "local"},
2877 "bundles": [{
2878 "bundle_id": "solo",
2879 "revisions": [
2880 {"name": "only", "bundle_path": "only.gtbundle"}
2881 ]
2882 }]
2883 }))
2884 .unwrap();
2885 manifest.validate_shape().expect("valid");
2886 let revs = manifest.bundles[0].revisions.as_ref().unwrap();
2887 let weights = compute_effective_weights_bps(revs);
2888 assert_eq!(weights, vec![FULL_TRAFFIC_BPS]);
2889 }
2890
2891 fn bundle_with(extra: serde_json::Value) -> EnvManifest {
2894 let mut bundle = serde_json::json!({
2895 "bundle_id": "b",
2896 "bundle_path": "b.gtbundle"
2897 });
2898 bundle
2899 .as_object_mut()
2900 .unwrap()
2901 .extend(extra.as_object().unwrap().clone());
2902 serde_json::from_value(serde_json::json!({
2903 "schema": ENV_MANIFEST_SCHEMA_V1,
2904 "environment": {"id": "local"},
2905 "bundles": [bundle]
2906 }))
2907 .expect("deserialize")
2908 }
2909
2910 #[test]
2911 fn revenue_share_valid_sum_passes() {
2912 let manifest = bundle_with(serde_json::json!({
2913 "revenue_share": [
2914 {"party_id": "greentic", "basis_points": 8000},
2915 {"party_id": "partner", "basis_points": 2000}
2916 ]
2917 }));
2918 manifest.validate_shape().expect("valid 10000 sum");
2919 assert_eq!(manifest.bundles[0].revenue_share.as_ref().unwrap().len(), 2);
2920 }
2921
2922 #[test]
2923 fn revenue_share_wrong_sum_fails() {
2924 let manifest = bundle_with(serde_json::json!({
2925 "revenue_share": [
2926 {"party_id": "greentic", "basis_points": 8000},
2927 {"party_id": "partner", "basis_points": 1000}
2928 ]
2929 }));
2930 let err = manifest.validate_shape().unwrap_err();
2931 assert!(err.to_string().contains("9000"), "{err}");
2932 assert!(err.to_string().contains("10000"), "{err}");
2933 }
2934
2935 #[test]
2936 fn revenue_share_empty_fails() {
2937 let manifest = bundle_with(serde_json::json!({ "revenue_share": [] }));
2938 let err = manifest.validate_shape().unwrap_err();
2939 assert!(err.to_string().contains("must not be empty"), "{err}");
2940 }
2941
2942 #[test]
2943 fn revenue_share_duplicate_party_fails() {
2944 let manifest = bundle_with(serde_json::json!({
2945 "revenue_share": [
2946 {"party_id": "greentic", "basis_points": 5000},
2947 {"party_id": "greentic", "basis_points": 5000}
2948 ]
2949 }));
2950 let err = manifest.validate_shape().unwrap_err();
2951 assert!(
2952 err.to_string().contains("duplicate revenue_share party_id"),
2953 "{err}"
2954 );
2955 }
2956
2957 #[test]
2958 fn status_deserializes_lowercase() {
2959 for (text, want) in [
2960 ("active", BundleDeploymentStatus::Active),
2961 ("paused", BundleDeploymentStatus::Paused),
2962 ("archived", BundleDeploymentStatus::Archived),
2963 ] {
2964 let manifest = bundle_with(serde_json::json!({ "status": text }));
2965 manifest.validate_shape().expect("valid status");
2966 assert_eq!(manifest.bundles[0].status, Some(want), "status `{text}`");
2967 }
2968 }
2969
2970 #[test]
2971 fn unknown_status_rejected_at_parse() {
2972 let err = serde_json::from_value::<EnvManifest>(serde_json::json!({
2973 "schema": ENV_MANIFEST_SCHEMA_V1,
2974 "environment": {"id": "local"},
2975 "bundles": [{"bundle_id": "b", "bundle_path": "b.gtbundle", "status": "running"}]
2976 }))
2977 .unwrap_err();
2978 assert!(
2979 err.to_string().contains("status") || err.to_string().contains("variant"),
2980 "{err}"
2981 );
2982 }
2983}