use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use qa_spec::spec::ListSpec;
use qa_spec::spec::question::QuestionPolicy;
use qa_spec::{AnswerSet, FormSpec, QuestionSpec, QuestionType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::OpError;
use super::bundles::{RouteBindingPayload, TenantSelectorPayload};
pub const ENV_MANIFEST_SCHEMA_V1: &str = "greentic.env-manifest.v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvManifest {
pub schema: String,
pub environment: ManifestEnvironment,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_root: Option<TrustRootDirective>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<ManifestSecret>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bundles: Vec<ManifestBundle>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messaging_endpoints: Vec<ManifestEndpoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEnvironment {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_base_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustRootDirective {
Bootstrap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestSecret {
pub path: String,
pub from_env: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestBundle {
pub bundle_id: String,
pub bundle_path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route_binding: Option<RouteBindingPayload>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEndpoint {
pub name: String,
pub provider_type: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub welcome_flow: Option<ManifestWelcomeFlow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret_refs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestWelcomeFlow {
pub bundle_id: String,
pub pack_id: String,
pub flow_id: String,
}
impl EnvManifest {
pub fn validate_shape(&self) -> Result<(), OpError> {
if self.schema != ENV_MANIFEST_SCHEMA_V1 {
return Err(OpError::InvalidArgument(format!(
"manifest schema `{}` is not the expected `{ENV_MANIFEST_SCHEMA_V1}`",
self.schema
)));
}
if self.environment.id.trim().is_empty() {
return Err(OpError::InvalidArgument(
"environment.id must not be empty".to_string(),
));
}
let mut secret_paths = BTreeSet::new();
for s in &self.secrets {
let rel_path = s.path.trim_start_matches('/');
super::secrets::validate_dev_store_secret_path(rel_path)?;
if !secret_paths.insert(rel_path) {
return Err(OpError::InvalidArgument(format!(
"duplicate secret path `{rel_path}` in manifest secrets[] \
(order-dependent last-write-wins is never what you want)"
)));
}
if s.from_env.trim().is_empty() {
return Err(OpError::InvalidArgument(format!(
"secret `{rel_path}`: from_env must name an environment variable"
)));
}
}
let mut bundle_ids = BTreeSet::new();
for b in &self.bundles {
if b.bundle_id.trim().is_empty() {
return Err(OpError::InvalidArgument(
"bundles[].bundle_id must not be empty".to_string(),
));
}
if !bundle_ids.insert(b.bundle_id.as_str()) {
return Err(OpError::InvalidArgument(format!(
"duplicate bundle_id `{}` in manifest bundles[]",
b.bundle_id
)));
}
if let Some(rb) = &b.route_binding {
rb.validate()?;
for prefix in &rb.path_prefixes {
if !prefix.starts_with('/') {
return Err(OpError::InvalidArgument(format!(
"bundle `{}` route_binding.path_prefixes entry `{prefix}` \
must start with `/`",
b.bundle_id
)));
}
}
}
}
let mut endpoint_names = BTreeSet::new();
for ep in &self.messaging_endpoints {
if ep.name.trim().is_empty() {
return Err(OpError::InvalidArgument(
"messaging_endpoints[].name must not be empty".to_string(),
));
}
if ep.provider_type.trim().is_empty() {
return Err(OpError::InvalidArgument(format!(
"endpoint `{}`: provider_type must not be empty",
ep.name
)));
}
if !endpoint_names.insert(ep.name.as_str()) {
return Err(OpError::InvalidArgument(format!(
"duplicate endpoint name `{}` in manifest messaging_endpoints[]",
ep.name
)));
}
let mut link_set = BTreeSet::new();
for link in &ep.links {
if !link_set.insert(link.as_str()) {
return Err(OpError::InvalidArgument(format!(
"endpoint `{}`: duplicate link `{link}` in links[]",
ep.name
)));
}
}
}
Ok(())
}
}
pub const MANIFEST_TEMPLATE_JSON: &str = r#"{
"schema": "greentic.env-manifest.v1",
"environment": {
"id": "local",
"public_base_url": null
},
"trust_root": "bootstrap",
"secrets": [
{
"path": "default/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_BOT_TOKEN"
}
],
"bundles": [
{
"bundle_id": "example-bundle",
"bundle_path": "example-bundle.gtbundle",
"route_binding": {
"path_prefixes": ["/example"],
"tenant_selector": { "tenant": "default", "team": "default" }
}
}
],
"messaging_endpoints": [
{
"name": "example-endpoint",
"provider_type": "messaging.telegram.bot",
"links": ["example-bundle"],
"welcome_flow": {
"bundle_id": "example-bundle",
"pack_id": "example-pack",
"flow_id": "main"
}
}
]
}
"#;
pub fn manifest_schema() -> Value {
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EnvManifest",
"description": "greentic.env-manifest.v1 — declarative environment wiring for `gtc op env apply`",
"type": "object",
"required": ["schema", "environment"],
"additionalProperties": false,
"properties": {
"schema": {"const": ENV_MANIFEST_SCHEMA_V1},
"environment": {
"type": "object",
"required": ["id"],
"additionalProperties": false,
"properties": {
"id": {"type": "string", "description": "Environment id; v1 bootstraps only `local`"},
"public_base_url": {"type": ["string", "null"], "description": "origin-only URL; absent = leave untouched"}
}
},
"trust_root": {"enum": ["bootstrap", null], "description": "`bootstrap` seeds the operator key (idempotent)"},
"secrets": {
"type": "array",
"description": "dev-store secret entries; always-put (values cannot be diffed until A9)",
"items": {
"type": "object",
"required": ["path", "from_env"],
"additionalProperties": false,
"properties": {
"path": {"type": "string", "description": "<tenant>/<team>/<pack>/<name>"},
"from_env": {"type": "string", "description": "env var holding the value; values never appear in the manifest"}
}
}
},
"bundles": {
"type": "array",
"items": {
"type": "object",
"required": ["bundle_id", "bundle_path"],
"additionalProperties": false,
"properties": {
"bundle_id": {"type": "string"},
"bundle_path": {"type": "string", "description": "local .gtbundle; relative to the manifest file"},
"customer_id": {"type": ["string", "null"], "description": "required for non-local envs (B10)"},
"config_overrides": {"type": ["object", "null"], "description": "<pack_id> -> <key> -> <json>; absent=untouched, {}=clear, map=replace"},
"route_binding": {
"type": ["object", "null"],
"properties": {
"hosts": {"type": "array", "items": {"type": "string"}},
"path_prefixes": {"type": "array", "items": {"type": "string"}},
"tenant_selector": {
"type": ["object", "null"],
"required": ["tenant", "team"],
"properties": {"tenant": {"type": "string"}, "team": {"type": "string"}}
}
}
}
}
}
},
"messaging_endpoints": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "provider_type"],
"additionalProperties": false,
"properties": {
"name": {"type": "string", "description": "natural key: matches existing endpoints by (provider_type, display_name)"},
"provider_type": {"type": "string"},
"links": {"type": "array", "items": {"type": "string"}},
"welcome_flow": {
"type": ["object", "null"],
"required": ["bundle_id", "pack_id", "flow_id"],
"additionalProperties": false,
"properties": {
"bundle_id": {"type": "string"},
"pack_id": {"type": "string"},
"flow_id": {"type": "string"}
}
},
"secret_refs": {"type": "array", "items": {"type": "string"}}
}
}
}
}
})
}
pub const ENV_MANIFEST_FORM_ID: &str = "greentic.env-manifest";
pub const ENV_MANIFEST_FORM_VERSION: &str = "1";
pub fn manifest_form_spec() -> FormSpec {
let mut environment_id = question(
"environment_id",
QuestionType::String,
"Environment id",
"Environment to apply to. v1 apply can bootstrap only `local`; any \
other id must already exist.",
true,
);
environment_id.default_value = Some("local".to_string());
let public_base_url = question(
"public_base_url",
QuestionType::String,
"Public base URL",
"Origin-only URL persisted on the environment (e.g. \
https://bots.example.com). Leave empty to keep the current value.",
false,
);
let mut trust_root_bootstrap = question(
"trust_root_bootstrap",
QuestionType::Boolean,
"Bootstrap the trust root?",
"Seed the environment trust root with the local operator key \
(idempotent; required once before bundles can be staged).",
true,
);
trust_root_bootstrap.default_value = Some("true".to_string());
let mut secrets = question(
"secrets",
QuestionType::List,
"Secrets",
"Dev-store secret entries. Each names the environment VARIABLE \
holding the value — values never go into a manifest.",
false,
);
secrets.list = Some(ListSpec {
min_items: None,
max_items: None,
fields: vec![
question(
"path",
QuestionType::String,
"Secret path",
"`<tenant>/<team>/<pack>/<name>`, e.g. \
default/_/messaging-telegram/telegram_bot_token",
true,
),
question(
"from_env",
QuestionType::String,
"Environment variable name",
"Name of the variable holding the secret value (e.g. \
TELEGRAM_BOT_TOKEN) — the name, never the value.",
true,
),
],
});
let mut bundles = question(
"bundles",
QuestionType::List,
"Bundles",
"Bundle deployments for this environment.",
false,
);
bundles.list = Some(ListSpec {
min_items: None,
max_items: None,
fields: vec![
question(
"bundle_id",
QuestionType::String,
"Bundle id",
"Natural key — unique within the manifest.",
true,
),
question(
"bundle_path",
QuestionType::String,
"Bundle path",
"Local `.gtbundle`. Relative paths resolve against the \
manifest file's directory.",
true,
),
question(
"customer_id",
QuestionType::String,
"Customer id",
"Billing principal — required by apply for non-`local` \
environments.",
false,
),
question(
"config_overrides",
QuestionType::String,
"Config overrides (JSON)",
"JSON object `{\"<pack_id>\": {\"<key>\": <value>}}`. Empty \
= leave untouched; `{}` = explicit clear.",
false,
),
question(
"route_hosts",
QuestionType::String,
"Route hosts",
"Comma-separated host names for the route binding.",
false,
),
question(
"route_path_prefixes",
QuestionType::String,
"Route path prefixes",
"Comma-separated HTTP path prefixes, each starting with `/` \
(e.g. /legal).",
false,
),
question(
"route_tenant",
QuestionType::String,
"Route tenant",
"Tenant for the route binding's tenant selector — set \
together with `route_team`.",
false,
),
question(
"route_team",
QuestionType::String,
"Route team",
"Team for the route binding's tenant selector — set \
together with `route_tenant`.",
false,
),
],
});
let mut messaging_endpoints = question(
"messaging_endpoints",
QuestionType::List,
"Messaging endpoints",
"Messaging endpoints and their bundle links.",
false,
);
messaging_endpoints.list = Some(ListSpec {
min_items: None,
max_items: None,
fields: vec![
question(
"name",
QuestionType::String,
"Endpoint name",
"Manifest-local handle and display name. Upsert key \
together with the provider type.",
true,
),
question(
"provider_type",
QuestionType::String,
"Provider type",
"Provider class, e.g. messaging.telegram.bot.",
true,
),
question(
"links",
QuestionType::String,
"Linked bundle ids",
"Comma-separated `bundle_id`s this endpoint admits.",
false,
),
question(
"welcome_bundle_id",
QuestionType::String,
"Welcome flow: bundle id",
"Set the three welcome_* fields together (or none).",
false,
),
question(
"welcome_pack_id",
QuestionType::String,
"Welcome flow: pack id",
"Set the three welcome_* fields together (or none).",
false,
),
question(
"welcome_flow_id",
QuestionType::String,
"Welcome flow: flow id",
"Set the three welcome_* fields together (or none).",
false,
),
question(
"secret_refs",
QuestionType::String,
"Secret refs",
"Comma-separated secret refs forwarded on endpoint create.",
false,
),
],
});
FormSpec {
id: ENV_MANIFEST_FORM_ID.to_string(),
title: "Environment setup".to_string(),
version: ENV_MANIFEST_FORM_VERSION.to_string(),
description: Some(format!(
"Authors a `{ENV_MANIFEST_SCHEMA_V1}` manifest — the durable, \
re-appliable desired-state document for one environment."
)),
presentation: None,
progress_policy: None,
secrets_policy: None,
store: Vec::new(),
validations: Vec::new(),
includes: Vec::new(),
questions: vec![
environment_id,
public_base_url,
trust_root_bootstrap,
bundles,
messaging_endpoints,
secrets,
],
}
}
fn question(
id: &str,
kind: QuestionType,
title: &str,
description: &str,
required: bool,
) -> QuestionSpec {
QuestionSpec {
id: id.to_string(),
kind,
title: title.to_string(),
title_i18n: None,
description: Some(description.to_string()),
description_i18n: None,
required,
choices: None,
default_value: None,
secret: false,
visible_if: None,
constraint: None,
list: None,
computed: None,
policy: QuestionPolicy::default(),
computed_overridable: false,
}
}
pub fn answers_to_manifest(answers: &AnswerSet) -> Result<EnvManifest, OpError> {
if answers.form_id != ENV_MANIFEST_FORM_ID {
return Err(OpError::InvalidArgument(format!(
"answers form_id `{}` is not `{ENV_MANIFEST_FORM_ID}`",
answers.form_id
)));
}
if answers.spec_version != ENV_MANIFEST_FORM_VERSION {
return Err(OpError::InvalidArgument(format!(
"answers spec_version `{}` is not `{ENV_MANIFEST_FORM_VERSION}` \
— re-run the wizard against the current form",
answers.spec_version
)));
}
let map = answers
.answers
.as_object()
.ok_or_else(|| OpError::InvalidArgument("answers must be a JSON object".to_string()))?;
let environment_id = opt_string(map, "environment_id")?.ok_or_else(|| {
OpError::InvalidArgument("answers: environment_id must be a non-empty string".to_string())
})?;
let public_base_url = opt_string(map, "public_base_url")?;
let trust_root = match map.get("trust_root_bootstrap") {
None | Some(Value::Null) | Some(Value::Bool(false)) => None,
Some(Value::Bool(true)) => Some(TrustRootDirective::Bootstrap),
Some(other) => {
return Err(OpError::InvalidArgument(format!(
"answers: trust_root_bootstrap must be a boolean, got {other}"
)));
}
};
let mut secrets = Vec::new();
for (idx, row) in rows(map, "secrets")?.iter().enumerate() {
let row = row_object("secrets", idx, row)?;
secrets.push(ManifestSecret {
path: req_row_string("secrets", idx, row, "path")?,
from_env: req_row_string("secrets", idx, row, "from_env")?,
});
}
let mut bundles = Vec::new();
for (idx, row) in rows(map, "bundles")?.iter().enumerate() {
let row = row_object("bundles", idx, row)?;
let bundle_id = req_row_string("bundles", idx, row, "bundle_id")?;
let config_overrides = match opt_row_string("bundles", idx, row, "config_overrides")? {
None => None,
Some(raw) => Some(
serde_json::from_str::<BTreeMap<String, BTreeMap<String, Value>>>(&raw).map_err(
|err| {
OpError::InvalidArgument(format!(
"answers: bundles[{idx}] (`{bundle_id}`): config_overrides is \
not a `<pack_id> -> <key> -> <value>` JSON object: {err}"
))
},
)?,
),
};
let hosts = split_csv(opt_row_string("bundles", idx, row, "route_hosts")?);
let path_prefixes = split_csv(opt_row_string("bundles", idx, row, "route_path_prefixes")?);
let tenant_selector = match (
opt_row_string("bundles", idx, row, "route_tenant")?,
opt_row_string("bundles", idx, row, "route_team")?,
) {
(Some(tenant), Some(team)) => Some(TenantSelectorPayload { tenant, team }),
(None, None) => None,
_ => {
return Err(OpError::InvalidArgument(format!(
"answers: bundles[{idx}] (`{bundle_id}`): set route_tenant and \
route_team together (or neither)"
)));
}
};
let route_binding =
if hosts.is_empty() && path_prefixes.is_empty() && tenant_selector.is_none() {
None
} else {
Some(RouteBindingPayload {
hosts,
path_prefixes,
tenant_selector,
})
};
bundles.push(ManifestBundle {
bundle_id,
bundle_path: PathBuf::from(req_row_string("bundles", idx, row, "bundle_path")?),
customer_id: opt_row_string("bundles", idx, row, "customer_id")?,
config_overrides,
route_binding,
});
}
let mut messaging_endpoints = Vec::new();
for (idx, row) in rows(map, "messaging_endpoints")?.iter().enumerate() {
let row = row_object("messaging_endpoints", idx, row)?;
let name = req_row_string("messaging_endpoints", idx, row, "name")?;
let welcome_flow = match (
opt_row_string("messaging_endpoints", idx, row, "welcome_bundle_id")?,
opt_row_string("messaging_endpoints", idx, row, "welcome_pack_id")?,
opt_row_string("messaging_endpoints", idx, row, "welcome_flow_id")?,
) {
(Some(bundle_id), Some(pack_id), Some(flow_id)) => Some(ManifestWelcomeFlow {
bundle_id,
pack_id,
flow_id,
}),
(None, None, None) => None,
_ => {
return Err(OpError::InvalidArgument(format!(
"answers: messaging_endpoints[{idx}] (`{name}`): set \
welcome_bundle_id, welcome_pack_id and welcome_flow_id \
together (or none)"
)));
}
};
messaging_endpoints.push(ManifestEndpoint {
name,
provider_type: req_row_string("messaging_endpoints", idx, row, "provider_type")?,
links: split_csv(opt_row_string("messaging_endpoints", idx, row, "links")?),
welcome_flow,
secret_refs: split_csv(opt_row_string(
"messaging_endpoints",
idx,
row,
"secret_refs",
)?),
});
}
Ok(EnvManifest {
schema: ENV_MANIFEST_SCHEMA_V1.to_string(),
environment: ManifestEnvironment {
id: environment_id,
public_base_url,
},
trust_root,
secrets,
bundles,
messaging_endpoints,
})
}
fn rows<'a>(map: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a [Value], OpError> {
const EMPTY: &[Value] = &[];
match map.get(key) {
None | Some(Value::Null) => Ok(EMPTY),
Some(Value::Array(items)) => Ok(items.as_slice()),
Some(other) => Err(OpError::InvalidArgument(format!(
"answers: {key} must be an array, got {other}"
))),
}
}
fn row_object<'a>(
section: &str,
idx: usize,
row: &'a Value,
) -> Result<&'a serde_json::Map<String, Value>, OpError> {
row.as_object().ok_or_else(|| {
OpError::InvalidArgument(format!(
"answers: {section}[{idx}] must be an object, got {row}"
))
})
}
fn opt_string(map: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OpError> {
opt_string_at(map, key, key)
}
fn opt_string_at(
map: &serde_json::Map<String, Value>,
key: &str,
label: &str,
) -> Result<Option<String>, OpError> {
match map.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => {
let trimmed = s.trim();
Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
}
Some(other) => Err(OpError::InvalidArgument(format!(
"answers: {label} must be a string, got {other}"
))),
}
}
fn opt_row_string(
section: &str,
idx: usize,
row: &serde_json::Map<String, Value>,
key: &str,
) -> Result<Option<String>, OpError> {
opt_string_at(row, key, &format!("{section}[{idx}].{key}"))
}
fn req_row_string(
section: &str,
idx: usize,
row: &serde_json::Map<String, Value>,
key: &str,
) -> Result<String, OpError> {
opt_row_string(section, idx, row, key)?.ok_or_else(|| {
OpError::InvalidArgument(format!(
"answers: {section}[{idx}].{key} must be a non-empty string"
))
})
}
fn split_csv(value: Option<String>) -> Vec<String> {
value
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal(schema: &str) -> EnvManifest {
serde_json::from_value(serde_json::json!({
"schema": schema,
"environment": {"id": "local"}
}))
.expect("minimal manifest parses")
}
#[test]
fn schema_mismatch_rejected() {
let err = minimal("greentic.env-manifest.v2")
.validate_shape()
.unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "{err}");
}
#[test]
fn unknown_top_level_field_rejected_at_parse() {
let err = serde_json::from_value::<EnvManifest>(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"bundlez": []
}))
.unwrap_err();
assert!(err.to_string().contains("bundlez"), "{err}");
}
#[test]
fn valid_secrets_pass_shape_validation() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"secrets": [
{"path": "legal/_/messaging-telegram/telegram_bot_token", "from_env": "A"},
{"path": "accounting/_/messaging-telegram/telegram_bot_token", "from_env": "B"}
]
}))
.unwrap();
manifest.validate_shape().expect("valid");
}
#[test]
fn non_canonical_secret_path_rejected_at_shape() {
for path in [
"credentials/aws",
"legal/default/messaging-telegram/telegram_bot_token",
"legal/_/messaging-telegram/BOT-TOKEN",
] {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"secrets": [{"path": path, "from_env": "X"}]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(
matches!(err, OpError::InvalidArgument(_)),
"path `{path}` got {err}"
);
}
}
#[test]
fn duplicate_secret_path_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"secrets": [
{"path": "legal/_/p/tok", "from_env": "A"},
{"path": "/legal/_/p/tok", "from_env": "B"}
]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("duplicate secret path"), "{err}");
}
#[test]
fn empty_from_env_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"secrets": [{"path": "legal/_/p/tok", "from_env": " "}]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("from_env"), "{err}");
}
#[test]
fn duplicate_bundle_id_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"bundles": [
{"bundle_id": "a", "bundle_path": "a.gtbundle"},
{"bundle_id": "a", "bundle_path": "b.gtbundle"}
]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("duplicate bundle_id"), "{err}");
}
#[test]
fn duplicate_endpoint_name_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"messaging_endpoints": [
{"name": "n", "provider_type": "messaging.telegram.bot"},
{"name": "n", "provider_type": "messaging.telegram.bot"}
]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("duplicate endpoint name"), "{err}");
}
#[test]
fn tenant_selector_without_matcher_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"bundles": [{
"bundle_id": "a",
"bundle_path": "a.gtbundle",
"route_binding": {"tenant_selector": {"tenant": "t", "team": "d"}}
}]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("tenant_selector"), "{err}");
}
#[test]
fn path_prefix_must_start_with_slash() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"bundles": [{
"bundle_id": "a",
"bundle_path": "a.gtbundle",
"route_binding": {"path_prefixes": ["legal"]}
}]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("must start with `/`"), "{err}");
}
#[test]
fn duplicate_link_in_endpoint_rejected() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"messaging_endpoints": [{
"name": "n",
"provider_type": "messaging.telegram.bot",
"links": ["bundle-a", "bundle-a"]
}]
}))
.unwrap();
let err = manifest.validate_shape().unwrap_err();
assert!(err.to_string().contains("duplicate link"), "{err}");
assert!(err.to_string().contains("bundle-a"), "{err}");
}
#[test]
fn trust_root_bootstrap_parses() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local"},
"trust_root": "bootstrap"
}))
.unwrap();
assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
manifest.validate_shape().expect("valid");
}
#[test]
fn template_round_trips_through_manifest_and_shape_validation() {
let manifest: EnvManifest =
serde_json::from_str(MANIFEST_TEMPLATE_JSON).expect("template parses as EnvManifest");
manifest
.validate_shape()
.expect("template passes validate_shape");
assert_eq!(manifest.schema, ENV_MANIFEST_SCHEMA_V1);
assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
assert!(!manifest.secrets.is_empty());
assert!(!manifest.bundles.is_empty());
assert!(!manifest.messaging_endpoints.is_empty());
}
#[test]
fn two_dept_worked_example_parses() {
let manifest: EnvManifest = serde_json::from_value(serde_json::json!({
"schema": ENV_MANIFEST_SCHEMA_V1,
"environment": {"id": "local", "public_base_url": null},
"trust_root": "bootstrap",
"secrets": [
{
"path": "legal/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
},
{
"path": "accounting/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_ACCOUNTING_BOT_TOKEN"
}
],
"bundles": [
{
"bundle_id": "realbot-legal",
"bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
"route_binding": {
"hosts": [],
"path_prefixes": ["/legal"],
"tenant_selector": {"tenant": "legal", "team": "default"}
}
},
{
"bundle_id": "realbot-accounting",
"bundle_path": "bundle-workspace-accounting/realbot-accounting.gtbundle",
"route_binding": {
"hosts": [],
"path_prefixes": ["/accounting"],
"tenant_selector": {"tenant": "accounting", "team": "default"}
}
}
],
"messaging_endpoints": [
{
"name": "realbot-legal",
"provider_type": "messaging.telegram.bot",
"links": ["realbot-legal"]
},
{
"name": "realbot-accounting",
"provider_type": "messaging.telegram.bot",
"links": ["realbot-accounting"]
}
]
}))
.unwrap();
manifest.validate_shape().expect("worked example is valid");
assert_eq!(manifest.secrets.len(), 2);
assert_eq!(manifest.bundles.len(), 2);
assert_eq!(manifest.messaging_endpoints.len(), 2);
}
fn question_ids(spec: &FormSpec) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
for q in &spec.questions {
match &q.list {
Some(list) => {
for field in &list.fields {
assert!(
ids.insert(format!("{}.{}", q.id, field.id)),
"duplicate question id {}.{}",
q.id,
field.id
);
}
}
None => {
assert!(ids.insert(q.id.clone()), "duplicate question id {}", q.id);
}
}
}
ids
}
fn answers(value: Value) -> AnswerSet {
AnswerSet {
form_id: ENV_MANIFEST_FORM_ID.to_string(),
spec_version: ENV_MANIFEST_FORM_VERSION.to_string(),
answers: value,
meta: None,
}
}
#[test]
fn form_spec_never_asks_for_secret_values() {
let spec = manifest_form_spec();
for q in &spec.questions {
assert!(!q.secret, "`{}` must not be a secret question", q.id);
match q.kind {
QuestionType::List => {
let list = q.list.as_ref().unwrap_or_else(|| {
panic!("List question `{}` is missing its row definition", q.id)
});
assert!(!list.fields.is_empty(), "`{}` has no row fields", q.id);
for field in &list.fields {
assert!(!field.secret, "`{}.{}` must not be secret", q.id, field.id);
}
}
_ => assert!(q.list.is_none(), "`{}` is not a List but has rows", q.id),
}
}
}
#[test]
fn required_marks_the_normal_mode_surface() {
let spec = manifest_form_spec();
let mut required = BTreeSet::new();
for q in &spec.questions {
if q.required {
required.insert(q.id.clone());
}
for field in q.list.iter().flat_map(|l| &l.fields) {
if field.required {
required.insert(format!("{}.{}", q.id, field.id));
}
}
}
let expected: BTreeSet<String> = [
"environment_id",
"trust_root_bootstrap",
"secrets.path",
"secrets.from_env",
"bundles.bundle_id",
"bundles.bundle_path",
"messaging_endpoints.name",
"messaging_endpoints.provider_type",
]
.into_iter()
.map(str::to_string)
.collect();
assert_eq!(required, expected);
}
#[test]
fn form_questions_and_manifest_fields_cover_each_other() {
const FIELD_TO_QUESTION: &[(&str, &str)] = &[
("schema", ""),
("environment.id", "environment_id"),
("environment.public_base_url", "public_base_url"),
("trust_root", "trust_root_bootstrap"),
("secrets[].path", "secrets.path"),
("secrets[].from_env", "secrets.from_env"),
("bundles[].bundle_id", "bundles.bundle_id"),
("bundles[].bundle_path", "bundles.bundle_path"),
("bundles[].customer_id", "bundles.customer_id"),
("bundles[].config_overrides", "bundles.config_overrides"),
("bundles[].route_binding.hosts", "bundles.route_hosts"),
(
"bundles[].route_binding.path_prefixes",
"bundles.route_path_prefixes",
),
(
"bundles[].route_binding.tenant_selector.tenant",
"bundles.route_tenant",
),
(
"bundles[].route_binding.tenant_selector.team",
"bundles.route_team",
),
("messaging_endpoints[].name", "messaging_endpoints.name"),
(
"messaging_endpoints[].provider_type",
"messaging_endpoints.provider_type",
),
("messaging_endpoints[].links", "messaging_endpoints.links"),
(
"messaging_endpoints[].welcome_flow.bundle_id",
"messaging_endpoints.welcome_bundle_id",
),
(
"messaging_endpoints[].welcome_flow.pack_id",
"messaging_endpoints.welcome_pack_id",
),
(
"messaging_endpoints[].welcome_flow.flow_id",
"messaging_endpoints.welcome_flow_id",
),
(
"messaging_endpoints[].secret_refs",
"messaging_endpoints.secret_refs",
),
];
fn collect_leaves(node: &Value, prefix: &str, out: &mut BTreeSet<String>) {
if let Some(items) = node.get("items") {
if items.get("properties").is_some() {
collect_leaves(items, &format!("{prefix}[]"), out);
} else {
out.insert(prefix.to_string());
}
return;
}
if let Some(props) = node.get("properties").and_then(Value::as_object) {
for (key, sub) in props {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
collect_leaves(sub, &path, out);
}
return;
}
out.insert(prefix.to_string());
}
let mut schema_leaves = BTreeSet::new();
collect_leaves(&manifest_schema(), "", &mut schema_leaves);
let mapped_fields: BTreeSet<String> = FIELD_TO_QUESTION
.iter()
.map(|(field, _)| field.to_string())
.collect();
assert_eq!(
schema_leaves, mapped_fields,
"manifest fields and the coverage table drifted — map every \
schema leaf to a question (or `\"\"` for constants)"
);
let mapped_questions: BTreeSet<String> = FIELD_TO_QUESTION
.iter()
.filter(|(_, q)| !q.is_empty())
.map(|(_, q)| q.to_string())
.collect();
assert_eq!(
question_ids(&manifest_form_spec()),
mapped_questions,
"form questions and the coverage table drifted — every question \
must map to a manifest field"
);
}
#[test]
fn answers_round_trip_to_valid_manifest() {
let spec = manifest_form_spec();
let set = answers(serde_json::json!({
"environment_id": "local",
"public_base_url": "https://bots.example.com",
"trust_root_bootstrap": true,
"secrets": [
{
"path": "legal/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_LEGAL_BOT_TOKEN"
}
],
"bundles": [
{
"bundle_id": "realbot-legal",
"bundle_path": "bundle-workspace-legal/realbot-legal.gtbundle",
"customer_id": "acme",
"config_overrides": "{\"realbot\": {\"mode\": \"prod\"}}",
"route_path_prefixes": "/legal, /legal-archive",
"route_tenant": "legal",
"route_team": "default"
}
],
"messaging_endpoints": [
{
"name": "realbot-legal",
"provider_type": "messaging.telegram.bot",
"links": "realbot-legal, realbot-audit",
"welcome_bundle_id": "realbot-legal",
"welcome_pack_id": "realbot",
"welcome_flow_id": "main"
}
]
}));
let report = qa_spec::validate(&spec, &set.answers);
assert!(report.valid, "answers must pass the form spec: {report:?}");
let manifest = answers_to_manifest(&set).expect("converts");
manifest.validate_shape().expect("round-trip passes shape");
assert_eq!(manifest.environment.id, "local");
assert_eq!(
manifest.environment.public_base_url.as_deref(),
Some("https://bots.example.com")
);
assert_eq!(manifest.trust_root, Some(TrustRootDirective::Bootstrap));
assert_eq!(manifest.secrets.len(), 1);
assert_eq!(
manifest.secrets[0].from_env, "TELEGRAM_LEGAL_BOT_TOKEN",
"from_env carries the variable NAME"
);
let bundle = &manifest.bundles[0];
assert_eq!(bundle.customer_id.as_deref(), Some("acme"));
assert_eq!(
bundle.config_overrides.as_ref().unwrap()["realbot"]["mode"],
serde_json::json!("prod")
);
let rb = bundle.route_binding.as_ref().expect("route binding built");
assert_eq!(rb.path_prefixes, ["/legal", "/legal-archive"]);
assert!(rb.hosts.is_empty());
let selector = rb.tenant_selector.as_ref().expect("selector built");
assert_eq!(
(selector.tenant.as_str(), selector.team.as_str()),
("legal", "default")
);
let ep = &manifest.messaging_endpoints[0];
assert_eq!(ep.links, ["realbot-legal", "realbot-audit"]);
assert_eq!(
ep.welcome_flow,
Some(ManifestWelcomeFlow {
bundle_id: "realbot-legal".to_string(),
pack_id: "realbot".to_string(),
flow_id: "main".to_string(),
})
);
assert!(ep.secret_refs.is_empty());
}
#[test]
fn minimal_answers_convert_leniently() {
let manifest = answers_to_manifest(&answers(serde_json::json!({
"environment_id": "demo",
"trust_root_bootstrap": false
})))
.expect("converts");
manifest.validate_shape().expect("valid shape");
assert_eq!(manifest.environment.id, "demo");
assert_eq!(manifest.environment.public_base_url, None);
assert_eq!(manifest.trust_root, None);
assert!(manifest.secrets.is_empty());
assert!(manifest.bundles.is_empty());
assert!(manifest.messaging_endpoints.is_empty());
}
#[test]
fn minimal_answers_pass_form_validation() {
let result = qa_spec::validate(
&manifest_form_spec(),
&serde_json::json!({
"environment_id": "local",
"trust_root_bootstrap": true
}),
);
assert!(
result.valid,
"errors: {:?}, missing: {:?}, unknown: {:?}",
result.errors, result.missing_required, result.unknown_fields
);
}
#[test]
fn answers_conversion_errors_name_the_gap() {
for (label, value, needle) in [
(
"missing environment_id",
serde_json::json!({}),
"environment_id",
),
(
"tenant without team",
serde_json::json!({
"environment_id": "local",
"bundles": [{
"bundle_id": "b", "bundle_path": "b.gtbundle",
"route_tenant": "legal"
}]
}),
"route_team",
),
(
"partial welcome flow",
serde_json::json!({
"environment_id": "local",
"messaging_endpoints": [{
"name": "n", "provider_type": "messaging.telegram.bot",
"welcome_bundle_id": "b"
}]
}),
"welcome_pack_id",
),
(
"config_overrides not an object",
serde_json::json!({
"environment_id": "local",
"bundles": [{
"bundle_id": "b", "bundle_path": "b.gtbundle",
"config_overrides": "[1, 2]"
}]
}),
"config_overrides",
),
(
"row field of the wrong type",
serde_json::json!({
"environment_id": "local",
"secrets": [{"path": "a/_/p/tok", "from_env": 7}]
}),
"secrets[0].from_env",
),
] {
let err = answers_to_manifest(&answers(value)).unwrap_err();
assert!(
err.to_string().contains(needle),
"{label}: expected `{needle}` in `{err}`"
);
}
}
#[test]
fn answers_form_identity_is_checked() {
let mut set = answers(serde_json::json!({"environment_id": "local"}));
set.form_id = "something.else".to_string();
let err = answers_to_manifest(&set).unwrap_err();
assert!(err.to_string().contains(ENV_MANIFEST_FORM_ID), "{err}");
let mut set = answers(serde_json::json!({"environment_id": "local"}));
set.spec_version = "0".to_string();
let err = answers_to_manifest(&set).unwrap_err();
assert!(err.to_string().contains("spec_version"), "{err}");
}
#[test]
fn form_spec_enforces_required_row_fields() {
let spec = manifest_form_spec();
let report = qa_spec::validate(
&spec,
&serde_json::json!({
"environment_id": "local",
"trust_root_bootstrap": false,
"secrets": [{"path": "default/_/p/tok"}],
"bundles": [],
"messaging_endpoints": []
}),
);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|e| format!("{e:?}").contains("from_env")),
"missing row field must be reported: {report:?}"
);
}
}