1use std::collections::{BTreeMap, BTreeSet};
68use std::io::IsTerminal;
69use std::path::{Path, PathBuf};
70
71use greentic_deploy_spec::{
72 BundleDeploymentStatus, CapabilitySlot, CustomerId, DeploymentId, EnvId, Environment,
73 MessagingEndpoint, RouteBinding,
74};
75use serde_json::{Value, json};
76use sha2::{Digest, Sha256};
77
78use crate::environment::{EnvironmentStore, LocalFsStore, trust_root as store_trust_root};
79use crate::runtime_secrets::SecretValue;
80
81use super::bundles::{
82 BundleUpdatePayload, RevenueShareEntryPayload, RouteBindingPayload, convert_revenue_share,
83 into_route_binding,
84};
85use super::config::ConfigSetPayload;
86use super::deploy::BundleDeployPayload;
87use super::env::EnvInitPayload;
88use super::env_manifest::{
89 ENV_MANIFEST_SCHEMA_V1, EnvManifest, ManifestBundle, ManifestEndpoint, ManifestRevision,
90 ManifestWelcomeFlow, TrustRootDirective, compute_effective_weights_bps, manifest_schema,
91};
92use super::env_packs::EnvPackBindingPayload;
93use super::extensions::ExtensionBindingPayload;
94use super::messaging::{
95 EndpointAddPayload, EndpointLinkBundlePayload, EndpointSetWelcomeFlowPayload, EndpointSummary,
96};
97use super::secrets::SecretsPutPayload;
98use super::trust_root::TrustRootBootstrapPayload;
99use super::{OpError, OpFlags, OpOutcome};
100
101const NOUN: &str = "env";
102const VERB: &str = "apply";
103
104const DEFAULT_UPDATED_BY: &str = "env-apply";
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum ApplyAction {
117 Create,
118 Update,
119 Put,
120 NoOp,
121}
122
123impl ApplyAction {
124 fn as_str(self) -> &'static str {
125 match self {
126 ApplyAction::Create => "create",
127 ApplyAction::Update => "update",
128 ApplyAction::Put => "put",
129 ApplyAction::NoOp => "no-op",
130 }
131 }
132
133 fn counts_as_drift(self) -> bool {
140 matches!(self, ApplyAction::Create | ApplyAction::Update)
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum ApplyStepKind {
146 EnsureEnvironment,
147 UpdateHostConfig,
148 BootstrapTrustRoot,
149 AddPackBinding,
150 UpdatePackBinding,
151 PutSecret,
152 DeployBundle,
153 DeploySplit,
154 UpdateBundle,
155 AddExtension,
156 UpdateExtension,
157 AddEndpoint,
158 LinkEndpoint,
159 SetWelcomeFlow,
160}
161
162impl ApplyStepKind {
163 fn label(self) -> &'static str {
164 match self {
165 ApplyStepKind::EnsureEnvironment => "ensure-environment",
166 ApplyStepKind::UpdateHostConfig => "update-host-config",
167 ApplyStepKind::BootstrapTrustRoot => "bootstrap-trust-root",
168 ApplyStepKind::AddPackBinding => "add-pack-binding",
169 ApplyStepKind::UpdatePackBinding => "update-pack-binding",
170 ApplyStepKind::PutSecret => "put-secret",
171 ApplyStepKind::DeployBundle => "deploy-bundle",
172 ApplyStepKind::DeploySplit => "deploy-split",
173 ApplyStepKind::UpdateBundle => "update-bundle",
174 ApplyStepKind::AddExtension => "add-extension",
175 ApplyStepKind::UpdateExtension => "update-extension",
176 ApplyStepKind::AddEndpoint => "add-endpoint",
177 ApplyStepKind::LinkEndpoint => "link-endpoint",
178 ApplyStepKind::SetWelcomeFlow => "set-welcome-flow",
179 }
180 }
181}
182
183#[derive(Debug, Clone)]
187struct SplitRevisionEntry {
188 name: String,
189 resolved_path: PathBuf,
190 expected_digest: String,
191 weight_bps: u32,
192 drain_seconds: Option<u32>,
193 bundle_source_uri: Option<String>,
196}
197
198#[derive(Debug, Clone)]
201enum EndpointRef {
202 Existing(String),
203 CreatedByName(String),
204}
205
206#[derive(Debug, Clone)]
208enum StepOp {
209 None,
210 EnvInit {
211 public_base_url: Option<String>,
212 },
213 SetPublicUrl {
214 url: String,
215 },
216 TrustRootBootstrap,
217 PutSecret {
221 path: String,
222 },
223 Deploy {
224 payload: Box<BundleDeployPayload>,
225 expected_digest: String,
228 },
229 DeploySplit {
233 env_id: String,
234 bundle_id: String,
235 customer_id: Option<String>,
236 config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
237 route_binding: Option<RouteBindingPayload>,
238 revenue_share: Option<Vec<RevenueShareEntryPayload>>,
242 revisions: Vec<SplitRevisionEntry>,
243 },
244 UpdateHostConfig(Box<ConfigSetPayload>),
245 AddPackBinding(Box<EnvPackBindingPayload>),
246 UpdatePackBinding(Box<EnvPackBindingPayload>),
247 AddExtension(Box<ExtensionBindingPayload>),
248 UpdateExtension(Box<ExtensionBindingPayload>),
249 BundleUpdate(Box<BundleUpdatePayload>),
250 EndpointAdd(Box<EndpointAddPayload>),
251 EndpointLink {
252 endpoint: EndpointRef,
253 bundle_id: String,
254 },
255 WelcomeFlow {
256 endpoint: EndpointRef,
257 flow: ManifestWelcomeFlow,
258 },
259}
260
261#[derive(Debug, Clone)]
262struct ApplyStep {
263 kind: ApplyStepKind,
264 key: String,
265 action: ApplyAction,
266 detail: String,
267 idempotency_key: Option<String>,
268 op: StepOp,
269}
270
271impl ApplyStep {
272 fn no_op(kind: ApplyStepKind, key: impl Into<String>, detail: impl Into<String>) -> Self {
273 Self {
274 kind,
275 key: key.into(),
276 action: ApplyAction::NoOp,
277 detail: detail.into(),
278 idempotency_key: None,
279 op: StepOp::None,
280 }
281 }
282
283 fn to_json(&self) -> Value {
284 json!({
285 "kind": self.kind.label(),
286 "key": self.key,
287 "action": self.action.as_str(),
288 "detail": self.detail,
289 "idempotency_key": self.idempotency_key,
290 })
291 }
292}
293
294struct ResolvedRevision {
298 spec: ManifestRevision,
299 resolved_path: PathBuf,
300 digest: String,
301 weight_bps: u32,
304}
305
306struct ResolvedBundle {
311 spec: ManifestBundle,
312 customer_id: CustomerId,
316 revisions: Vec<ResolvedRevision>,
318}
319
320struct ResolvedEndpoint {
322 spec: ManifestEndpoint,
323 matched: Option<MessagingEndpoint>,
325}
326
327struct ApplyContext {
329 env_id: EnvId,
330 manifest: EnvManifest,
331 secret_values: BTreeMap<String, SecretValue>,
337 prompted_paths: BTreeSet<String>,
340 store_satisfied_paths: BTreeSet<String>,
344 bundles: Vec<ResolvedBundle>,
345 endpoints: Vec<ResolvedEndpoint>,
346 env: Option<Environment>,
347 canonical_public_base_url: Option<String>,
349 missing: Vec<MissingItem>,
352 warnings: Vec<String>,
353 updated_by: String,
354 manifest_dir: PathBuf,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
370struct MissingItem {
371 kind: MissingKind,
372 key: String,
375 source: String,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum MissingKind {
381 SecretValue,
382 BundleArtifact,
383}
384
385impl MissingKind {
386 fn as_str(self) -> &'static str {
387 match self {
388 MissingKind::SecretValue => "secret_value",
389 MissingKind::BundleArtifact => "bundle_artifact",
390 }
391 }
392}
393
394impl MissingItem {
395 fn to_json(&self) -> Value {
396 json!({
397 "kind": self.kind.as_str(),
398 "key": self.key,
399 "source": self.source,
400 })
401 }
402}
403
404#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
406pub enum ApplyMode {
407 #[default]
409 Apply,
410 DryRun,
413 Check,
417}
418
419impl ApplyMode {
420 pub(super) fn as_str(self) -> &'static str {
421 match self {
422 ApplyMode::Apply => "apply",
423 ApplyMode::DryRun => "dry-run",
424 ApplyMode::Check => "check",
425 }
426 }
427}
428
429#[derive(Debug, Clone, Default)]
437pub struct ApplyOptions {
438 pub mode: ApplyMode,
439 pub updated_by: Option<String>,
442 pub yes: bool,
444 pub non_interactive: bool,
448 pub prefilled_secrets: BTreeMap<String, SecretValue>,
454}
455
456pub fn emit_answers_template(path: &Path) -> Result<OpOutcome, OpError> {
463 std::fs::write(path, super::env_manifest::MANIFEST_TEMPLATE_JSON).map_err(|source| {
464 OpError::Io {
465 path: path.to_path_buf(),
466 source,
467 }
468 })?;
469 Ok(OpOutcome::new(
470 NOUN,
471 VERB,
472 json!({
473 "manifest_schema": ENV_MANIFEST_SCHEMA_V1,
474 "mode": "emit-answers-template",
475 "path": path,
476 }),
477 ))
478}
479
480pub fn apply(
483 store: &LocalFsStore,
484 flags: &OpFlags,
485 opts: ApplyOptions,
486) -> Result<OpOutcome, OpError> {
487 let interactive = opts.mode == ApplyMode::Apply
492 && !opts.non_interactive
493 && std::io::stdin().is_terminal()
494 && std::io::stderr().is_terminal();
495 let prompter: Option<&SecretPrompter> = if interactive {
496 Some(&prompt_secret_value)
497 } else {
498 None
499 };
500 apply_with_lookups(
501 store,
502 flags,
503 opts,
504 &|name| std::env::var(name).ok(),
505 prompter,
506 )
507}
508
509type SecretPrompter = dyn Fn(&str, &str) -> Option<String>;
515
516fn prompt_secret_value(path: &str, from_env: &str) -> Option<String> {
523 let prompt = if from_env.is_empty() {
524 format!("secret `{path}`: enter value (hidden; empty to abort): ")
525 } else {
526 format!("secret `{path}`: ${from_env} is unset — enter value (hidden; empty to abort): ")
527 };
528 let value = rpassword::prompt_password(prompt).ok()?;
529 (!value.is_empty()).then_some(value)
530}
531
532fn apply_with_lookups(
537 store: &LocalFsStore,
538 flags: &OpFlags,
539 opts: ApplyOptions,
540 env_lookup: &dyn Fn(&str) -> Option<String>,
541 prompter: Option<&SecretPrompter>,
542) -> Result<OpOutcome, OpError> {
543 if flags.schema_only {
544 return Ok(OpOutcome::new(NOUN, VERB, manifest_schema()));
545 }
546 let ApplyOptions {
547 mode,
548 updated_by,
549 yes,
550 non_interactive,
551 prefilled_secrets,
552 } = opts;
553 let yes = yes || non_interactive;
554 let manifest_path = flags.answers.clone().ok_or_else(|| {
555 OpError::InvalidArgument(
556 "env apply requires `--answers <manifest.json>` (a greentic.env-manifest.v1 \
557 document; see `gtc op env apply --schema`)"
558 .to_string(),
559 )
560 })?;
561 let manifest: EnvManifest = super::load_answers(&manifest_path)?;
562 manifest.validate_shape()?;
563 let manifest_dir = manifest_path
564 .parent()
565 .filter(|p| !p.as_os_str().is_empty())
566 .unwrap_or_else(|| Path::new("."))
567 .to_path_buf();
568 let updated_by = updated_by.unwrap_or_else(|| DEFAULT_UPDATED_BY.to_string());
569
570 let ctx = resolve_and_validate(
571 store,
572 manifest,
573 &manifest_dir,
574 updated_by,
575 env_lookup,
576 prompter,
577 &prefilled_secrets,
578 )?;
579 let steps = diff(store, &ctx)?;
580 render_plan(&steps, &ctx.warnings, &ctx.missing);
581
582 match mode {
583 ApplyMode::DryRun => {
584 return Ok(OpOutcome::new(
585 NOUN,
586 VERB,
587 report_json(&ctx, &steps, mode.as_str(), None),
588 ));
589 }
590 ApplyMode::Check => {
591 let diffable_pending = steps.iter().filter(|s| s.action.counts_as_drift()).count();
596 if diffable_pending > 0 {
597 return Err(OpError::Conflict(format!(
598 "env `{}` is not converged: {diffable_pending} pending change(s) — \
599 run `gtc op env apply --answers <manifest>` to reconcile (see the \
600 plan above for the step list)",
601 ctx.env_id.as_str()
602 )));
603 }
604 return Ok(OpOutcome::new(
605 NOUN,
606 VERB,
607 report_json(&ctx, &steps, mode.as_str(), None),
608 ));
609 }
610 ApplyMode::Apply => {}
611 }
612
613 if !ctx.missing.is_empty() {
617 return Err(OpError::InvalidArgument(format!(
618 "cannot apply: {} missing input(s): {} — export the named variable(s) / \
619 provide the artifact(s) and re-run (on a TTY, missing secret values are \
620 prompted for)",
621 ctx.missing.len(),
622 ctx.missing
623 .iter()
624 .map(|m| format!("{} `{}` ({})", m.kind.as_str(), m.key, m.source))
625 .collect::<Vec<_>>()
626 .join(", "),
627 )));
628 }
629
630 let pending = steps
631 .iter()
632 .filter(|s| s.action != ApplyAction::NoOp)
633 .count();
634 if pending > 0 && !yes && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
635 eprint!(
636 "apply {pending} change(s) to env `{}`? [y/N] ",
637 ctx.env_id.as_str()
638 );
639 let mut line = String::new();
640 std::io::stdin()
641 .read_line(&mut line)
642 .map_err(|source| OpError::Io {
643 path: PathBuf::from("<stdin>"),
644 source,
645 })?;
646 let answer = line.trim().to_ascii_lowercase();
647 if answer != "y" && answer != "yes" {
648 return Err(OpError::InvalidArgument(
649 "aborted by user (pass --yes to skip the confirmation)".to_string(),
650 ));
651 }
652 }
653
654 execute(store, &ctx, &steps)?;
655 let verify = verify(store, &ctx)?;
656 Ok(OpOutcome::new(
657 NOUN,
658 VERB,
659 report_json(&ctx, &steps, "apply", Some(verify)),
660 ))
661}
662
663fn resolve_and_validate(
666 store: &LocalFsStore,
667 manifest: EnvManifest,
668 manifest_dir: &Path,
669 updated_by: String,
670 env_lookup: &dyn Fn(&str) -> Option<String>,
671 prompter: Option<&SecretPrompter>,
672 prefilled_secrets: &BTreeMap<String, SecretValue>,
673) -> Result<ApplyContext, OpError> {
674 let env_id = EnvId::try_from(manifest.environment.id.as_str())
675 .map_err(|e| OpError::InvalidArgument(format!("environment.id: {e}")))?;
676 let canonical_public_base_url =
677 super::env::parse_optional_public_base_url(&manifest.environment.public_base_url)?;
678
679 let env = if store.exists(&env_id)? {
680 Some(store.load(&env_id)?)
681 } else {
682 if env_id.as_str() != crate::defaults::LOCAL_ENV_ID {
688 return Err(OpError::NotFound(format!(
689 "environment `{env_id}` not found — `env apply` reconciles an existing \
690 named environment but does not create one. Run `op env create {env_id}` \
691 first, then re-run apply (or use `local`, which apply bootstraps)."
692 )));
693 }
694 None
695 };
696
697 if !manifest.secrets.is_empty()
705 && let Some(env) = &env
706 {
707 let secrets_pack = super::secrets::require_secrets_pack(env, &env_id)?;
708 if secrets_pack.kind.path() != super::secrets::DEV_STORE_KIND_PATH {
709 return Err(OpError::NotYetImplemented(format!(
710 "manifest secrets[] write through the dev-store only; env `{env_id}` \
711 binds `{}` — backend dispatch beyond the dev-store lands in A9 \
712 (env-pack registry)",
713 secrets_pack.kind
714 )));
715 }
716 }
717 let env_dir = store.env_dir(&env_id)?;
730 let mut missing = Vec::new();
731 let mut secret_values = BTreeMap::new();
732 let mut prompted_paths = BTreeSet::new();
733 let mut store_satisfied_paths = BTreeSet::new();
734 for s in &manifest.secrets {
735 let value = match &s.from_env {
736 Some(from_env) => match env_lookup(from_env).filter(|v| !v.is_empty()) {
737 Some(v) => Some(v),
738 None => prompter.and_then(|p| {
739 let v = p(&s.path, from_env).filter(|v| !v.is_empty());
740 if v.is_some() {
741 prompted_paths.insert(s.path.clone());
742 }
743 v
744 }),
745 },
746 None => {
747 let prefilled = prefilled_secrets
754 .get(&s.path)
755 .map(|value| value.expose())
756 .filter(|value| !value.is_empty());
757 if let Some(value) = prefilled {
758 Some(value.to_string())
759 } else if super::secrets::dev_store_has(&env_dir, &env_id, &s.path)? {
760 store_satisfied_paths.insert(s.path.clone());
763 continue;
764 } else {
765 prompter.and_then(|p| p(&s.path, "").filter(|v| !v.is_empty()))
767 }
768 }
769 };
770 match value {
771 Some(v) => {
772 secret_values.insert(s.path.clone(), SecretValue::from(v));
773 }
774 None => missing.push(MissingItem {
775 kind: MissingKind::SecretValue,
776 key: s.path.clone(),
777 source: match &s.from_env {
778 Some(from_env) => format!("env:{from_env}"),
779 None => "paste".to_string(),
780 },
781 }),
782 }
783 }
784
785 let mut resolved_bundles = Vec::with_capacity(manifest.bundles.len());
790 for b in &manifest.bundles {
791 let customer_id = super::bundles::resolve_customer_id(&env_id, b.customer_id.clone())?;
792
793 if b.bundle_path.is_none() && b.revisions.is_none() {
799 let uri = b
800 .bundle_source_uri
801 .as_deref()
802 .expect("validate_shape: single-revision URI-only carries bundle_source_uri");
803 let fetched = match super::bundle_fetch::fetch_bundle_uri_to_local(uri) {
804 Ok(path) => path,
805 Err(OpError::Fetch(message)) => {
808 missing.push(MissingItem {
809 kind: MissingKind::BundleArtifact,
810 key: b.bundle_id.clone(),
811 source: format!("uri:{uri} ({message})"),
812 });
813 continue;
814 }
815 Err(other) => return Err(other),
817 };
818 let digest = resolved_artifact_digest(
819 &fetched,
820 b.bundle_digest.as_deref(),
821 &format!("bundle `{}`", b.bundle_id),
822 )?;
823 resolved_bundles.push(ResolvedBundle {
824 spec: b.clone(),
825 customer_id,
826 revisions: vec![ResolvedRevision {
827 spec: ManifestRevision {
828 name: "default".to_string(),
829 bundle_path: fetched.clone(),
832 weight_percent: None,
833 weight_bps: Some(super::deploy::FULL_TRAFFIC_BPS),
834 drain_seconds: None,
835 abort_metrics: Vec::new(),
836 bundle_source_uri: None,
839 bundle_digest: None,
840 },
841 resolved_path: fetched,
842 digest,
843 weight_bps: super::deploy::FULL_TRAFFIC_BPS,
844 }],
845 });
846 continue;
847 }
848
849 let artifact_specs: Vec<(&std::path::Path, Option<&ManifestRevision>)> =
851 if let Some(bp) = &b.bundle_path {
852 vec![(bp.as_path(), None)]
853 } else if let Some(revs) = &b.revisions {
854 revs.iter()
855 .map(|r| (r.bundle_path.as_path(), Some(r)))
856 .collect()
857 } else {
858 continue;
860 };
861
862 let mut any_missing = false;
863 let mut resolved_revs = Vec::with_capacity(artifact_specs.len());
864
865 let weights: Vec<u32> = if let Some(revs) = &b.revisions {
868 compute_effective_weights_bps(revs)
869 } else {
870 vec![super::deploy::FULL_TRAFFIC_BPS]
871 };
872
873 for (i, (artifact_path, rev_spec)) in artifact_specs.iter().enumerate() {
874 let resolved_path = if artifact_path.is_absolute() {
875 artifact_path.to_path_buf()
876 } else {
877 manifest_dir.join(artifact_path)
878 };
879 if !resolved_path.is_file() {
880 let key = match rev_spec {
881 Some(r) => format!("{}:{}", b.bundle_id, r.name),
882 None => b.bundle_id.clone(),
883 };
884 missing.push(MissingItem {
885 kind: MissingKind::BundleArtifact,
886 key,
887 source: format!("path:{}", resolved_path.display()),
888 });
889 any_missing = true;
890 continue;
891 }
892 let declared_digest = match rev_spec {
893 Some(r) => r.bundle_digest.as_deref(),
894 None => b.bundle_digest.as_deref(),
895 };
896 let location = match rev_spec {
897 Some(r) => format!("bundle `{}`, revision `{}`", b.bundle_id, r.name),
898 None => format!("bundle `{}`", b.bundle_id),
899 };
900 let digest = resolved_artifact_digest(&resolved_path, declared_digest, &location)?;
901 let spec = rev_spec.cloned().unwrap_or_else(|| {
902 ManifestRevision {
904 name: "default".to_string(),
905 bundle_path: b
906 .bundle_path
907 .clone()
908 .expect("single-revision has bundle_path"),
909 weight_percent: None,
910 weight_bps: Some(super::deploy::FULL_TRAFFIC_BPS),
911 drain_seconds: None,
912 abort_metrics: Vec::new(),
913 bundle_source_uri: None,
917 bundle_digest: None,
918 }
919 });
920 resolved_revs.push(ResolvedRevision {
921 spec,
922 resolved_path,
923 digest,
924 weight_bps: weights[i],
925 });
926 }
927
928 if any_missing {
929 continue;
930 }
931
932 resolved_bundles.push(ResolvedBundle {
933 spec: b.clone(),
934 customer_id,
935 revisions: resolved_revs,
936 });
937 }
938
939 if let Some(env) = &env {
943 for rb in &resolved_bundles {
944 let same_customer: Vec<_> = env
945 .bundles
946 .iter()
947 .filter(|d| {
948 d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id
949 })
950 .collect();
951 let other_customer: Vec<_> = env
952 .bundles
953 .iter()
954 .filter(|d| {
955 d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id != rb.customer_id
956 })
957 .collect();
958 if !other_customer.is_empty() {
959 return Err(OpError::Conflict(format!(
960 "bundle `{}`: deployment owned by customer `{}` exists but manifest \
961 resolves to `{}` — apply refuses to adopt a deployment owned by a \
962 different customer",
963 rb.spec.bundle_id,
964 other_customer[0].customer_id.as_str(),
965 rb.customer_id.as_str(),
966 )));
967 }
968 if same_customer.len() > 1 {
969 return Err(OpError::Conflict(format!(
970 "bundle `{}` matches {} deployments for customer `{}` in env `{env_id}` \
971 — apply refuses to guess; reconcile with `gtc op bundles list {env_id}` \
972 first",
973 rb.spec.bundle_id,
974 same_customer.len(),
975 rb.customer_id.as_str(),
976 )));
977 }
978 }
979 }
980
981 let mut warnings = Vec::new();
982
983 let mut resolved_endpoints = Vec::with_capacity(manifest.messaging_endpoints.len());
985 for ep in &manifest.messaging_endpoints {
986 for link in &ep.links {
987 let in_manifest = manifest.bundles.iter().any(|b| &b.bundle_id == link);
988 if in_manifest {
989 continue;
990 }
991 let in_env = env
992 .as_ref()
993 .is_some_and(|e| e.bundles.iter().any(|d| d.bundle_id.as_str() == link));
994 if in_env {
995 warnings.push(format!(
996 "endpoint `{}`: link `{link}` is satisfied only by a pre-existing env \
997 deployment (not declared in this manifest)",
998 ep.name
999 ));
1000 } else {
1001 return Err(OpError::InvalidArgument(format!(
1002 "endpoint `{}`: link `{link}` is neither declared in this manifest's \
1003 bundles[] nor deployed in env `{env_id}`",
1004 ep.name
1005 )));
1006 }
1007 }
1008
1009 let matched = match &env {
1010 Some(e) => match_existing_endpoint(e, ep)?,
1011 None => None,
1012 };
1013 if let Some(wf) = &ep.welcome_flow {
1014 let linked_via_manifest = ep.links.contains(&wf.bundle_id);
1015 let linked_on_existing = matched
1016 .is_some_and(|m| m.linked_bundles.iter().any(|b| b.as_str() == wf.bundle_id));
1017 if !linked_via_manifest && !linked_on_existing {
1018 return Err(OpError::InvalidArgument(format!(
1019 "endpoint `{}`: welcome_flow.bundle_id `{}` must be in this endpoint's \
1020 links[] (or already linked on the matched endpoint)",
1021 ep.name, wf.bundle_id
1022 )));
1023 }
1024 }
1025 if let Some(m) = matched {
1026 let existing_refs: Vec<String> = m
1027 .secret_refs
1028 .iter()
1029 .map(|r| r.as_str().to_string())
1030 .collect();
1031 if !ep.secret_refs.is_empty() && ep.secret_refs != existing_refs {
1032 warnings.push(format!(
1033 "endpoint `{}`: secret_refs differ from the existing endpoint's — \
1034 left untouched (no endpoint update verb exists yet)",
1035 ep.name
1036 ));
1037 }
1038 }
1039 resolved_endpoints.push(ResolvedEndpoint {
1040 spec: ep.clone(),
1041 matched: matched.cloned(),
1042 });
1043 }
1044
1045 for p in &manifest.packs {
1054 if let Some(ar) = &p.answers_ref {
1055 let resolved = if ar.is_absolute() {
1056 ar.clone()
1057 } else {
1058 manifest_dir.join(ar)
1059 };
1060 if !resolved.is_file() {
1061 return Err(OpError::InvalidArgument(format!(
1062 "packs[] slot `{}`: answers_ref `{}` does not exist (resolved to `{}`)",
1063 p.slot,
1064 ar.display(),
1065 resolved.display()
1066 )));
1067 }
1068 }
1069 }
1070 for ext in &manifest.extensions {
1071 if let Some(ar) = &ext.answers_ref {
1072 let resolved = if ar.is_absolute() {
1073 ar.clone()
1074 } else {
1075 manifest_dir.join(ar)
1076 };
1077 if !resolved.is_file() {
1078 return Err(OpError::InvalidArgument(format!(
1079 "extensions[] kind `{}`: answers_ref `{}` does not exist (resolved to `{}`)",
1080 ext.kind,
1081 ar.display(),
1082 resolved.display()
1083 )));
1084 }
1085 }
1086 }
1087
1088 Ok(ApplyContext {
1089 env_id,
1090 manifest,
1091 secret_values,
1092 prompted_paths,
1093 store_satisfied_paths,
1094 bundles: resolved_bundles,
1095 endpoints: resolved_endpoints,
1096 env,
1097 canonical_public_base_url,
1098 missing,
1099 warnings,
1100 updated_by,
1101 manifest_dir: manifest_dir.to_path_buf(),
1102 })
1103}
1104
1105fn match_existing_endpoint<'e>(
1110 env: &'e Environment,
1111 ep: &ManifestEndpoint,
1112) -> Result<Option<&'e MessagingEndpoint>, OpError> {
1113 let name_matches: Vec<&MessagingEndpoint> = env
1114 .messaging_endpoints
1115 .iter()
1116 .filter(|m| m.display_name == ep.name)
1117 .collect();
1118 let pair_matches: Vec<&&MessagingEndpoint> = name_matches
1119 .iter()
1120 .filter(|m| m.provider_type == ep.provider_type)
1121 .collect();
1122 match pair_matches.len() {
1123 0 if name_matches.is_empty() => Ok(None),
1124 0 => Err(OpError::Conflict(format!(
1125 "endpoint `{}`: an endpoint with this display_name exists but with \
1126 provider_type `{}` (manifest says `{}`) — apply refuses to repurpose a name; \
1127 rename or remove the existing endpoint first",
1128 ep.name, name_matches[0].provider_type, ep.provider_type
1129 ))),
1130 1 => Ok(Some(pair_matches[0])),
1131 n => Err(OpError::Conflict(format!(
1132 "endpoint `{}`: {n} existing endpoints match (provider_type=`{}`, \
1133 display_name=`{}`): [{}] — apply refuses to guess; remove the duplicates first",
1134 ep.name,
1135 ep.provider_type,
1136 ep.name,
1137 pair_matches
1138 .iter()
1139 .map(|m| m.endpoint_id.to_string())
1140 .collect::<Vec<_>>()
1141 .join(", ")
1142 ))),
1143 }
1144}
1145
1146fn diff(store: &LocalFsStore, ctx: &ApplyContext) -> Result<Vec<ApplyStep>, OpError> {
1149 let mut steps = Vec::new();
1150 let env_id_str = ctx.env_id.as_str().to_string();
1151
1152 match &ctx.env {
1157 None => {
1158 steps.push(ApplyStep {
1159 kind: ApplyStepKind::EnsureEnvironment,
1160 key: env_id_str.clone(),
1161 action: ApplyAction::Create,
1162 detail: "env init (local bootstrap: default env-pack bindings + trust-root seed)"
1163 .to_string(),
1164 idempotency_key: None,
1165 op: StepOp::EnvInit {
1166 public_base_url: ctx.canonical_public_base_url.clone(),
1167 },
1168 });
1169 }
1170 Some(env) => match &ctx.canonical_public_base_url {
1171 Some(url) if env.host_config.public_base_url.as_deref() != Some(url.as_str()) => {
1172 steps.push(ApplyStep {
1173 kind: ApplyStepKind::EnsureEnvironment,
1174 key: env_id_str.clone(),
1175 action: ApplyAction::Update,
1176 detail: format!("set-public-url {url}"),
1177 idempotency_key: None,
1178 op: StepOp::SetPublicUrl { url: url.clone() },
1179 });
1180 }
1181 _ => steps.push(ApplyStep::no_op(
1182 ApplyStepKind::EnsureEnvironment,
1183 env_id_str.clone(),
1184 "exists (public_base_url unchanged)",
1185 )),
1186 },
1187 }
1188
1189 if let Some(env) = &ctx.env {
1195 let me = &ctx.manifest.environment;
1196 let name_differs = me.name.as_ref().is_some_and(|n| *n != env.name);
1197 let region_differs = me
1198 .region
1199 .as_ref()
1200 .is_some_and(|r| env.host_config.region.as_deref() != Some(r.as_str()));
1201 let tenant_org_differs = me
1202 .tenant_org_id
1203 .as_ref()
1204 .is_some_and(|t| env.host_config.tenant_org_id.as_deref() != Some(t.as_str()));
1205 let listen_addr_differs = me.listen_addr.as_ref().is_some_and(|la| {
1206 let parsed: std::net::SocketAddr = la
1207 .parse()
1208 .expect("validate_shape already validated listen_addr");
1209 env.host_config.listen_addr != Some(parsed)
1210 });
1211 let gui_enabled_differs = me
1215 .gui_enabled
1216 .is_some_and(|g| env.host_config.gui_enabled != Some(g));
1217
1218 if name_differs
1219 || region_differs
1220 || tenant_org_differs
1221 || listen_addr_differs
1222 || gui_enabled_differs
1223 {
1224 let mut fields = Vec::new();
1225 if name_differs {
1226 fields.push("name");
1227 }
1228 if region_differs {
1229 fields.push("region");
1230 }
1231 if tenant_org_differs {
1232 fields.push("tenant_org_id");
1233 }
1234 if listen_addr_differs {
1235 fields.push("listen_addr");
1236 }
1237 if gui_enabled_differs {
1238 fields.push("gui_enabled");
1239 }
1240 let desired_hash = hash_json(&json!({
1241 "name": me.name,
1242 "region": me.region,
1243 "tenant_org_id": me.tenant_org_id,
1244 "listen_addr": me.listen_addr,
1245 "gui_enabled": me.gui_enabled,
1246 }));
1247 let ikey = derive_idempotency_key(
1248 &ctx.env_id,
1249 ApplyStepKind::UpdateHostConfig.label(),
1250 &env_id_str,
1251 &desired_hash,
1252 );
1253 steps.push(ApplyStep {
1254 kind: ApplyStepKind::UpdateHostConfig,
1255 key: env_id_str.clone(),
1256 action: ApplyAction::Update,
1257 detail: format!("set {}", fields.join(", ")),
1258 idempotency_key: Some(ikey),
1259 op: StepOp::UpdateHostConfig(Box::new(ConfigSetPayload {
1260 environment_id: env_id_str.clone(),
1261 name: me.name.clone(),
1262 region: me.region.clone(),
1263 tenant_org_id: me.tenant_org_id.clone(),
1264 listen_addr: me.listen_addr.clone(),
1265 public_base_url: None, gui_enabled: me.gui_enabled,
1267 })),
1268 });
1269 } else if me.declares_host_config() {
1270 steps.push(ApplyStep::no_op(
1271 ApplyStepKind::UpdateHostConfig,
1272 env_id_str.clone(),
1273 "host-config unchanged",
1274 ));
1275 }
1276 } else {
1277 let me = &ctx.manifest.environment;
1282 if me.declares_host_config() {
1283 steps.push(ApplyStep {
1284 kind: ApplyStepKind::UpdateHostConfig,
1285 key: env_id_str.clone(),
1286 action: ApplyAction::Create,
1287 detail: "set host-config on fresh env".to_string(),
1288 idempotency_key: None,
1289 op: StepOp::UpdateHostConfig(Box::new(ConfigSetPayload {
1290 environment_id: env_id_str.clone(),
1291 name: me.name.clone(),
1292 region: me.region.clone(),
1293 tenant_org_id: me.tenant_org_id.clone(),
1294 listen_addr: me.listen_addr.clone(),
1295 public_base_url: None,
1296 gui_enabled: me.gui_enabled,
1297 })),
1298 });
1299 }
1300 }
1301
1302 if ctx.manifest.trust_root == Some(TrustRootDirective::Bootstrap) {
1304 steps.push(trust_root_step(store, ctx)?);
1305 }
1306
1307 let default_bindings = if ctx.env.is_none() {
1317 Some(
1318 crate::defaults::local_pack_bindings()
1319 .map_err(|e| OpError::InvalidArgument(format!("default pack bindings: {e}")))?,
1320 )
1321 } else {
1322 None
1323 };
1324 for mp in &ctx.manifest.packs {
1325 let existing = match &ctx.env {
1326 Some(e) => e.pack_for_slot(mp.slot),
1327 None => default_bindings
1328 .as_ref()
1329 .and_then(|bs| bs.iter().find(|b| b.slot == mp.slot)),
1330 };
1331 match existing {
1332 None => {
1333 let desired_hash = hash_json(&json!({
1334 "slot": mp.slot.to_string(),
1335 "kind": mp.kind,
1336 "pack_ref": mp.pack_ref,
1337 "answers_ref": mp.answers_ref,
1338 }));
1339 let ikey = derive_idempotency_key(
1340 &ctx.env_id,
1341 ApplyStepKind::AddPackBinding.label(),
1342 &mp.slot.to_string(),
1343 &desired_hash,
1344 );
1345 steps.push(ApplyStep {
1346 kind: ApplyStepKind::AddPackBinding,
1347 key: mp.slot.to_string(),
1348 action: ApplyAction::Create,
1349 detail: format!("{} ({})", mp.kind, mp.pack_ref),
1350 idempotency_key: Some(ikey.clone()),
1351 op: StepOp::AddPackBinding(Box::new(EnvPackBindingPayload {
1352 environment_id: env_id_str.clone(),
1353 slot: mp.slot,
1354 kind: mp.kind.clone(),
1355 pack_ref: mp.pack_ref.clone(),
1356 answers_ref: mp.answers_ref.clone(),
1357 idempotency_key: Some(ikey),
1358 })),
1359 });
1360 }
1361 Some(b) => {
1362 let kind_differs = b.kind.to_string() != mp.kind;
1363 let pack_ref_differs = b.pack_ref.as_str() != mp.pack_ref;
1364 let answers_ref_differs = match &mp.answers_ref {
1374 Some(ar) => {
1375 let content_outdated = staged_answers_outdated(
1376 store,
1377 &ctx.env_id,
1378 &ctx.manifest_dir,
1379 mp.slot,
1380 ar,
1381 )?;
1382 let ref_wrong =
1383 b.answers_ref.as_deref() != Some(staged_answers_rel(mp.slot).as_path());
1384 content_outdated || ref_wrong
1385 }
1386 None => false,
1387 };
1388 if kind_differs || pack_ref_differs || answers_ref_differs {
1389 let desired_hash = hash_json(&json!({
1390 "slot": mp.slot.to_string(),
1391 "kind": mp.kind,
1392 "pack_ref": mp.pack_ref,
1393 "answers_ref": mp.answers_ref,
1394 }));
1395 let ikey = derive_idempotency_key(
1396 &ctx.env_id,
1397 ApplyStepKind::UpdatePackBinding.label(),
1398 &mp.slot.to_string(),
1399 &desired_hash,
1400 );
1401 let mut what = Vec::new();
1402 if kind_differs {
1403 what.push(format!("kind → {}", mp.kind));
1404 }
1405 if pack_ref_differs {
1406 what.push(format!("pack_ref → {}", mp.pack_ref));
1407 }
1408 if answers_ref_differs {
1409 what.push("answers_ref".to_string());
1410 }
1411 steps.push(ApplyStep {
1412 kind: ApplyStepKind::UpdatePackBinding,
1413 key: mp.slot.to_string(),
1414 action: ApplyAction::Update,
1415 detail: what.join(", "),
1416 idempotency_key: Some(ikey.clone()),
1417 op: StepOp::UpdatePackBinding(Box::new(EnvPackBindingPayload {
1418 environment_id: env_id_str.clone(),
1419 slot: mp.slot,
1420 kind: mp.kind.clone(),
1421 pack_ref: mp.pack_ref.clone(),
1422 answers_ref: mp.answers_ref.clone(),
1423 idempotency_key: Some(ikey),
1424 })),
1425 });
1426 } else {
1427 steps.push(ApplyStep::no_op(
1428 ApplyStepKind::AddPackBinding,
1429 mp.slot.to_string(),
1430 format!("bound ({}, {})", mp.kind, mp.pack_ref),
1431 ));
1432 }
1433 }
1434 }
1435 }
1436
1437 for s in &ctx.manifest.secrets {
1444 if ctx.store_satisfied_paths.contains(&s.path) {
1450 continue;
1451 }
1452 let detail = match &s.from_env {
1462 None => "pasted (cannot diff until A9)".to_string(),
1463 Some(_) if ctx.prompted_paths.contains(&s.path) => {
1464 "prompted (cannot diff until A9)".to_string()
1465 }
1466 Some(from_env) => format!("from ${from_env} (cannot diff until A9)"),
1467 };
1468 steps.push(ApplyStep {
1469 kind: ApplyStepKind::PutSecret,
1470 key: s.path.clone(),
1471 action: ApplyAction::Put,
1472 detail,
1473 idempotency_key: None,
1474 op: StepOp::PutSecret {
1475 path: s.path.clone(),
1476 },
1477 });
1478 }
1479
1480 for rb in &ctx.bundles {
1483 let is_multi = rb.spec.revisions.is_some();
1484 let existing = ctx.env.as_ref().and_then(|e| {
1485 e.bundles.iter().find(|d| {
1486 d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id
1487 })
1488 });
1489
1490 let primary_digest = || rb.revisions[0].digest.clone();
1493
1494 match existing {
1495 None if is_multi => {
1496 let digests: Vec<String> = rb
1497 .revisions
1498 .iter()
1499 .map(|r| format!("{}@{}", r.spec.name, short_digest(&r.digest)))
1500 .collect();
1501 let detail = format!(
1502 "{} revision(s) [{}] → {}",
1503 rb.revisions.len(),
1504 digests.join(", "),
1505 binding_summary(&rb.spec.route_binding.clone().map(into_route_binding))
1506 );
1507 steps.push(ApplyStep {
1508 kind: ApplyStepKind::DeploySplit,
1509 key: rb.spec.bundle_id.clone(),
1510 action: ApplyAction::Create,
1511 detail,
1512 idempotency_key: None,
1513 op: deploy_split_op(&env_id_str, rb),
1514 });
1515 }
1516 None => {
1517 let detail = format!(
1518 "{} → {}",
1519 short_digest(&primary_digest()),
1520 binding_summary(&rb.spec.route_binding.clone().map(into_route_binding))
1521 );
1522 steps.push(ApplyStep {
1523 kind: ApplyStepKind::DeployBundle,
1524 key: rb.spec.bundle_id.clone(),
1525 action: ApplyAction::Create,
1526 detail,
1527 idempotency_key: None, op: StepOp::Deploy {
1529 payload: Box::new(deploy_payload(
1530 &env_id_str,
1531 rb,
1532 rb.spec.route_binding.clone(),
1533 )),
1534 expected_digest: primary_digest(),
1535 },
1536 });
1537 }
1538 Some(dep) if is_multi => {
1539 let env = ctx.env.as_ref().expect("existing deployment implies env");
1540 let converged = split_converged(env, dep.deployment_id, &rb.revisions);
1541 let desired_binding: Option<RouteBinding> =
1542 rb.spec.route_binding.clone().map(into_route_binding);
1543 let binding_differs = desired_binding
1544 .as_ref()
1545 .is_some_and(|b| *b != dep.route_binding);
1546 let overrides_differ = rb
1547 .spec
1548 .config_overrides
1549 .as_ref()
1550 .is_some_and(|o| *o != dep.config_overrides);
1551
1552 if !converged {
1553 let digests: Vec<String> = rb
1554 .revisions
1555 .iter()
1556 .map(|r| format!("{}@{}", r.spec.name, short_digest(&r.digest)))
1557 .collect();
1558 let detail = format!(
1559 "split not converged → re-deploy {} revision(s) [{}]",
1560 rb.revisions.len(),
1561 digests.join(", ")
1562 );
1563 steps.push(ApplyStep {
1564 kind: ApplyStepKind::DeploySplit,
1565 key: rb.spec.bundle_id.clone(),
1566 action: ApplyAction::Update,
1567 detail,
1568 idempotency_key: None,
1569 op: deploy_split_op(&env_id_str, rb),
1570 });
1571 }
1572
1573 let diff = BundleMetaDiff {
1578 route_binding: binding_differs.then(|| {
1579 rb.spec
1580 .route_binding
1581 .clone()
1582 .expect("binding_differs implies manifest binding")
1583 }),
1584 config_overrides: (overrides_differ && converged)
1585 .then(|| rb.spec.config_overrides.clone().expect("overrides_differ")),
1586 revenue_share: revenue_share_differs(rb, dep).then(|| {
1587 rb.spec
1588 .revenue_share
1589 .clone()
1590 .expect("differs implies manifest")
1591 }),
1592 status: status_differs(rb, dep).then(|| rb.spec.status.expect("differs")),
1593 };
1594 let did_update = !diff.is_noop();
1595 if let Some(step) = bundle_meta_update_step(
1596 &ctx.env_id,
1597 &env_id_str,
1598 &rb.spec.bundle_id,
1599 dep.deployment_id,
1600 &desired_binding,
1601 diff,
1602 ) {
1603 steps.push(step);
1604 }
1605
1606 if converged && !did_update {
1607 steps.push(ApplyStep::no_op(
1608 ApplyStepKind::DeploySplit,
1609 rb.spec.bundle_id.clone(),
1610 format!("split converged ({} revision(s))", rb.revisions.len()),
1611 ));
1612 }
1613 }
1614 Some(dep) => {
1615 let desired_binding: Option<RouteBinding> =
1616 rb.spec.route_binding.clone().map(into_route_binding);
1617 let binding_differs = desired_binding
1618 .as_ref()
1619 .is_some_and(|b| *b != dep.route_binding);
1620 let overrides_differ = rb
1621 .spec
1622 .config_overrides
1623 .as_ref()
1624 .is_some_and(|o| *o != dep.config_overrides);
1625 let env = ctx.env.as_ref().expect("existing deployment implies env");
1626 let converged = deployment_converged(
1627 env,
1628 dep.deployment_id,
1629 &primary_digest(),
1630 rb.spec.bundle_source_uri.as_deref(),
1631 );
1632 let needs_deploy = !converged;
1633 let live = live_revision_digest(env, dep.deployment_id);
1634
1635 if needs_deploy {
1640 let detail = if live.is_some_and(digest_is_real) {
1642 format!(
1643 "digest {} → {} (blue-green re-stage)",
1644 live.map(short_digest).unwrap_or("none"),
1645 short_digest(&primary_digest())
1646 )
1647 } else {
1648 format!(
1649 "traffic split is not a single 100% entry \
1650 → re-deploy reconverges ({})",
1651 short_digest(&primary_digest())
1652 )
1653 };
1654 steps.push(ApplyStep {
1655 kind: ApplyStepKind::DeployBundle,
1656 key: rb.spec.bundle_id.clone(),
1657 action: ApplyAction::Update,
1658 detail,
1659 idempotency_key: None,
1660 op: StepOp::Deploy {
1661 payload: Box::new(deploy_payload(&env_id_str, rb, None)),
1667 expected_digest: primary_digest(),
1668 },
1669 });
1670 }
1671
1672 let diff = BundleMetaDiff {
1678 route_binding: binding_differs.then(|| {
1679 rb.spec
1680 .route_binding
1681 .clone()
1682 .expect("binding_differs implies manifest binding")
1683 }),
1684 config_overrides: (overrides_differ && !needs_deploy)
1685 .then(|| rb.spec.config_overrides.clone().expect("overrides_differ")),
1686 revenue_share: revenue_share_differs(rb, dep).then(|| {
1687 rb.spec
1688 .revenue_share
1689 .clone()
1690 .expect("differs implies manifest")
1691 }),
1692 status: status_differs(rb, dep).then(|| rb.spec.status.expect("differs")),
1693 };
1694 let did_update = !diff.is_noop();
1695 if let Some(step) = bundle_meta_update_step(
1696 &ctx.env_id,
1697 &env_id_str,
1698 &rb.spec.bundle_id,
1699 dep.deployment_id,
1700 &desired_binding,
1701 diff,
1702 ) {
1703 steps.push(step);
1704 }
1705
1706 if !needs_deploy && !did_update {
1707 steps.push(ApplyStep::no_op(
1708 ApplyStepKind::DeployBundle,
1709 rb.spec.bundle_id.clone(),
1710 format!("digest match ({})", short_digest(&primary_digest())),
1711 ));
1712 }
1713 }
1714 }
1715 }
1716
1717 for mx in &ctx.manifest.extensions {
1719 let kind_path = greentic_deploy_spec::PackDescriptor::try_new(&mx.kind)
1720 .expect("validate_shape already validated kind")
1721 .path()
1722 .to_string();
1723 let ext_key = format!(
1724 "{}{}",
1725 kind_path,
1726 mx.instance_id
1727 .as_ref()
1728 .map(|i| format!("/{i}"))
1729 .unwrap_or_default()
1730 );
1731 let existing = ctx.env.as_ref().and_then(|e| {
1732 e.extensions
1733 .iter()
1734 .find(|b| b.kind.path() == kind_path && b.instance_id == mx.instance_id)
1735 });
1736 match existing {
1737 None => {
1738 let desired_hash = hash_json(&json!({
1739 "kind": mx.kind,
1740 "pack_ref": mx.pack_ref,
1741 "instance_id": mx.instance_id,
1742 "answers_ref": mx.answers_ref,
1743 }));
1744 let ikey = derive_idempotency_key(
1745 &ctx.env_id,
1746 ApplyStepKind::AddExtension.label(),
1747 &ext_key,
1748 &desired_hash,
1749 );
1750 steps.push(ApplyStep {
1751 kind: ApplyStepKind::AddExtension,
1752 key: ext_key,
1753 action: ApplyAction::Create,
1754 detail: format!("{} ({})", mx.kind, mx.pack_ref),
1755 idempotency_key: Some(ikey.clone()),
1756 op: StepOp::AddExtension(Box::new(ExtensionBindingPayload {
1757 environment_id: env_id_str.clone(),
1758 kind: mx.kind.clone(),
1759 pack_ref: mx.pack_ref.clone(),
1760 instance_id: mx.instance_id.clone(),
1761 answers_ref: mx.answers_ref.clone(),
1762 idempotency_key: Some(ikey),
1763 })),
1764 });
1765 }
1766 Some(b) => {
1767 let kind_differs = b.kind.to_string() != mx.kind;
1768 let pack_ref_differs = b.pack_ref.as_str() != mx.pack_ref;
1769 let answers_ref_differs = mx
1770 .answers_ref
1771 .as_ref()
1772 .is_some_and(|ar| b.answers_ref.as_ref() != Some(ar));
1773 if kind_differs || pack_ref_differs || answers_ref_differs {
1774 let desired_hash = hash_json(&json!({
1775 "kind": mx.kind,
1776 "pack_ref": mx.pack_ref,
1777 "instance_id": mx.instance_id,
1778 "answers_ref": mx.answers_ref,
1779 }));
1780 let ikey = derive_idempotency_key(
1781 &ctx.env_id,
1782 ApplyStepKind::UpdateExtension.label(),
1783 &ext_key,
1784 &desired_hash,
1785 );
1786 let mut what = Vec::new();
1787 if kind_differs {
1788 what.push(format!("kind → {}", mx.kind));
1789 }
1790 if pack_ref_differs {
1791 what.push(format!("pack_ref → {}", mx.pack_ref));
1792 }
1793 if answers_ref_differs {
1794 what.push("answers_ref".to_string());
1795 }
1796 steps.push(ApplyStep {
1797 kind: ApplyStepKind::UpdateExtension,
1798 key: ext_key,
1799 action: ApplyAction::Update,
1800 detail: what.join(", "),
1801 idempotency_key: Some(ikey.clone()),
1802 op: StepOp::UpdateExtension(Box::new(ExtensionBindingPayload {
1803 environment_id: env_id_str.clone(),
1804 kind: mx.kind.clone(),
1805 pack_ref: mx.pack_ref.clone(),
1806 instance_id: mx.instance_id.clone(),
1807 answers_ref: mx.answers_ref.clone(),
1808 idempotency_key: Some(ikey),
1809 })),
1810 });
1811 } else {
1812 steps.push(ApplyStep::no_op(
1813 ApplyStepKind::AddExtension,
1814 ext_key,
1815 format!("bound ({}, {})", mx.kind, mx.pack_ref),
1816 ));
1817 }
1818 }
1819 }
1820 }
1821
1822 for re in &ctx.endpoints {
1826 let ep = &re.spec;
1827 let matched = re.matched.as_ref();
1828 let endpoint_ref = match matched {
1829 Some(m) => EndpointRef::Existing(m.endpoint_id.to_string()),
1830 None => EndpointRef::CreatedByName(ep.name.clone()),
1831 };
1832
1833 match matched {
1834 None => {
1835 let desired_hash = hash_json(&json!({
1836 "provider_type": ep.provider_type,
1837 "secret_refs": ep.secret_refs,
1838 }));
1839 let ikey = derive_idempotency_key(
1840 &ctx.env_id,
1841 ApplyStepKind::AddEndpoint.label(),
1842 &ep.name,
1843 &desired_hash,
1844 );
1845 steps.push(ApplyStep {
1846 kind: ApplyStepKind::AddEndpoint,
1847 key: ep.name.clone(),
1848 action: ApplyAction::Create,
1849 detail: format!("{} (provider_id = name)", ep.provider_type),
1850 idempotency_key: Some(ikey.clone()),
1851 op: StepOp::EndpointAdd(Box::new(EndpointAddPayload {
1852 environment_id: env_id_str.clone(),
1853 provider_id: ep.name.clone(),
1854 provider_type: ep.provider_type.clone(),
1855 display_name: ep.name.clone(),
1856 secret_refs: ep.secret_refs.clone(),
1857 webhook_secret_ref: None,
1861 idempotency_key: Some(ikey),
1862 updated_by: ctx.updated_by.clone(),
1863 })),
1864 });
1865 }
1866 Some(m) => steps.push(ApplyStep::no_op(
1867 ApplyStepKind::AddEndpoint,
1868 ep.name.clone(),
1869 format!("matched endpoint {}", m.endpoint_id),
1870 )),
1871 }
1872
1873 for link in &ep.links {
1874 let already_linked =
1875 matched.is_some_and(|m| m.linked_bundles.iter().any(|b| b.as_str() == *link));
1876 let key = format!("{} → {link}", ep.name);
1877 if already_linked {
1878 steps.push(ApplyStep::no_op(ApplyStepKind::LinkEndpoint, key, "linked"));
1879 } else {
1880 let ikey = derive_idempotency_key(
1881 &ctx.env_id,
1882 ApplyStepKind::LinkEndpoint.label(),
1883 &format!("{}\u{0}{link}", ep.name),
1884 "",
1885 );
1886 steps.push(ApplyStep {
1887 kind: ApplyStepKind::LinkEndpoint,
1888 key,
1889 action: ApplyAction::Create,
1890 detail: String::new(),
1891 idempotency_key: Some(ikey.clone()),
1892 op: StepOp::EndpointLink {
1893 endpoint: endpoint_ref.clone(),
1894 bundle_id: link.clone(),
1895 },
1896 });
1897 }
1898 }
1899
1900 if let Some(wf) = &ep.welcome_flow {
1901 let current_equal = matched.is_some_and(|m| {
1902 m.welcome_flow.as_ref().is_some_and(|cur| {
1903 cur.bundle_id.as_str() == wf.bundle_id
1904 && cur.pack_id.as_str() == wf.pack_id
1905 && cur.flow_id == wf.flow_id
1906 })
1907 });
1908 if current_equal {
1909 steps.push(ApplyStep::no_op(
1910 ApplyStepKind::SetWelcomeFlow,
1911 ep.name.clone(),
1912 format!("{}/{}/{}", wf.bundle_id, wf.pack_id, wf.flow_id),
1913 ));
1914 } else {
1915 let desired_hash = hash_json(&json!({
1916 "bundle_id": wf.bundle_id, "pack_id": wf.pack_id, "flow_id": wf.flow_id,
1917 }));
1918 let ikey = derive_idempotency_key(
1919 &ctx.env_id,
1920 ApplyStepKind::SetWelcomeFlow.label(),
1921 &ep.name,
1922 &desired_hash,
1923 );
1924 let action = if matched.is_some_and(|m| m.welcome_flow.is_some()) {
1925 ApplyAction::Update
1926 } else {
1927 ApplyAction::Create
1928 };
1929 steps.push(ApplyStep {
1930 kind: ApplyStepKind::SetWelcomeFlow,
1931 key: ep.name.clone(),
1932 action,
1933 detail: format!("{}/{}/{}", wf.bundle_id, wf.pack_id, wf.flow_id),
1934 idempotency_key: Some(ikey.clone()),
1935 op: StepOp::WelcomeFlow {
1936 endpoint: endpoint_ref.clone(),
1937 flow: wf.clone(),
1938 },
1939 });
1940 }
1941 }
1942 }
1943
1944 Ok(steps)
1945}
1946
1947fn trust_root_step(store: &LocalFsStore, ctx: &ApplyContext) -> Result<ApplyStep, OpError> {
1951 let key = ctx.env_id.as_str().to_string();
1952 let Some(_env) = &ctx.env else {
1953 return Ok(ApplyStep {
1954 kind: ApplyStepKind::BootstrapTrustRoot,
1955 key,
1956 action: ApplyAction::Create,
1957 detail: "bootstrap (fresh env)".to_string(),
1958 idempotency_key: None,
1959 op: StepOp::TrustRootBootstrap,
1960 });
1961 };
1962 let env_dir = store.env_dir(&ctx.env_id)?;
1963 let trust_root = store_trust_root::load(&env_dir)?;
1964 let operator_key = crate::operator_key::load_existing_only();
1965 let (action, detail, op) = match operator_key {
1966 Ok(k)
1967 if trust_root
1968 .keys
1969 .iter()
1970 .any(|t| t.key_id.eq_ignore_ascii_case(&k.key_id)) =>
1971 {
1972 (
1973 ApplyAction::NoOp,
1974 format!("operator key {} already trusted", short_key_id(&k.key_id)),
1975 StepOp::None,
1976 )
1977 }
1978 Ok(k) => (
1979 ApplyAction::Create,
1980 format!("trust operator key {}", short_key_id(&k.key_id)),
1981 StepOp::TrustRootBootstrap,
1982 ),
1983 Err(_) => (
1984 ApplyAction::Create,
1985 "operator key will be generated".to_string(),
1986 StepOp::TrustRootBootstrap,
1987 ),
1988 };
1989 Ok(ApplyStep {
1990 kind: ApplyStepKind::BootstrapTrustRoot,
1991 key,
1992 action,
1993 detail,
1994 idempotency_key: None,
1995 op,
1996 })
1997}
1998
1999fn execute(store: &LocalFsStore, ctx: &ApplyContext, steps: &[ApplyStep]) -> Result<(), OpError> {
2002 for rb in &ctx.bundles {
2009 for rr in &rb.revisions {
2010 ensure_artifact_unchanged(&rr.resolved_path, &rr.digest)?;
2011 }
2012 }
2013
2014 let exec_flags = OpFlags::default();
2017 let mut created_endpoints: BTreeMap<String, String> = BTreeMap::new();
2018 let total = steps.len();
2019
2020 for (i, step) in steps.iter().enumerate() {
2021 let n = i + 1;
2022 if step.action == ApplyAction::NoOp {
2023 eprintln!(
2024 "[{n}/{total}] {:<22} {:<40} no-op",
2025 step.kind.label(),
2026 step.key
2027 );
2028 continue;
2029 }
2030 eprintln!(
2031 "[{n}/{total}] {:<22} {:<40} {}…",
2032 step.kind.label(),
2033 step.key,
2034 step.action.as_str()
2035 );
2036 let result: Result<(), OpError> = match &step.op {
2037 StepOp::None => Ok(()),
2038 StepOp::EnvInit { public_base_url } => super::env::init(
2039 store,
2040 &exec_flags,
2041 EnvInitPayload {
2042 public_base_url: public_base_url.clone(),
2043 },
2044 )
2045 .map(|_| ()),
2046 StepOp::SetPublicUrl { url } => {
2047 super::env::set_public_url(store, &exec_flags, ctx.env_id.as_str(), url).map(|_| ())
2048 }
2049 StepOp::UpdateHostConfig(payload) => {
2050 super::config::set(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2051 }
2052 StepOp::AddPackBinding(payload) => {
2053 let payload = stage_binding_answers(store, ctx, (**payload).clone())?;
2054 super::env_packs::add(store, &exec_flags, Some(payload)).map(|_| ())
2055 }
2056 StepOp::UpdatePackBinding(payload) => {
2057 let payload = stage_binding_answers(store, ctx, (**payload).clone())?;
2058 super::env_packs::update(store, &exec_flags, Some(payload)).map(|_| ())
2059 }
2060 StepOp::AddExtension(payload) => {
2061 super::extensions::add(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2062 }
2063 StepOp::UpdateExtension(payload) => {
2064 super::extensions::update(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2065 }
2066 StepOp::TrustRootBootstrap => super::trust_root::bootstrap(
2067 store,
2068 &exec_flags,
2069 Some(TrustRootBootstrapPayload {
2070 environment_id: ctx.env_id.as_str().to_string(),
2071 }),
2072 )
2073 .map(|_| ()),
2074 StepOp::PutSecret { path } => {
2075 let value = ctx
2076 .secret_values
2077 .get(path)
2078 .expect("validated: every put-secret step has a resolved value");
2079 super::secrets::put(
2080 store,
2081 &exec_flags,
2082 Some(SecretsPutPayload {
2083 environment_id: ctx.env_id.as_str().to_string(),
2084 path: path.clone(),
2085 value: value.expose().to_string(),
2086 idempotency_key: step.idempotency_key.clone(),
2087 }),
2088 )
2089 .map(|_| ())
2090 }
2091 StepOp::Deploy {
2092 payload,
2093 expected_digest,
2094 } => {
2095 ensure_artifact_unchanged(
2096 payload
2097 .bundle_path
2098 .as_deref()
2099 .expect("apply-built Deploy always has bundle_path"),
2100 expected_digest,
2101 )?;
2102 super::deploy::deploy(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2103 }
2104 StepOp::DeploySplit { .. } => {
2105 execute_deploy_split(store, &exec_flags, &step.op)?;
2106 Ok(())
2107 }
2108 StepOp::BundleUpdate(payload) => {
2109 super::bundles::update(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2110 }
2111 StepOp::EndpointAdd(payload) => super::messaging::add(
2112 store,
2113 &exec_flags,
2114 Some((**payload).clone()),
2115 )
2116 .map(|outcome| {
2117 if let Ok(summary) = serde_json::from_value::<EndpointSummary>(outcome.result) {
2118 created_endpoints.insert(payload.display_name.clone(), summary.endpoint_id);
2119 }
2120 }),
2121 StepOp::EndpointLink {
2122 endpoint,
2123 bundle_id,
2124 } => resolve_endpoint_id(endpoint, &created_endpoints).and_then(|endpoint_id| {
2125 super::messaging::link_bundle(
2126 store,
2127 &exec_flags,
2128 Some(EndpointLinkBundlePayload {
2129 environment_id: ctx.env_id.as_str().to_string(),
2130 endpoint_id,
2131 bundle_id: bundle_id.clone(),
2132 idempotency_key: step.idempotency_key.clone(),
2133 updated_by: ctx.updated_by.clone(),
2134 }),
2135 )
2136 .map(|_| ())
2137 }),
2138 StepOp::WelcomeFlow { endpoint, flow } => {
2139 resolve_endpoint_id(endpoint, &created_endpoints).and_then(|endpoint_id| {
2140 super::messaging::set_welcome_flow(
2141 store,
2142 &exec_flags,
2143 Some(EndpointSetWelcomeFlowPayload {
2144 environment_id: ctx.env_id.as_str().to_string(),
2145 endpoint_id,
2146 bundle_id: flow.bundle_id.clone(),
2147 pack_id: flow.pack_id.clone(),
2148 flow_id: flow.flow_id.clone(),
2149 idempotency_key: step.idempotency_key.clone(),
2150 updated_by: ctx.updated_by.clone(),
2151 }),
2152 )
2153 .map(|_| ())
2154 })
2155 }
2156 };
2157 if let Err(err) = result {
2158 let remaining = total - n;
2159 eprintln!(
2160 "apply: step {n}/{total} `{} {}` failed; {remaining} step(s) not attempted. \
2161 Fix the cause and re-run the same apply — completed steps replay as no-ops.",
2162 step.kind.label(),
2163 step.key
2164 );
2165 return Err(err);
2166 }
2167 }
2168 Ok(())
2169}
2170
2171fn resolve_endpoint_id(
2176 endpoint: &EndpointRef,
2177 created: &BTreeMap<String, String>,
2178) -> Result<String, OpError> {
2179 match endpoint {
2180 EndpointRef::Existing(id) => Ok(id.clone()),
2181 EndpointRef::CreatedByName(name) => created.get(name).cloned().ok_or_else(|| {
2182 OpError::InvalidArgument(format!(
2183 "internal: endpoint `{name}` was not created before its link step \
2184 (add-endpoint outcome missing)"
2185 ))
2186 }),
2187 }
2188}
2189
2190fn verify(store: &LocalFsStore, ctx: &ApplyContext) -> Result<Value, OpError> {
2199 let env = store.load(&ctx.env_id)?;
2200 let mut failures: Vec<String> = Vec::new();
2201 let mut checked = 0usize;
2202
2203 if let Some(url) = &ctx.canonical_public_base_url {
2204 checked += 1;
2205 if env.host_config.public_base_url.as_deref() != Some(url.as_str()) {
2206 failures.push(format!(
2207 "public_base_url is `{:?}`, expected `{url}`",
2208 env.host_config.public_base_url
2209 ));
2210 }
2211 }
2212
2213 {
2215 let me = &ctx.manifest.environment;
2216 if let Some(name) = &me.name {
2217 checked += 1;
2218 if *name != env.name {
2219 failures.push(format!("name is `{}`, expected `{name}`", env.name));
2220 }
2221 }
2222 if let Some(region) = &me.region {
2223 checked += 1;
2224 if env.host_config.region.as_deref() != Some(region.as_str()) {
2225 failures.push(format!(
2226 "region is `{:?}`, expected `{region}`",
2227 env.host_config.region
2228 ));
2229 }
2230 }
2231 if let Some(tenant_org_id) = &me.tenant_org_id {
2232 checked += 1;
2233 if env.host_config.tenant_org_id.as_deref() != Some(tenant_org_id.as_str()) {
2234 failures.push(format!(
2235 "tenant_org_id is `{:?}`, expected `{tenant_org_id}`",
2236 env.host_config.tenant_org_id
2237 ));
2238 }
2239 }
2240 if let Some(listen_addr) = &me.listen_addr {
2241 checked += 1;
2242 let parsed: std::net::SocketAddr = listen_addr
2243 .parse()
2244 .expect("validate_shape already validated listen_addr");
2245 if env.host_config.listen_addr != Some(parsed) {
2246 failures.push(format!(
2247 "listen_addr is `{:?}`, expected `{parsed}`",
2248 env.host_config.listen_addr
2249 ));
2250 }
2251 }
2252 if let Some(gui_enabled) = me.gui_enabled {
2253 checked += 1;
2254 if env.host_config.gui_enabled != Some(gui_enabled) {
2255 failures.push(format!(
2256 "gui_enabled is `{:?}`, expected `{gui_enabled}`",
2257 env.host_config.gui_enabled
2258 ));
2259 }
2260 }
2261 }
2262
2263 if ctx.manifest.trust_root == Some(TrustRootDirective::Bootstrap) {
2264 checked += 1;
2265 let env_dir = store.env_dir(&ctx.env_id)?;
2266 let trust_root = store_trust_root::load(&env_dir)?;
2267 match crate::operator_key::load_existing_only() {
2268 Ok(k)
2269 if trust_root
2270 .keys
2271 .iter()
2272 .any(|t| t.key_id.eq_ignore_ascii_case(&k.key_id)) => {}
2273 Ok(k) => failures.push(format!(
2274 "operator key {} is not in the trust root",
2275 short_key_id(&k.key_id)
2276 )),
2277 Err(e) => failures.push(format!("operator key not loadable: {e}")),
2278 }
2279 }
2280
2281 for mp in &ctx.manifest.packs {
2283 checked += 1;
2284 let Some(b) = env.pack_for_slot(mp.slot) else {
2285 failures.push(format!("pack slot `{}` is not bound", mp.slot));
2286 continue;
2287 };
2288 if b.kind.to_string() != mp.kind {
2289 failures.push(format!(
2290 "pack slot `{}`: kind is `{}`, expected `{}`",
2291 mp.slot, b.kind, mp.kind
2292 ));
2293 }
2294 if b.pack_ref.as_str() != mp.pack_ref {
2295 failures.push(format!(
2296 "pack slot `{}`: pack_ref is `{}`, expected `{}`",
2297 mp.slot,
2298 b.pack_ref.as_str(),
2299 mp.pack_ref
2300 ));
2301 }
2302 if mp.answers_ref.is_some()
2303 && b.answers_ref.as_deref() != Some(staged_answers_rel(mp.slot).as_path())
2304 {
2305 failures.push(format!(
2306 "pack slot `{}`: answers_ref is `{:?}`, expected `{:?}`",
2307 mp.slot,
2308 b.answers_ref,
2309 staged_answers_rel(mp.slot)
2310 ));
2311 }
2312 }
2313
2314 for mx in &ctx.manifest.extensions {
2316 checked += 1;
2317 let kind_path = greentic_deploy_spec::PackDescriptor::try_new(&mx.kind)
2318 .expect("validate_shape already validated kind")
2319 .path()
2320 .to_string();
2321 let ext_key = format!(
2322 "{}{}",
2323 kind_path,
2324 mx.instance_id
2325 .as_ref()
2326 .map(|i| format!("/{i}"))
2327 .unwrap_or_default()
2328 );
2329 let Some(b) = env
2330 .extensions
2331 .iter()
2332 .find(|b| b.kind.path() == kind_path && b.instance_id == mx.instance_id)
2333 else {
2334 failures.push(format!("extension `{ext_key}` is not bound"));
2335 continue;
2336 };
2337 if b.kind.to_string() != mx.kind {
2338 failures.push(format!(
2339 "extension `{ext_key}`: kind is `{}`, expected `{}`",
2340 b.kind, mx.kind
2341 ));
2342 }
2343 if b.pack_ref.as_str() != mx.pack_ref {
2344 failures.push(format!(
2345 "extension `{ext_key}`: pack_ref is `{}`, expected `{}`",
2346 b.pack_ref.as_str(),
2347 mx.pack_ref
2348 ));
2349 }
2350 }
2351
2352 for rb in &ctx.bundles {
2353 checked += 1;
2354 let Some(dep) = env
2355 .bundles
2356 .iter()
2357 .find(|d| d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id)
2358 else {
2359 failures.push(format!("bundle `{}` is not deployed", rb.spec.bundle_id));
2360 continue;
2361 };
2362 let is_multi = rb.spec.revisions.is_some();
2363 if is_multi {
2364 if !split_converged(&env, dep.deployment_id, &rb.revisions) {
2365 failures.push(format!(
2366 "bundle `{}`: traffic split not converged ({} revision(s) expected)",
2367 rb.spec.bundle_id,
2368 rb.revisions.len()
2369 ));
2370 }
2371 } else {
2372 let primary_digest = &rb.revisions[0].digest;
2373 if !deployment_converged(
2374 &env,
2375 dep.deployment_id,
2376 primary_digest,
2377 rb.spec.bundle_source_uri.as_deref(),
2378 ) {
2379 failures.push(format!(
2380 "bundle `{}`: live revision digest is `{}`, expected `{}`",
2381 rb.spec.bundle_id,
2382 live_revision_digest(&env, dep.deployment_id).unwrap_or("none"),
2383 primary_digest
2384 ));
2385 }
2386 }
2387 if let Some(binding) = &rb.spec.route_binding {
2388 let desired = into_route_binding(binding.clone());
2389 if desired != dep.route_binding {
2390 failures.push(format!(
2391 "bundle `{}`: route_binding differs from the manifest",
2392 rb.spec.bundle_id
2393 ));
2394 }
2395 }
2396 if let Some(overrides) = &rb.spec.config_overrides
2397 && *overrides != dep.config_overrides
2398 {
2399 failures.push(format!(
2400 "bundle `{}`: config_overrides differ from the manifest",
2401 rb.spec.bundle_id
2402 ));
2403 }
2404 if let Some(shares) = &rb.spec.revenue_share
2408 && convert_revenue_share(shares) != dep.revenue_share
2409 {
2410 failures.push(format!(
2411 "bundle `{}`: revenue_share differs from the manifest",
2412 rb.spec.bundle_id
2413 ));
2414 }
2415 let existed_pre_apply = ctx.env.as_ref().is_some_and(|e| {
2420 e.bundles
2421 .iter()
2422 .any(|d| d.bundle_id == dep.bundle_id && d.customer_id == dep.customer_id)
2423 });
2424 if let Some(status) = rb.spec.status
2425 && existed_pre_apply
2426 && status != dep.status
2427 {
2428 failures.push(format!(
2429 "bundle `{}`: status is `{:?}`, expected `{status:?}`",
2430 rb.spec.bundle_id, dep.status
2431 ));
2432 }
2433 }
2434
2435 for ep in &ctx.manifest.messaging_endpoints {
2438 checked += 1;
2439 let Some(m) = match_existing_endpoint(&env, ep)? else {
2440 failures.push(format!("endpoint `{}` is absent", ep.name));
2441 continue;
2442 };
2443 for link in &ep.links {
2444 if !m.linked_bundles.iter().any(|b| b.as_str() == *link) {
2445 failures.push(format!("endpoint `{}`: link `{link}` is absent", ep.name));
2446 }
2447 }
2448 if let Some(wf) = &ep.welcome_flow {
2449 let equal = m.welcome_flow.as_ref().is_some_and(|cur| {
2450 cur.bundle_id.as_str() == wf.bundle_id
2451 && cur.pack_id.as_str() == wf.pack_id
2452 && cur.flow_id == wf.flow_id
2453 });
2454 if !equal {
2455 failures.push(format!(
2456 "endpoint `{}`: welcome_flow differs from the manifest",
2457 ep.name
2458 ));
2459 }
2460 }
2461 }
2462
2463 if failures.is_empty() {
2464 Ok(json!({ "checked": checked, "failures": [] }))
2465 } else {
2466 Err(OpError::Conflict(format!(
2467 "apply executed but post-verify found {} mismatch(es): {}",
2468 failures.len(),
2469 failures.join("; ")
2470 )))
2471 }
2472}
2473
2474fn revenue_share_differs(
2479 rb: &ResolvedBundle,
2480 dep: &greentic_deploy_spec::BundleDeployment,
2481) -> bool {
2482 rb.spec
2483 .revenue_share
2484 .as_ref()
2485 .is_some_and(|s| convert_revenue_share(s) != dep.revenue_share)
2486}
2487
2488fn status_differs(rb: &ResolvedBundle, dep: &greentic_deploy_spec::BundleDeployment) -> bool {
2491 rb.spec.status.is_some_and(|s| s != dep.status)
2492}
2493
2494struct BundleMetaDiff {
2498 route_binding: Option<RouteBindingPayload>,
2500 config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
2503 revenue_share: Option<Vec<RevenueShareEntryPayload>>,
2505 status: Option<BundleDeploymentStatus>,
2507}
2508
2509impl BundleMetaDiff {
2510 fn is_noop(&self) -> bool {
2511 self.route_binding.is_none()
2512 && self.config_overrides.is_none()
2513 && self.revenue_share.is_none()
2514 && self.status.is_none()
2515 }
2516}
2517
2518fn bundle_meta_update_step(
2523 env_id: &EnvId,
2524 env_id_str: &str,
2525 bundle_id: &str,
2526 deployment_id: DeploymentId,
2527 desired_binding: &Option<RouteBinding>,
2528 diff: BundleMetaDiff,
2529) -> Option<ApplyStep> {
2530 if diff.is_noop() {
2531 return None;
2532 }
2533 let revenue_share_hash = diff.revenue_share.as_ref().map(|s| {
2536 s.iter()
2537 .map(|e| (e.party_id.clone(), e.basis_points))
2538 .collect::<Vec<_>>()
2539 });
2540 let desired_hash = hash_json(&json!({
2541 "route_binding": diff.route_binding,
2542 "config_overrides": diff.config_overrides,
2543 "revenue_share": revenue_share_hash,
2544 "status": diff.status,
2545 }));
2546 let ikey = derive_idempotency_key(
2547 env_id,
2548 ApplyStepKind::UpdateBundle.label(),
2549 bundle_id,
2550 &desired_hash,
2551 );
2552 let mut what = Vec::new();
2553 if diff.route_binding.is_some() {
2554 what.push(format!("binding → {}", binding_summary(desired_binding)));
2555 }
2556 if diff.config_overrides.is_some() {
2557 what.push("config_overrides".to_string());
2558 }
2559 if diff.revenue_share.is_some() {
2560 what.push("revenue_share".to_string());
2561 }
2562 if let Some(s) = diff.status {
2563 what.push(format!("status → {s:?}"));
2564 }
2565 Some(ApplyStep {
2566 kind: ApplyStepKind::UpdateBundle,
2567 key: bundle_id.to_string(),
2568 action: ApplyAction::Update,
2569 detail: what.join(", "),
2570 idempotency_key: Some(ikey.clone()),
2571 op: StepOp::BundleUpdate(Box::new(BundleUpdatePayload {
2572 environment_id: env_id_str.to_string(),
2573 deployment_id: deployment_id.to_string(),
2574 status: diff.status,
2575 route_binding: diff.route_binding,
2576 revenue_share: diff.revenue_share,
2577 config_overrides: diff.config_overrides,
2578 idempotency_key: Some(ikey),
2579 })),
2580 })
2581}
2582
2583fn staged_answers_rel(slot: CapabilitySlot) -> PathBuf {
2588 PathBuf::from("env-packs")
2589 .join(slot.to_string())
2590 .join("answers.json")
2591}
2592
2593fn resolve_answers_src(manifest_dir: &Path, manifest_ref: &Path) -> PathBuf {
2596 if manifest_ref.is_absolute() {
2597 manifest_ref.to_path_buf()
2598 } else {
2599 manifest_dir.join(manifest_ref)
2600 }
2601}
2602
2603fn staged_answers_outdated(
2607 store: &LocalFsStore,
2608 env_id: &EnvId,
2609 manifest_dir: &Path,
2610 slot: CapabilitySlot,
2611 manifest_ref: &Path,
2612) -> Result<bool, OpError> {
2613 let src = resolve_answers_src(manifest_dir, manifest_ref);
2614 let staged = store.env_dir(env_id)?.join(staged_answers_rel(slot));
2615 let src_bytes = std::fs::read(&src).map_err(|source| OpError::Io {
2616 path: src.clone(),
2617 source,
2618 })?;
2619 match std::fs::read(&staged) {
2620 Ok(staged_bytes) => Ok(staged_bytes != src_bytes),
2621 Err(_) => Ok(true),
2622 }
2623}
2624
2625fn stage_answers_file(
2631 store: &LocalFsStore,
2632 env_id: &EnvId,
2633 manifest_dir: &Path,
2634 slot: CapabilitySlot,
2635 manifest_ref: &Path,
2636) -> Result<PathBuf, OpError> {
2637 let src = resolve_answers_src(manifest_dir, manifest_ref);
2638 let rel = staged_answers_rel(slot);
2639 let dest = store.env_dir(env_id)?.join(&rel);
2640 let same_file = dest.exists() && src.canonicalize().ok() == dest.canonicalize().ok();
2641 if !same_file {
2642 if let Some(parent) = dest.parent() {
2643 std::fs::create_dir_all(parent).map_err(|source| OpError::Io {
2644 path: parent.to_path_buf(),
2645 source,
2646 })?;
2647 }
2648 std::fs::copy(&src, &dest).map_err(|source| OpError::Io {
2649 path: src.clone(),
2650 source,
2651 })?;
2652 }
2653 Ok(rel)
2654}
2655
2656fn stage_binding_answers(
2660 store: &LocalFsStore,
2661 ctx: &ApplyContext,
2662 mut payload: EnvPackBindingPayload,
2663) -> Result<EnvPackBindingPayload, OpError> {
2664 if let Some(manifest_ref) = payload.answers_ref.clone() {
2665 payload.answers_ref = Some(stage_answers_file(
2666 store,
2667 &ctx.env_id,
2668 &ctx.manifest_dir,
2669 payload.slot,
2670 &manifest_ref,
2671 )?);
2672 }
2673 Ok(payload)
2674}
2675
2676fn resolved_artifact_digest(
2682 path: &std::path::Path,
2683 declared: Option<&str>,
2684 location: &str,
2685) -> Result<String, OpError> {
2686 let digest = super::bundle_stage::sha256_file(path).map_err(|source| OpError::Io {
2687 path: path.to_path_buf(),
2688 source,
2689 })?;
2690 if let Some(declared) = declared
2691 && declared != digest
2692 {
2693 return Err(OpError::Conflict(format!(
2694 "{location}: declared bundle_digest `{declared}` does not match the resolved \
2695 artifact digest `{digest}`"
2696 )));
2697 }
2698 Ok(digest)
2699}
2700
2701fn deploy_payload(
2704 env_id: &str,
2705 rb: &ResolvedBundle,
2706 route_binding: Option<RouteBindingPayload>,
2707) -> BundleDeployPayload {
2708 BundleDeployPayload {
2709 environment_id: env_id.to_string(),
2710 bundle_id: rb.spec.bundle_id.clone(),
2711 customer_id: rb.spec.customer_id.clone(),
2712 bundle_path: Some(rb.revisions[0].resolved_path.clone()),
2713 bundle_source_uri: rb.spec.bundle_source_uri.clone(),
2716 remote_pins: None,
2719 idempotency_key: None,
2720 config_overrides: rb.spec.config_overrides.clone(),
2721 route_binding,
2722 revenue_share: rb.spec.revenue_share.clone(),
2725 }
2726}
2727
2728fn deploy_split_op(env_id: &str, rb: &ResolvedBundle) -> StepOp {
2730 StepOp::DeploySplit {
2731 env_id: env_id.to_string(),
2732 bundle_id: rb.spec.bundle_id.clone(),
2733 customer_id: rb.spec.customer_id.clone(),
2734 config_overrides: rb.spec.config_overrides.clone(),
2735 route_binding: rb.spec.route_binding.clone(),
2736 revenue_share: rb.spec.revenue_share.clone(),
2737 revisions: rb
2738 .revisions
2739 .iter()
2740 .map(|rr| SplitRevisionEntry {
2741 name: rr.spec.name.clone(),
2742 resolved_path: rr.resolved_path.clone(),
2743 expected_digest: rr.digest.clone(),
2744 weight_bps: rr.weight_bps,
2745 drain_seconds: rr.spec.drain_seconds,
2746 bundle_source_uri: rr.spec.bundle_source_uri.clone(),
2747 })
2748 .collect(),
2749 }
2750}
2751
2752fn execute_deploy_split(store: &LocalFsStore, flags: &OpFlags, op: &StepOp) -> Result<(), OpError> {
2756 use super::bundles::{BundleAddPayload, BundleSummary};
2757 use super::revisions::{RevisionStagePayload, RevisionSummary, RevisionTransitionPayload};
2758 use super::traffic::{TrafficSetEntryPayload, TrafficSetPayload};
2759
2760 let StepOp::DeploySplit {
2761 env_id,
2762 bundle_id,
2763 customer_id,
2764 config_overrides,
2765 route_binding,
2766 revenue_share,
2767 revisions: split_revs,
2768 } = op
2769 else {
2770 unreachable!("execute_deploy_split called with non-DeploySplit op");
2771 };
2772
2773 let env_id_parsed = EnvId::try_from(env_id.as_str())
2774 .map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
2775 let resolved_customer =
2776 super::bundles::resolve_customer_id(&env_id_parsed, customer_id.clone())?;
2777
2778 let env = store.load(&env_id_parsed)?;
2779 let existing = env
2780 .bundles
2781 .iter()
2782 .find(|b| b.bundle_id.as_str() == bundle_id.as_str() && b.customer_id == resolved_customer);
2783
2784 let deployment_id = match existing {
2785 Some(b) => b.deployment_id.to_string(),
2786 None => {
2787 let add_payload = BundleAddPayload {
2788 environment_id: env_id.clone(),
2789 bundle_id: bundle_id.clone(),
2790 customer_id: customer_id.clone(),
2791 route_binding: route_binding.clone().unwrap_or_default(),
2792 revenue_share: revenue_share
2793 .clone()
2794 .unwrap_or_else(super::bundles::default_revenue_share),
2795 authorization_ref: super::bundles::default_authorization_ref(),
2796 config_overrides: config_overrides.clone().unwrap_or_default(),
2797 idempotency_key: None,
2798 };
2799 let outcome = super::bundles::add(store, flags, Some(add_payload))?;
2800 let summary: BundleSummary = serde_json::from_value(outcome.result).map_err(|e| {
2801 OpError::InvalidArgument(format!("internal: bundle add summary: {e}"))
2802 })?;
2803 summary.deployment_id
2804 }
2805 };
2806
2807 let mut traffic_entries = Vec::with_capacity(split_revs.len());
2809 for rev in split_revs {
2810 eprintln!(
2811 " split: staging revision `{}` ({})",
2812 rev.name,
2813 short_digest(&rev.expected_digest)
2814 );
2815 ensure_artifact_unchanged(&rev.resolved_path, &rev.expected_digest)?;
2817
2818 let stage_payload = RevisionStagePayload {
2819 environment_id: env_id.to_string(),
2820 deployment_id: deployment_id.clone(),
2821 revision_id: None,
2823 idempotency_key: None,
2824 bundle_path: Some(rev.resolved_path.clone()),
2825 bundle_digest: super::revisions::default_bundle_digest(),
2826 bundle_source_uri: rev.bundle_source_uri.clone(),
2831 pack_list: Vec::new(),
2832 pack_list_lock_ref: PathBuf::new(),
2833 config_digest: super::revisions::default_config_digest(),
2834 signature_sidecar_ref: super::revisions::default_signature_sidecar_ref(),
2835 drain_seconds: rev
2836 .drain_seconds
2837 .unwrap_or_else(super::revisions::default_drain_seconds),
2838 };
2839 let stage_outcome = super::revisions::stage(store, flags, Some(stage_payload))?;
2840 let staged: RevisionSummary =
2841 serde_json::from_value(stage_outcome.result).map_err(|e| {
2842 OpError::InvalidArgument(format!("internal: revision stage summary: {e}"))
2843 })?;
2844
2845 super::revisions::warm(
2846 store,
2847 flags,
2848 Some(RevisionTransitionPayload {
2849 environment_id: env_id.to_string(),
2850 revision_id: staged.revision_id.clone(),
2851 idempotency_key: None,
2852 }),
2853 )?;
2854
2855 traffic_entries.push(TrafficSetEntryPayload {
2856 revision_id: staged.revision_id,
2857 weight_bps: Some(rev.weight_bps),
2858 weight_percent: None,
2859 });
2860 }
2861
2862 let ikey = format!(
2864 "deploy-split:{deployment_id}:{}",
2865 traffic_entries
2866 .iter()
2867 .map(|e| e.revision_id.as_str())
2868 .collect::<Vec<_>>()
2869 .join("+")
2870 );
2871 super::traffic::set(
2872 store,
2873 flags,
2874 Some(TrafficSetPayload {
2875 environment_id: env_id.to_string(),
2876 deployment_id: deployment_id.clone(),
2877 entries: traffic_entries,
2878 updated_by: super::traffic::default_updated_by(),
2879 idempotency_key: ikey,
2880 authorization_ref: super::traffic::default_authorization_ref(),
2881 }),
2882 )?;
2883
2884 Ok(())
2885}
2886
2887fn derive_idempotency_key(
2893 env_id: &EnvId,
2894 step_kind: &str,
2895 natural_key: &str,
2896 desired_state_hash: &str,
2897) -> String {
2898 let mut hasher = Sha256::new();
2899 for part in [
2900 ENV_MANIFEST_SCHEMA_V1,
2901 env_id.as_str(),
2902 step_kind,
2903 natural_key,
2904 desired_state_hash,
2905 ] {
2906 hasher.update(part.as_bytes());
2907 hasher.update([0u8]);
2908 }
2909 let digest = hasher.finalize();
2910 let mut bytes = [0u8; 16];
2911 bytes.copy_from_slice(&digest[..16]);
2912 ulid::Ulid::from(u128::from_be_bytes(bytes)).to_string()
2913}
2914
2915fn hash_json(value: &Value) -> String {
2919 let mut hasher = Sha256::new();
2920 hasher.update(value.to_string().as_bytes());
2921 hex::encode(hasher.finalize())
2922}
2923
2924fn live_revision_digest(env: &Environment, deployment_id: DeploymentId) -> Option<&str> {
2928 let split = env
2929 .traffic_splits
2930 .iter()
2931 .find(|s| s.deployment_id == deployment_id)?;
2932 let entry = split.entries.iter().max_by_key(|e| e.weight_bps)?;
2933 env.revisions
2934 .iter()
2935 .find(|r| r.revision_id == entry.revision_id)
2936 .map(|r| r.bundle_digest.as_str())
2937}
2938
2939pub(super) fn digest_is_real(digest: &str) -> bool {
2942 digest.starts_with("sha256:") && digest.len() > "sha256:".len() && digest != "sha256:00"
2943}
2944
2945fn deployment_converged(
2952 env: &Environment,
2953 deployment_id: DeploymentId,
2954 expected_digest: &str,
2955 expected_source_uri: Option<&str>,
2956) -> bool {
2957 let Some(split) = env
2958 .traffic_splits
2959 .iter()
2960 .find(|s| s.deployment_id == deployment_id)
2961 else {
2962 return false;
2963 };
2964 if split.entries.len() != 1 || split.entries[0].weight_bps != super::deploy::FULL_TRAFFIC_BPS {
2965 return false;
2966 }
2967 let entry = &split.entries[0];
2968 env.revisions
2969 .iter()
2970 .find(|r| r.revision_id == entry.revision_id)
2971 .is_some_and(|r| {
2972 digest_is_real(&r.bundle_digest)
2973 && r.bundle_digest == expected_digest
2974 && r.bundle_source_uri.as_deref() == expected_source_uri
2975 })
2976}
2977
2978fn split_converged(
2988 env: &Environment,
2989 deployment_id: DeploymentId,
2990 expected: &[ResolvedRevision],
2991) -> bool {
2992 let Some(split) = env
2993 .traffic_splits
2994 .iter()
2995 .find(|s| s.deployment_id == deployment_id)
2996 else {
2997 return false;
2998 };
2999 if split.entries.len() != expected.len() {
3000 return false;
3001 }
3002 let mut live_bag: Vec<(&str, u32, Option<&str>)> = Vec::with_capacity(split.entries.len());
3005 for entry in &split.entries {
3006 let Some(rev) = env
3007 .revisions
3008 .iter()
3009 .find(|r| r.revision_id == entry.revision_id)
3010 else {
3011 return false;
3012 };
3013 if !digest_is_real(&rev.bundle_digest) {
3014 return false;
3015 }
3016 live_bag.push((
3017 rev.bundle_digest.as_str(),
3018 entry.weight_bps,
3019 rev.bundle_source_uri.as_deref(),
3020 ));
3021 }
3022 let mut expected_bag: Vec<(&str, u32, Option<&str>)> = expected
3024 .iter()
3025 .map(|rr| {
3026 (
3027 rr.digest.as_str(),
3028 rr.weight_bps,
3029 rr.spec.bundle_source_uri.as_deref(),
3030 )
3031 })
3032 .collect();
3033 live_bag.sort_unstable();
3035 expected_bag.sort_unstable();
3036 live_bag == expected_bag
3037}
3038
3039fn ensure_artifact_unchanged(path: &Path, expected: &str) -> Result<(), OpError> {
3044 let actual = super::bundle_stage::sha256_file(path).map_err(|source| OpError::Io {
3045 path: path.to_path_buf(),
3046 source,
3047 })?;
3048 if actual != expected {
3049 return Err(OpError::Conflict(format!(
3050 "artifact `{}` changed since the plan was computed (expected {}, found {}); \
3051 re-run apply",
3052 path.display(),
3053 short_digest(expected),
3054 short_digest(&actual),
3055 )));
3056 }
3057 Ok(())
3058}
3059
3060fn short_digest(digest: &str) -> &str {
3061 let end = digest.len().min("sha256:".len() + 8);
3062 &digest[..end]
3063}
3064
3065fn short_key_id(key_id: &str) -> &str {
3066 &key_id[..key_id.len().min(8)]
3067}
3068
3069fn binding_summary(binding: &Option<RouteBinding>) -> String {
3070 match binding {
3071 None => "default binding".to_string(),
3072 Some(rb) => {
3073 let mut parts = Vec::new();
3074 if !rb.path_prefixes.is_empty() {
3075 parts.push(rb.path_prefixes.join(","));
3076 }
3077 if !rb.hosts.is_empty() {
3078 parts.push(format!("hosts={}", rb.hosts.join(",")));
3079 }
3080 parts.push(format!(
3081 "tenant={}/{}",
3082 rb.tenant_selector.tenant, rb.tenant_selector.team
3083 ));
3084 parts.join(" ")
3085 }
3086 }
3087}
3088
3089fn render_plan(steps: &[ApplyStep], warnings: &[String], missing: &[MissingItem]) {
3090 eprintln!("plan ({} step(s)):", steps.len());
3091 for step in steps {
3092 eprintln!(
3093 " {:<22} {:<40} {:<7} {}",
3094 step.kind.label(),
3095 step.key,
3096 step.action.as_str(),
3097 step.detail
3098 );
3099 }
3100 for m in missing {
3101 eprintln!(
3102 " missing: {:<14} {:<40} {}",
3103 m.kind.as_str(),
3104 m.key,
3105 m.source
3106 );
3107 }
3108 for w in warnings {
3109 eprintln!(" warning: {w}");
3110 }
3111}
3112
3113fn report_json(
3114 ctx: &ApplyContext,
3115 steps: &[ApplyStep],
3116 mode: &str,
3117 verify: Option<Value>,
3118) -> Value {
3119 let changed = steps
3120 .iter()
3121 .filter(|s| s.action != ApplyAction::NoOp)
3122 .count();
3123 let undiffable = steps
3127 .iter()
3128 .filter(|s| s.action == ApplyAction::Put)
3129 .count();
3130 let mut report = json!({
3131 "manifest_schema": ENV_MANIFEST_SCHEMA_V1,
3132 "environment_id": ctx.env_id.as_str(),
3133 "mode": mode,
3134 "steps": steps.iter().map(ApplyStep::to_json).collect::<Vec<_>>(),
3135 "changed": changed,
3136 "no_op": steps.len() - changed,
3137 "undiffable": undiffable,
3138 "missing": ctx.missing.iter().map(MissingItem::to_json).collect::<Vec<_>>(),
3139 "warnings": ctx.warnings,
3140 });
3141 if let Some(v) = verify {
3142 report["verify"] = v;
3143 }
3144 report
3145}
3146
3147#[cfg(test)]
3148mod tests {
3149 use super::*;
3150
3151 #[test]
3152 fn idempotency_keys_are_deterministic_and_state_sensitive() {
3153 let env_id = EnvId::try_from("local").unwrap();
3154 let a = derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h1");
3155 let b = derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h1");
3156 assert_eq!(a, b, "same inputs must derive the same key");
3157 assert_eq!(a.len(), 26, "ULID rendering is 26 chars");
3158 let other_env = EnvId::try_from("prod").unwrap();
3160 assert_ne!(
3161 a,
3162 derive_idempotency_key(&other_env, "add-endpoint", "realbot-legal", "h1")
3163 );
3164 assert_ne!(
3165 a,
3166 derive_idempotency_key(&env_id, "link-endpoint", "realbot-legal", "h1")
3167 );
3168 assert_ne!(
3169 a,
3170 derive_idempotency_key(&env_id, "add-endpoint", "realbot-acct", "h1")
3171 );
3172 assert_ne!(
3173 a,
3174 derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h2")
3175 );
3176 greentic_deploy_spec::IdempotencyKey::new(a).expect("derived key is spec-valid");
3178 }
3179
3180 #[test]
3181 fn degenerate_digests_are_not_real() {
3182 assert!(!digest_is_real("sha256:00"));
3183 assert!(!digest_is_real("sha256:"));
3184 assert!(!digest_is_real(""));
3185 assert!(!digest_is_real("md5:abcd"));
3186 assert!(digest_is_real("sha256:ab12cd34"));
3187 }
3188
3189 use crate::cli::tests_common::{
3192 bootstrap_env_trust_root, make_binding, make_bundle_deployment, make_env, make_revision,
3193 make_traffic_split,
3194 };
3195 use greentic_deploy_spec::{CapabilitySlot, RevisionLifecycle};
3196 use std::path::Path;
3197 use tempfile::tempdir;
3198
3199 fn seeded_store() -> (tempfile::TempDir, LocalFsStore) {
3200 let dir = tempdir().unwrap();
3201 let store = LocalFsStore::new(dir.path());
3202 store.save(&make_env("local")).unwrap();
3203 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
3204 bootstrap_env_trust_root(&env_dir);
3205 (dir, store)
3206 }
3207
3208 fn fixture() -> PathBuf {
3209 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3210 .join("testdata/bundles/perf-smoke-bundle.gtbundle")
3211 }
3212
3213 fn write_manifest(dir: &Path, value: &Value) -> PathBuf {
3214 let path = dir.join("manifest.json");
3215 std::fs::write(&path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
3216 path
3217 }
3218
3219 #[test]
3220 fn resolved_artifact_digest_records_computed_when_undeclared() {
3221 let path = fixture();
3222 let digest = resolved_artifact_digest(&path, None, "bundle `x`").unwrap();
3223 assert_eq!(
3224 digest,
3225 super::super::bundle_stage::sha256_file(&path).unwrap()
3226 );
3227 }
3228
3229 #[test]
3230 fn resolved_artifact_digest_passes_when_declared_matches() {
3231 let path = fixture();
3232 let real = super::super::bundle_stage::sha256_file(&path).unwrap();
3233 let digest = resolved_artifact_digest(&path, Some(&real), "bundle `x`").unwrap();
3234 assert_eq!(digest, real);
3235 }
3236
3237 #[test]
3238 fn resolved_artifact_digest_rejects_declared_mismatch() {
3239 let path = fixture();
3240 let err =
3241 resolved_artifact_digest(&path, Some("sha256:deadbeef"), "bundle `x`").unwrap_err();
3242 assert_eq!(err.kind(), "conflict");
3243 assert!(err.to_string().contains("does not match"), "{err}");
3244 }
3245
3246 #[test]
3251 #[ignore = "network: applies a URI-only manifest that pulls from ghcr"]
3252 fn uri_only_apply_pulls_and_records_a_pullable_revision() {
3253 const URI: &str = "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1";
3254 let (dir, store) = seeded_store();
3255 let manifest = serde_json::json!({
3256 "schema": ENV_MANIFEST_SCHEMA_V1,
3257 "environment": {"id": "local"},
3258 "bundles": [{
3259 "bundle_id": "remote",
3260 "bundle_source_uri": URI
3261 }]
3262 });
3263 let manifest_path = write_manifest(dir.path(), &manifest);
3264 let outcome = run_apply(&store, &manifest_path).expect("uri-only apply succeeds");
3265 assert_eq!(
3266 outcome.result["missing"].as_array().unwrap().len(),
3267 0,
3268 "no missing inputs: {}",
3269 outcome.result
3270 );
3271 let env = load_local(&store);
3272 assert_eq!(env.bundles.len(), 1);
3273 let dep = &env.bundles[0];
3274 let digest = live_revision_digest(&env, dep.deployment_id).expect("live revision");
3275 assert!(
3276 digest.starts_with("sha256:") && digest != "sha256:00",
3277 "real digest recorded: {digest}"
3278 );
3279 let rev = env
3280 .revisions
3281 .iter()
3282 .find(|r| r.deployment_id == dep.deployment_id)
3283 .expect("revision recorded");
3284 assert_eq!(
3285 rev.bundle_source_uri.as_deref(),
3286 Some(URI),
3287 "K8s worker pull ref recorded on the revision"
3288 );
3289 }
3290
3291 fn run_mode(
3292 store: &LocalFsStore,
3293 manifest_path: &Path,
3294 mode: ApplyMode,
3295 ) -> Result<OpOutcome, OpError> {
3296 let flags = OpFlags {
3297 schema_only: false,
3298 answers: Some(manifest_path.to_path_buf()),
3299 };
3300 apply(
3301 store,
3302 &flags,
3303 ApplyOptions {
3304 mode,
3305 ..ApplyOptions::default()
3306 },
3307 )
3308 }
3309
3310 fn run_apply(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3311 run_mode(store, manifest_path, ApplyMode::Apply)
3312 }
3313
3314 fn run_dry(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3315 run_mode(store, manifest_path, ApplyMode::DryRun)
3316 }
3317
3318 fn run_check(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3319 run_mode(store, manifest_path, ApplyMode::Check)
3320 }
3321
3322 fn load_local(store: &LocalFsStore) -> Environment {
3323 store.load(&EnvId::try_from("local").unwrap()).unwrap()
3324 }
3325
3326 fn full_manifest(bundle_path: &Path) -> Value {
3329 json!({
3330 "schema": ENV_MANIFEST_SCHEMA_V1,
3331 "environment": {"id": "local"},
3332 "bundles": [{
3333 "bundle_id": "quickstart",
3334 "bundle_path": bundle_path,
3335 "route_binding": {
3336 "path_prefixes": ["/legal"],
3337 "tenant_selector": {"tenant": "legal", "team": "default"}
3338 }
3339 }],
3340 "messaging_endpoints": [{
3341 "name": "legal-bot",
3342 "provider_type": "messaging.telegram.bot",
3343 "links": ["quickstart"],
3344 "welcome_flow": {
3345 "bundle_id": "quickstart",
3346 "pack_id": "perf-smoke-pack",
3347 "flow_id": "main"
3348 }
3349 }]
3350 })
3351 }
3352
3353 fn step_actions(outcome: &Value) -> Vec<(String, String)> {
3354 outcome["steps"]
3355 .as_array()
3356 .expect("steps array")
3357 .iter()
3358 .map(|s| {
3359 (
3360 s["kind"].as_str().unwrap().to_string(),
3361 s["action"].as_str().unwrap().to_string(),
3362 )
3363 })
3364 .collect()
3365 }
3366
3367 #[test]
3368 fn fresh_apply_then_noop_reapply() {
3369 let (dir, store) = seeded_store();
3370 let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3371
3372 let outcome = run_apply(&store, &manifest_path).expect("first apply succeeds");
3373 let result = outcome.result;
3374 assert_eq!(result["mode"], "apply");
3375 assert_eq!(result["changed"], 4, "result: {result}");
3377 assert_eq!(
3378 result["verify"]["failures"].as_array().unwrap().len(),
3379 0,
3380 "verify must pass: {result}"
3381 );
3382
3383 let env = load_local(&store);
3384 assert_eq!(env.bundles.len(), 1);
3385 let dep = &env.bundles[0];
3386 assert_eq!(dep.route_binding.path_prefixes, vec!["/legal".to_string()]);
3387 assert_eq!(dep.route_binding.tenant_selector.tenant, "legal");
3388 let live = live_revision_digest(&env, dep.deployment_id).expect("live revision");
3389 assert_eq!(
3390 live,
3391 super::super::bundle_stage::sha256_file(&fixture()).unwrap(),
3392 "live revision must carry the artifact digest"
3393 );
3394 assert_eq!(env.messaging_endpoints.len(), 1);
3395 let ep = &env.messaging_endpoints[0];
3396 assert_eq!(ep.display_name, "legal-bot");
3397 assert_eq!(ep.provider_id, "legal-bot", "provider_id = manifest name");
3398 assert_eq!(
3399 ep.linked_bundles
3400 .iter()
3401 .map(|b| b.as_str())
3402 .collect::<Vec<_>>(),
3403 vec!["quickstart"]
3404 );
3405 let wf = ep.welcome_flow.as_ref().expect("welcome flow set");
3406 assert_eq!(wf.flow_id, "main");
3407 assert!(
3408 ep.webhook_secret_ref.is_some(),
3409 "telegram-class endpoint gets an auto-provisioned webhook secret"
3410 );
3411
3412 let revisions_before = env.revisions.len();
3414 let second = run_apply(&store, &manifest_path).expect("re-apply succeeds");
3415 assert_eq!(second.result["changed"], 0, "result: {}", second.result);
3416 let env = load_local(&store);
3417 assert_eq!(
3418 env.revisions.len(),
3419 revisions_before,
3420 "no-op re-apply must not stage a new revision"
3421 );
3422 }
3423
3424 #[test]
3425 fn dry_run_mutates_nothing() {
3426 let (dir, store) = seeded_store();
3427 let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3428
3429 let outcome = run_dry(&store, &manifest_path).expect("dry-run succeeds");
3430 assert_eq!(outcome.result["mode"], "dry-run");
3431 assert_eq!(outcome.result["changed"], 4);
3432 assert!(
3433 outcome.result.get("verify").is_none(),
3434 "dry-run must not verify (nothing executed)"
3435 );
3436
3437 let env = load_local(&store);
3438 assert!(env.bundles.is_empty(), "dry-run must not deploy");
3439 assert!(
3440 env.messaging_endpoints.is_empty(),
3441 "dry-run must not add endpoints"
3442 );
3443 }
3444
3445 #[test]
3448 fn check_fails_on_pending_diff_and_mutates_nothing() {
3449 let (dir, store) = seeded_store();
3450 let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3451
3452 let err = run_check(&store, &manifest_path).unwrap_err();
3453 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
3454 let msg = err.to_string();
3455 assert!(msg.contains("not converged"), "{msg}");
3456 assert!(msg.contains("4 pending change(s)"), "{msg}");
3457
3458 let env = load_local(&store);
3459 assert!(env.bundles.is_empty(), "--check must not deploy");
3460 assert!(
3461 env.messaging_endpoints.is_empty(),
3462 "--check must not add endpoints"
3463 );
3464 }
3465
3466 #[test]
3467 fn check_passes_on_converged_env() {
3468 let (dir, store) = seeded_store();
3469 let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3470 run_apply(&store, &manifest_path).expect("apply succeeds");
3471
3472 let outcome = run_check(&store, &manifest_path).expect("check passes on converged env");
3473 assert_eq!(outcome.result["mode"], "check");
3474 assert_eq!(outcome.result["changed"], 0, "result: {}", outcome.result);
3475 assert_eq!(outcome.result["undiffable"], 0);
3476 assert!(
3477 outcome.result.get("verify").is_none(),
3478 "check must not verify (nothing executed)"
3479 );
3480 }
3481
3482 #[test]
3483 fn binding_only_change_plans_update_without_restage() {
3484 let (dir, store) = seeded_store();
3485 let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3486 run_apply(&store, &manifest_path).expect("first apply");
3487 let revisions_before = load_local(&store).revisions.len();
3488
3489 let mut changed = full_manifest(&fixture());
3492 changed["bundles"][0]["route_binding"]["path_prefixes"] = json!(["/law"]);
3493 let manifest_path = write_manifest(dir.path(), &changed);
3494
3495 let plan = run_dry(&store, &manifest_path).expect("dry-run");
3496 let actions = step_actions(&plan.result);
3497 assert!(
3498 actions.contains(&("update-bundle".to_string(), "update".to_string())),
3499 "plan: {actions:?}"
3500 );
3501 assert!(
3502 actions
3503 .iter()
3504 .all(|(kind, action)| kind != "deploy-bundle" || action == "no-op"),
3505 "digest match must not re-deploy: {actions:?}"
3506 );
3507
3508 let outcome = run_apply(&store, &manifest_path).expect("apply binding change");
3509 assert_eq!(outcome.result["changed"], 1, "{}", outcome.result);
3510 let env = load_local(&store);
3511 assert_eq!(
3512 env.bundles[0].route_binding.path_prefixes,
3513 vec!["/law".to_string()]
3514 );
3515 assert_eq!(
3516 env.revisions.len(),
3517 revisions_before,
3518 "binding-only change must not stage a new revision"
3519 );
3520 }
3521
3522 fn plain_bundle_manifest() -> Value {
3527 json!({
3528 "schema": ENV_MANIFEST_SCHEMA_V1,
3529 "environment": {"id": "local"},
3530 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3531 })
3532 }
3533
3534 #[test]
3535 fn status_reconciles_against_existing_deployment() {
3536 let (dir, store) = seeded_store();
3537 let base = plain_bundle_manifest();
3538 let base_path = write_manifest(dir.path(), &base);
3539 run_apply(&store, &base_path).expect("first apply");
3540 assert_eq!(
3541 load_local(&store).bundles[0].status,
3542 BundleDeploymentStatus::Active
3543 );
3544
3545 let mut paused = base.clone();
3547 paused["bundles"][0]["status"] = json!("paused");
3548 let paused_path = write_manifest(dir.path(), &paused);
3549 let plan = run_dry(&store, &paused_path).expect("dry-run");
3550 let actions = step_actions(&plan.result);
3551 assert!(
3552 actions.contains(&("update-bundle".to_string(), "update".to_string())),
3553 "status change must plan an update-bundle: {actions:?}"
3554 );
3555 assert!(
3556 actions
3557 .iter()
3558 .all(|(k, a)| k != "deploy-bundle" || a == "no-op"),
3559 "status change must not re-stage: {actions:?}"
3560 );
3561
3562 run_apply(&store, &paused_path).expect("apply pause");
3563 assert_eq!(
3564 load_local(&store).bundles[0].status,
3565 BundleDeploymentStatus::Paused
3566 );
3567
3568 let plan2 = run_dry(&store, &paused_path).expect("dry-run 2");
3570 assert_eq!(plan2.result["changed"], 0, "{}", plan2.result);
3571 }
3572
3573 #[test]
3574 fn revenue_share_reconciles_against_existing_deployment() {
3575 let (dir, store) = seeded_store();
3576 let base = plain_bundle_manifest();
3577 let base_path = write_manifest(dir.path(), &base);
3578 run_apply(&store, &base_path).expect("first apply");
3579 assert_eq!(load_local(&store).bundles[0].revenue_share.len(), 1);
3581
3582 let mut split = base.clone();
3583 split["bundles"][0]["revenue_share"] = json!([
3584 {"party_id": "greentic", "basis_points": 7000},
3585 {"party_id": "partner", "basis_points": 3000}
3586 ]);
3587 let split_path = write_manifest(dir.path(), &split);
3588 let plan = run_dry(&store, &split_path).expect("dry-run");
3589 assert!(
3590 step_actions(&plan.result)
3591 .contains(&("update-bundle".to_string(), "update".to_string())),
3592 "revenue_share change must plan an update-bundle"
3593 );
3594
3595 run_apply(&store, &split_path).expect("apply revenue split");
3596 let env = load_local(&store);
3597 let shares = &env.bundles[0].revenue_share;
3598 assert_eq!(shares.len(), 2);
3599 assert_eq!(shares[0].party_id.as_str(), "greentic");
3600 assert_eq!(shares[0].basis_points, 7000);
3601 assert_eq!(shares[1].party_id.as_str(), "partner");
3602 assert_eq!(shares[1].basis_points, 3000);
3603
3604 let plan2 = run_dry(&store, &split_path).expect("dry-run 2");
3606 assert_eq!(plan2.result["changed"], 0, "{}", plan2.result);
3607 }
3608
3609 #[test]
3610 fn revenue_share_threaded_into_fresh_deploy_single_apply() {
3611 let (dir, store) = seeded_store();
3612 let mut manifest = plain_bundle_manifest();
3613 manifest["bundles"][0]["revenue_share"] = json!([
3614 {"party_id": "greentic", "basis_points": 6000},
3615 {"party_id": "partner", "basis_points": 4000}
3616 ]);
3617 let p = write_manifest(dir.path(), &manifest);
3618 run_apply(&store, &p).expect("fresh apply with revenue_share must verify");
3621 let shares = &load_local(&store).bundles[0].revenue_share;
3622 assert_eq!(shares.len(), 2);
3623 assert_eq!(shares[1].party_id.as_str(), "partner");
3624 assert_eq!(shares[1].basis_points, 4000);
3625 assert_eq!(run_dry(&store, &p).expect("dry").result["changed"], 0);
3626 }
3627
3628 #[test]
3629 fn status_on_fresh_create_defers_one_apply_without_failing_verify() {
3630 let (dir, store) = seeded_store();
3631 let mut manifest = plain_bundle_manifest();
3632 manifest["bundles"][0]["status"] = json!("paused");
3633 let p = write_manifest(dir.path(), &manifest);
3634 run_apply(&store, &p).expect("fresh apply with status must not fail verify");
3637 assert_eq!(
3638 load_local(&store).bundles[0].status,
3639 BundleDeploymentStatus::Active
3640 );
3641 run_apply(&store, &p).expect("second apply pauses");
3644 assert_eq!(
3645 load_local(&store).bundles[0].status,
3646 BundleDeploymentStatus::Paused
3647 );
3648 }
3649
3650 #[test]
3651 fn degenerate_live_digest_fails_toward_redeploy() {
3652 let (dir, store) = seeded_store();
3653 let mut env = make_env("local");
3656 let dep = make_bundle_deployment("local", "quickstart");
3657 let rev = make_revision(
3658 "local",
3659 "quickstart",
3660 &dep.deployment_id,
3661 1,
3662 RevisionLifecycle::Ready,
3663 );
3664 env.traffic_splits.push(make_traffic_split(
3665 "local",
3666 "quickstart",
3667 &dep.deployment_id,
3668 &rev.revision_id,
3669 "seed",
3670 ));
3671 env.bundles.push(dep);
3672 env.revisions.push(rev);
3673 store.save(&env).unwrap();
3674
3675 let manifest = json!({
3678 "schema": ENV_MANIFEST_SCHEMA_V1,
3679 "environment": {"id": "local"},
3680 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3681 });
3682 let manifest_path = write_manifest(dir.path(), &manifest);
3683 let plan = run_dry(&store, &manifest_path).expect("dry-run");
3684 let actions = step_actions(&plan.result);
3685 assert!(
3686 actions.contains(&("deploy-bundle".to_string(), "update".to_string())),
3687 "degenerate digest must plan a re-deploy: {actions:?}"
3688 );
3689 assert_eq!(plan.result["changed"], 1, "{}", plan.result);
3690 }
3691
3692 #[test]
3693 fn endpoint_ambiguity_is_an_error() {
3694 let (dir, store) = seeded_store();
3695 for provider_id in ["bot-a", "bot-b"] {
3699 super::super::messaging::add(
3700 &store,
3701 &OpFlags::default(),
3702 Some(EndpointAddPayload {
3703 environment_id: "local".to_string(),
3704 provider_id: provider_id.to_string(),
3705 provider_type: "messaging.telegram.bot".to_string(),
3706 display_name: "legal-bot".to_string(),
3707 secret_refs: Vec::new(),
3708 webhook_secret_ref: None,
3709 idempotency_key: None,
3710 updated_by: "test".to_string(),
3711 }),
3712 )
3713 .expect("seed endpoint");
3714 }
3715 let manifest = json!({
3716 "schema": ENV_MANIFEST_SCHEMA_V1,
3717 "environment": {"id": "local"},
3718 "messaging_endpoints": [
3719 {"name": "legal-bot", "provider_type": "messaging.telegram.bot"}
3720 ]
3721 });
3722 let manifest_path = write_manifest(dir.path(), &manifest);
3723 let err = run_apply(&store, &manifest_path).unwrap_err();
3724 match err {
3725 OpError::Conflict(msg) => {
3726 assert!(msg.contains("refuses to guess"), "got: {msg}")
3727 }
3728 other => panic!("expected Conflict, got {other:?}"),
3729 }
3730 }
3731
3732 #[test]
3733 fn provider_type_mismatch_on_matched_name_is_an_error() {
3734 let (dir, store) = seeded_store();
3735 super::super::messaging::add(
3736 &store,
3737 &OpFlags::default(),
3738 Some(EndpointAddPayload {
3739 environment_id: "local".to_string(),
3740 provider_id: "legal-bot".to_string(),
3741 provider_type: "messaging.teams.bot".to_string(),
3742 display_name: "legal-bot".to_string(),
3743 secret_refs: Vec::new(),
3744 webhook_secret_ref: None,
3745 idempotency_key: None,
3746 updated_by: "test".to_string(),
3747 }),
3748 )
3749 .expect("seed endpoint");
3750 let manifest = json!({
3751 "schema": ENV_MANIFEST_SCHEMA_V1,
3752 "environment": {"id": "local"},
3753 "messaging_endpoints": [
3754 {"name": "legal-bot", "provider_type": "messaging.telegram.bot"}
3755 ]
3756 });
3757 let manifest_path = write_manifest(dir.path(), &manifest);
3758 let err = run_apply(&store, &manifest_path).unwrap_err();
3759 match err {
3760 OpError::Conflict(msg) => assert!(msg.contains("repurpose"), "got: {msg}"),
3761 other => panic!("expected Conflict, got {other:?}"),
3762 }
3763 }
3764
3765 #[test]
3766 fn link_to_unknown_bundle_is_an_error() {
3767 let (dir, store) = seeded_store();
3768 let manifest = json!({
3769 "schema": ENV_MANIFEST_SCHEMA_V1,
3770 "environment": {"id": "local"},
3771 "messaging_endpoints": [{
3772 "name": "legal-bot",
3773 "provider_type": "messaging.telegram.bot",
3774 "links": ["ghost"]
3775 }]
3776 });
3777 let manifest_path = write_manifest(dir.path(), &manifest);
3778 let err = run_apply(&store, &manifest_path).unwrap_err();
3779 match err {
3780 OpError::InvalidArgument(msg) => assert!(msg.contains("ghost"), "got: {msg}"),
3781 other => panic!("expected InvalidArgument, got {other:?}"),
3782 }
3783 assert!(load_local(&store).messaging_endpoints.is_empty());
3785 }
3786
3787 #[test]
3788 fn partial_failure_resumes_on_reapply() {
3789 let (dir, store) = seeded_store();
3790 let broken = dir.path().join("broken.gtbundle");
3793 std::fs::write(&broken, b"not a squashfs").unwrap();
3794 let manifest = json!({
3795 "schema": ENV_MANIFEST_SCHEMA_V1,
3796 "environment": {"id": "local"},
3797 "bundles": [
3798 {"bundle_id": "quickstart", "bundle_path": fixture()},
3799 {"bundle_id": "broken", "bundle_path": broken}
3800 ]
3801 });
3802 let manifest_path = write_manifest(dir.path(), &manifest);
3803 run_apply(&store, &manifest_path).expect_err("garbage artifact must fail the apply");
3804
3805 let env = load_local(&store);
3810 assert_eq!(env.bundles.len(), 2, "both deployment records exist");
3811 assert_eq!(
3812 env.traffic_splits.len(),
3813 1,
3814 "only the first bundle is routed"
3815 );
3816 assert_eq!(
3817 env.traffic_splits[0].bundle_id.as_str(),
3818 "quickstart",
3819 "the routed split belongs to the bundle that staged successfully"
3820 );
3821
3822 let fixed = json!({
3825 "schema": ENV_MANIFEST_SCHEMA_V1,
3826 "environment": {"id": "local"},
3827 "bundles": [
3828 {"bundle_id": "quickstart", "bundle_path": fixture()},
3829 {"bundle_id": "broken", "bundle_path": fixture()}
3830 ]
3831 });
3832 let manifest_path = write_manifest(dir.path(), &fixed);
3833 let outcome = run_apply(&store, &manifest_path).expect("resume succeeds");
3834 assert_eq!(outcome.result["changed"], 1, "{}", outcome.result);
3835 let env = load_local(&store);
3836 assert_eq!(env.bundles.len(), 2);
3837 }
3838
3839 #[test]
3840 fn nonexistent_nonlocal_env_gives_clear_error() {
3841 let dir = tempdir().unwrap();
3846 let store = LocalFsStore::new(dir.path());
3847 let manifest = json!({
3848 "schema": ENV_MANIFEST_SCHEMA_V1,
3849 "environment": {
3850 "id": "prod",
3851 "name": "Production",
3852 "region": "us-east-1",
3853 "tenant_org_id": "org-42",
3854 }
3855 });
3856 let manifest_path = write_manifest(dir.path(), &manifest);
3857 let err = run_apply(&store, &manifest_path).unwrap_err();
3858 match err {
3859 OpError::NotFound(msg) => {
3860 assert!(
3861 msg.contains("not found")
3862 && msg.contains("op env create prod")
3863 && msg.contains("does not create one"),
3864 "got: {msg}"
3865 );
3866 }
3867 other => panic!("expected NotFound, got {other:?}"),
3868 }
3869 }
3870
3871 #[test]
3872 fn link_satisfied_only_by_env_warns() {
3873 let (dir, store) = seeded_store();
3874 super::super::deploy::deploy(
3877 &store,
3878 &OpFlags::default(),
3879 Some(BundleDeployPayload {
3880 environment_id: "local".to_string(),
3881 bundle_id: "preexisting".to_string(),
3882 customer_id: None,
3883 bundle_path: Some(fixture()),
3884 bundle_source_uri: None,
3885 remote_pins: None,
3886 idempotency_key: None,
3887 config_overrides: None,
3888 route_binding: None,
3889 revenue_share: None,
3890 }),
3891 )
3892 .expect("imperative deploy");
3893 let manifest = json!({
3894 "schema": ENV_MANIFEST_SCHEMA_V1,
3895 "environment": {"id": "local"},
3896 "messaging_endpoints": [{
3897 "name": "legal-bot",
3898 "provider_type": "messaging.telegram.bot",
3899 "links": ["preexisting"]
3900 }]
3901 });
3902 let manifest_path = write_manifest(dir.path(), &manifest);
3903 let outcome = run_apply(&store, &manifest_path).expect("apply succeeds with warning");
3904 let warnings = outcome.result["warnings"].as_array().unwrap();
3905 assert!(
3906 warnings
3907 .iter()
3908 .any(|w| w.as_str().unwrap().contains("pre-existing")),
3909 "warnings: {warnings:?}"
3910 );
3911 let env = load_local(&store);
3912 assert_eq!(env.messaging_endpoints[0].linked_bundles.len(), 1);
3913 }
3914
3915 #[test]
3918 fn cross_customer_deployment_is_rejected() {
3919 let (dir, store) = seeded_store();
3920 let mut env = make_env("local");
3922 let mut dep = make_bundle_deployment("local", "quickstart");
3923 dep.customer_id = CustomerId::new("other-customer");
3924 env.bundles.push(dep);
3925 store.save(&env).unwrap();
3926
3927 let manifest = json!({
3930 "schema": ENV_MANIFEST_SCHEMA_V1,
3931 "environment": {"id": "local"},
3932 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3933 });
3934 let manifest_path = write_manifest(dir.path(), &manifest);
3935 let err = run_apply(&store, &manifest_path).unwrap_err();
3936 match err {
3937 OpError::Conflict(msg) => {
3938 assert!(msg.contains("other-customer"), "got: {msg}");
3939 assert!(msg.contains("local-dev"), "got: {msg}");
3940 }
3941 other => panic!("expected Conflict, got {other:?}"),
3942 }
3943 let env = load_local(&store);
3945 assert!(env.revisions.is_empty());
3946 }
3947
3948 #[test]
3951 fn ensure_artifact_unchanged_catches_modification() {
3952 let dir = tempdir().unwrap();
3953 let path = dir.path().join("test.bin");
3954 std::fs::write(&path, b"original content").unwrap();
3955 let digest = super::super::bundle_stage::sha256_file(&path).unwrap();
3956
3957 ensure_artifact_unchanged(&path, &digest).expect("unchanged file must pass");
3959
3960 std::fs::write(&path, b"tampered content").unwrap();
3962 let err = ensure_artifact_unchanged(&path, &digest).unwrap_err();
3963 match err {
3964 OpError::Conflict(msg) => {
3965 assert!(msg.contains("changed since the plan"), "got: {msg}")
3966 }
3967 other => panic!("expected Conflict, got {other:?}"),
3968 }
3969 }
3970
3971 #[test]
3974 fn mixed_split_is_not_converged_plans_redeploy() {
3975 use greentic_deploy_spec::TrafficSplitEntry;
3976
3977 let (dir, store) = seeded_store();
3978 let real_digest = super::super::bundle_stage::sha256_file(&fixture()).unwrap();
3979
3980 let mut env = make_env("local");
3981 let dep = make_bundle_deployment("local", "quickstart");
3982 let mut rev1 = make_revision(
3983 "local",
3984 "quickstart",
3985 &dep.deployment_id,
3986 1,
3987 RevisionLifecycle::Ready,
3988 );
3989 rev1.bundle_digest = real_digest;
3990 let rev2 = make_revision(
3991 "local",
3992 "quickstart",
3993 &dep.deployment_id,
3994 2,
3995 RevisionLifecycle::Ready,
3996 );
3997 let mut split = make_traffic_split(
3999 "local",
4000 "quickstart",
4001 &dep.deployment_id,
4002 &rev1.revision_id,
4003 "seed",
4004 );
4005 split.entries[0].weight_bps = 6_000;
4006 split.entries.push(TrafficSplitEntry {
4007 revision_id: rev2.revision_id,
4008 weight_bps: 4_000,
4009 });
4010 env.bundles.push(dep);
4011 env.revisions.push(rev1);
4012 env.revisions.push(rev2);
4013 env.traffic_splits.push(split);
4014 store.save(&env).unwrap();
4015
4016 let manifest = json!({
4019 "schema": ENV_MANIFEST_SCHEMA_V1,
4020 "environment": {"id": "local"},
4021 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
4022 });
4023 let manifest_path = write_manifest(dir.path(), &manifest);
4024 let plan = run_dry(&store, &manifest_path).expect("dry-run");
4025 let actions = step_actions(&plan.result);
4026 assert!(
4027 actions.contains(&("deploy-bundle".to_string(), "update".to_string())),
4028 "mixed split must plan a re-deploy: {actions:?}"
4029 );
4030 assert!(plan.result["changed"].as_u64().unwrap() >= 1);
4031 }
4032
4033 fn resolved_rev(name: &str, digest: &str, weight_bps: u32) -> ResolvedRevision {
4038 ResolvedRevision {
4039 spec: ManifestRevision {
4040 name: name.to_string(),
4041 bundle_path: PathBuf::from(format!("{name}.gtbundle")),
4042 weight_percent: None,
4043 weight_bps: Some(weight_bps),
4044 drain_seconds: None,
4045 abort_metrics: Vec::new(),
4046 bundle_source_uri: None,
4047 bundle_digest: None,
4048 },
4049 resolved_path: PathBuf::from(format!("{name}.gtbundle")),
4050 digest: digest.to_string(),
4051 weight_bps,
4052 }
4053 }
4054
4055 fn env_with_two_entry_split(live: [(&str, u32); 2]) -> (Environment, DeploymentId) {
4059 use greentic_deploy_spec::TrafficSplitEntry;
4060 let mut env = make_env("local");
4061 let dep = make_bundle_deployment("local", "quickstart");
4062 let dep_id = dep.deployment_id;
4063 let mut rev1 = make_revision("local", "quickstart", &dep_id, 1, RevisionLifecycle::Ready);
4064 rev1.bundle_digest = live[0].0.to_string();
4065 let mut rev2 = make_revision("local", "quickstart", &dep_id, 2, RevisionLifecycle::Ready);
4066 rev2.bundle_digest = live[1].0.to_string();
4067 let mut split =
4068 make_traffic_split("local", "quickstart", &dep_id, &rev1.revision_id, "seed");
4069 split.entries[0].weight_bps = live[0].1;
4070 split.entries.push(TrafficSplitEntry {
4071 revision_id: rev2.revision_id,
4072 weight_bps: live[1].1,
4073 });
4074 env.bundles.push(dep);
4075 env.revisions.push(rev1);
4076 env.revisions.push(rev2);
4077 env.traffic_splits.push(split);
4078 (env, dep_id)
4079 }
4080
4081 #[test]
4082 fn split_converged_matches_exact_multiset() {
4083 let (env, dep_id) =
4084 env_with_two_entry_split([("sha256:aaaa11", 5_000), ("sha256:bbbb22", 5_000)]);
4085 let expected = [
4086 resolved_rev("a", "sha256:aaaa11", 5_000),
4087 resolved_rev("b", "sha256:bbbb22", 5_000),
4088 ];
4089 assert!(
4090 split_converged(&env, dep_id, &expected),
4091 "identical (digest, weight) bag must converge"
4092 );
4093 let swapped = [
4095 resolved_rev("b", "sha256:bbbb22", 5_000),
4096 resolved_rev("a", "sha256:aaaa11", 5_000),
4097 ];
4098 assert!(split_converged(&env, dep_id, &swapped));
4099 }
4100
4101 #[test]
4102 fn split_converged_rejects_duplicate_digest_mismatch() {
4103 let (env, dep_id) =
4108 env_with_two_entry_split([("sha256:aaaa11", 5_000), ("sha256:bbbb22", 5_000)]);
4109 let expected = [
4110 resolved_rev("a", "sha256:aaaa11", 5_000),
4111 resolved_rev("a-dup", "sha256:aaaa11", 5_000),
4112 ];
4113 assert!(
4114 !split_converged(&env, dep_id, &expected),
4115 "duplicate-digest expected bag must NOT match a two-distinct-digest live split"
4116 );
4117 }
4118
4119 #[test]
4120 fn split_converged_rejects_weight_mismatch() {
4121 let (env, dep_id) =
4122 env_with_two_entry_split([("sha256:aaaa11", 6_000), ("sha256:bbbb22", 4_000)]);
4123 let expected = [
4124 resolved_rev("a", "sha256:aaaa11", 5_000),
4125 resolved_rev("b", "sha256:bbbb22", 5_000),
4126 ];
4127 assert!(
4128 !split_converged(&env, dep_id, &expected),
4129 "same digests but different weights must not converge"
4130 );
4131 }
4132
4133 #[test]
4134 fn split_converged_rejects_placeholder_digest() {
4135 let (env, dep_id) =
4138 env_with_two_entry_split([("sha256:00", 5_000), ("sha256:bbbb22", 5_000)]);
4139 let expected = [
4140 resolved_rev("a", "sha256:00", 5_000),
4141 resolved_rev("b", "sha256:bbbb22", 5_000),
4142 ];
4143 assert!(
4144 !split_converged(&env, dep_id, &expected),
4145 "placeholder live digest must not be treated as converged"
4146 );
4147 }
4148
4149 #[test]
4152 fn deploy_precedes_binding_update_when_both_differ() {
4153 let (dir, store) = seeded_store();
4154 let mut env = make_env("local");
4157 let dep = make_bundle_deployment("local", "quickstart");
4158 let rev = make_revision(
4159 "local",
4160 "quickstart",
4161 &dep.deployment_id,
4162 1,
4163 RevisionLifecycle::Ready,
4164 );
4165 env.traffic_splits.push(make_traffic_split(
4166 "local",
4167 "quickstart",
4168 &dep.deployment_id,
4169 &rev.revision_id,
4170 "seed",
4171 ));
4172 env.bundles.push(dep);
4173 env.revisions.push(rev);
4174 store.save(&env).unwrap();
4175
4176 let manifest = full_manifest(&fixture());
4178 let manifest_path = write_manifest(dir.path(), &manifest);
4179 let plan = run_dry(&store, &manifest_path).expect("dry-run");
4180 let steps = plan.result["steps"].as_array().expect("steps");
4181
4182 let deploy_idx = steps
4184 .iter()
4185 .position(|s| s["kind"] == "deploy-bundle" && s["action"] == "update")
4186 .expect("deploy-bundle update step");
4187 let update_idx = steps
4188 .iter()
4189 .position(|s| s["kind"] == "update-bundle" && s["action"] == "update")
4190 .expect("update-bundle update step");
4191
4192 assert!(
4193 deploy_idx < update_idx,
4194 "deploy-bundle (idx {deploy_idx}) must precede update-bundle (idx {update_idx}): \
4195 {steps:?}"
4196 );
4197 }
4198
4199 fn seeded_store_with_dev_secrets() -> (tempfile::TempDir, LocalFsStore) {
4204 let (dir, store) = seeded_store();
4205 let mut env = load_local(&store);
4206 env.packs.push(make_binding(
4207 CapabilitySlot::Secrets,
4208 "greentic.secrets.dev-store@1.0.0",
4209 ));
4210 store.save(&env).unwrap();
4211 (dir, store)
4212 }
4213
4214 fn run_with_lookup(
4215 store: &LocalFsStore,
4216 manifest_path: &Path,
4217 mode: ApplyMode,
4218 lookup: &dyn Fn(&str) -> Option<String>,
4219 ) -> Result<OpOutcome, OpError> {
4220 run_with_lookup_and_prompter(store, manifest_path, mode, lookup, None)
4221 }
4222
4223 fn run_with_lookup_and_prompter(
4224 store: &LocalFsStore,
4225 manifest_path: &Path,
4226 mode: ApplyMode,
4227 lookup: &dyn Fn(&str) -> Option<String>,
4228 prompter: Option<&SecretPrompter>,
4229 ) -> Result<OpOutcome, OpError> {
4230 let flags = OpFlags {
4231 schema_only: false,
4232 answers: Some(manifest_path.to_path_buf()),
4233 };
4234 apply_with_lookups(
4235 store,
4236 &flags,
4237 ApplyOptions {
4238 mode,
4239 ..ApplyOptions::default()
4240 },
4241 lookup,
4242 prompter,
4243 )
4244 }
4245
4246 use crate::cli::tests_common::dev_store_read;
4247
4248 const SECRET_PATH: &str = "legal/_/messaging-telegram/telegram_bot_token";
4249
4250 fn secrets_manifest(var: &str) -> Value {
4251 json!({
4252 "schema": ENV_MANIFEST_SCHEMA_V1,
4253 "environment": {"id": "local"},
4254 "secrets": [{"path": SECRET_PATH, "from_env": var}]
4255 })
4256 }
4257
4258 #[test]
4259 fn secrets_e2e_put_writes_value_and_redacts() {
4260 let (dir, store) = seeded_store_with_dev_secrets();
4261 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_LEGAL_BOT_TOKEN"));
4262 let lookup =
4263 |name: &str| (name == "APPLY_LEGAL_BOT_TOKEN").then(|| "tok-secret-9000".to_string());
4264
4265 let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup)
4266 .expect("apply succeeds");
4267
4268 let envelope = serde_json::to_string(&outcome).unwrap();
4270 assert!(
4271 !envelope.contains("tok-secret-9000"),
4272 "envelope must not leak the value: {envelope}"
4273 );
4274 assert!(envelope.contains("APPLY_LEGAL_BOT_TOKEN"), "{envelope}");
4275 let actions = step_actions(&outcome.result);
4276 assert!(
4277 actions.contains(&("put-secret".to_string(), "put".to_string())),
4278 "{actions:?}"
4279 );
4280
4281 let store_path = dir
4283 .path()
4284 .join("local")
4285 .join(super::super::secrets::DEV_STORE_RELATIVE);
4286 let bytes = dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"));
4287 assert_eq!(bytes, b"tok-secret-9000".to_vec());
4288
4289 let audit_path = dir.path().join("local/audit/events.jsonl");
4291 let put_ikeys = || -> Vec<String> {
4292 std::fs::read_to_string(&audit_path)
4293 .unwrap()
4294 .lines()
4295 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
4296 .filter(|e| e["noun"] == "secrets" && e["verb"] == "put")
4297 .map(|e| e["idempotency_key"].as_str().expect("ikey").to_string())
4298 .collect()
4299 };
4300 let audit = std::fs::read_to_string(&audit_path).unwrap();
4301 assert!(
4302 !audit.contains("tok-secret-9000"),
4303 "audit log must not leak the value"
4304 );
4305 assert_eq!(put_ikeys().len(), 1, "one put audit event: {audit}");
4306
4307 let second =
4309 run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).expect("re-apply");
4310 assert_eq!(second.result["changed"], 1, "{}", second.result);
4311
4312 let ikeys = put_ikeys();
4316 assert_eq!(ikeys.len(), 2, "two put events after re-apply: {ikeys:?}");
4317 assert_ne!(
4318 ikeys[0], ikeys[1],
4319 "two invocations must mint different keys"
4320 );
4321 }
4322
4323 #[test]
4324 fn missing_or_empty_secret_env_var_fails_apply_before_mutation() {
4325 let (dir, store) = seeded_store_with_dev_secrets();
4326 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_MISSING_VAR"));
4327 for value in [None, Some(String::new())] {
4328 let lookup = |_: &str| value.clone();
4329 let err =
4330 run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4331 match err {
4332 OpError::InvalidArgument(msg) => {
4333 assert!(msg.contains("1 missing input(s)"), "got: {msg}");
4334 assert!(msg.contains("APPLY_MISSING_VAR"), "got: {msg}");
4335 assert!(msg.contains(SECRET_PATH), "got: {msg}");
4336 }
4337 other => panic!("expected InvalidArgument, got {other:?}"),
4338 }
4339 }
4340 assert!(
4342 !dir.path()
4343 .join("local")
4344 .join(super::super::secrets::DEV_STORE_RELATIVE)
4345 .exists()
4346 );
4347 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4348 }
4349
4350 #[test]
4351 fn non_dev_store_backend_fails_at_validation() {
4352 let (dir, store) = seeded_store();
4353 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_VAR"));
4354 let lookup = |_: &str| Some("v".to_string());
4355
4356 let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4359 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4360
4361 let mut env = load_local(&store);
4363 env.packs.push(make_binding(
4364 CapabilitySlot::Secrets,
4365 "greentic.secrets.aws-sm@1.0.0",
4366 ));
4367 store.save(&env).unwrap();
4368 let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4369 assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
4370
4371 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4373 }
4374
4375 #[test]
4376 fn secrets_plan_orders_before_bundles_and_dry_run_writes_nothing() {
4377 let (dir, store) = seeded_store_with_dev_secrets();
4378 let mut manifest = full_manifest(&fixture());
4379 manifest["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_ORDER_TOKEN"}]);
4380 let manifest_path = write_manifest(dir.path(), &manifest);
4381 let lookup = |_: &str| Some("tok-order".to_string());
4382
4383 let plan =
4384 run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup).expect("dry-run");
4385 let steps = plan.result["steps"].as_array().expect("steps");
4386 let pos = |kind: &str| {
4387 steps
4388 .iter()
4389 .position(|s| s["kind"] == kind)
4390 .unwrap_or_else(|| panic!("no `{kind}` step in {steps:?}"))
4391 };
4392 assert!(pos("ensure-environment") < pos("put-secret"));
4393 assert!(
4394 pos("put-secret") < pos("deploy-bundle"),
4395 "secrets must land before bundles so a fresh revision never \
4396 resolves a missing secret"
4397 );
4398
4399 assert!(
4401 !dir.path()
4402 .join("local")
4403 .join(super::super::secrets::DEV_STORE_RELATIVE)
4404 .exists(),
4405 "dry-run must not write the dev store"
4406 );
4407 }
4408
4409 #[test]
4413 fn check_excludes_undiffable_secret_puts_but_counts_real_drift() {
4414 let (dir, store) = seeded_store_with_dev_secrets();
4415 let lookup =
4416 |name: &str| (name == "APPLY_LEGAL_BOT_TOKEN").then(|| "tok-secret-9000".to_string());
4417
4418 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_LEGAL_BOT_TOKEN"));
4422 let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Check, &lookup)
4423 .expect("check passes");
4424 assert_eq!(outcome.result["mode"], "check");
4425 assert_eq!(outcome.result["undiffable"], 1, "{}", outcome.result);
4426 assert_eq!(
4427 outcome.result["changed"], 1,
4428 "the put row stays visible in the report: {}",
4429 outcome.result
4430 );
4431 assert!(
4432 !dir.path()
4433 .join("local")
4434 .join(super::super::secrets::DEV_STORE_RELATIVE)
4435 .exists(),
4436 "--check must not write the dev store"
4437 );
4438
4439 let mut mixed = full_manifest(&fixture());
4442 mixed["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_LEGAL_BOT_TOKEN"}]);
4443 let mixed_path = write_manifest(dir.path(), &mixed);
4444 let err = run_with_lookup(&store, &mixed_path, ApplyMode::Check, &lookup).unwrap_err();
4445 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4446 assert!(
4447 err.to_string().contains("4 pending change(s)"),
4448 "put row must not count toward drift: {err}"
4449 );
4450 }
4451
4452 #[test]
4455 fn tampered_artifact_aborts_before_any_secret_write() {
4456 let (dir, store) = seeded_store_with_dev_secrets();
4462
4463 let tamper_bundle = dir.path().join("tamper.gtbundle");
4465 std::fs::copy(fixture(), &tamper_bundle).unwrap();
4466
4467 let manifest = json!({
4468 "schema": ENV_MANIFEST_SCHEMA_V1,
4469 "environment": {"id": "local"},
4470 "bundles": [{"bundle_id": "quickstart", "bundle_path": &tamper_bundle}],
4471 "secrets": [{"path": SECRET_PATH, "from_env": "APPLY_TAMPER_TOKEN"}]
4472 });
4473 let manifest_path = write_manifest(dir.path(), &manifest);
4474 let lookup = |name: &str| (name == "APPLY_TAMPER_TOKEN").then(|| "tok-tamper".to_string());
4475
4476 let loaded: EnvManifest = super::super::load_answers(&manifest_path).unwrap();
4479 loaded.validate_shape().unwrap();
4480 let manifest_dir = manifest_path.parent().unwrap().to_path_buf();
4481 let ctx = resolve_and_validate(
4482 &store,
4483 loaded,
4484 &manifest_dir,
4485 "test".to_string(),
4486 &lookup,
4487 None,
4488 &BTreeMap::new(),
4489 )
4490 .unwrap();
4491 let steps = diff(&store, &ctx).unwrap();
4492
4493 std::fs::write(&tamper_bundle, b"tampered bytes").unwrap();
4496
4497 let err = execute(&store, &ctx, &steps).expect_err("tampered artifact must abort");
4498 assert!(
4499 matches!(&err, OpError::Conflict(msg) if msg.contains("changed since the plan")),
4500 "expected Conflict about artifact change, got: {err:?}"
4501 );
4502
4503 assert!(
4505 !dir.path()
4506 .join("local")
4507 .join(super::super::secrets::DEV_STORE_RELATIVE)
4508 .exists(),
4509 "tampered artifact must abort before any secret write"
4510 );
4511 }
4512
4513 #[test]
4516 fn missing_inputs_accumulate_across_secrets_and_bundles() {
4517 let (dir, store) = seeded_store_with_dev_secrets();
4518 let absent = dir.path().join("ghost.gtbundle");
4519 let manifest = json!({
4520 "schema": ENV_MANIFEST_SCHEMA_V1,
4521 "environment": {"id": "local"},
4522 "secrets": [
4523 {"path": "legal/_/messaging-telegram/telegram_bot_token",
4524 "from_env": "APPLY_VAR_A"},
4525 {"path": "accounting/_/messaging-telegram/telegram_bot_token",
4526 "from_env": "APPLY_VAR_B"}
4527 ],
4528 "bundles": [{"bundle_id": "ghost", "bundle_path": absent}]
4529 });
4530 let manifest_path = write_manifest(dir.path(), &manifest);
4531 let lookup = |_: &str| None;
4532
4533 let plan = run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup)
4536 .expect("dry-run never fails on missing inputs");
4537 let rows: Vec<(String, String, String)> = plan.result["missing"]
4538 .as_array()
4539 .expect("missing array")
4540 .iter()
4541 .map(|m| {
4542 (
4543 m["kind"].as_str().unwrap().to_string(),
4544 m["key"].as_str().unwrap().to_string(),
4545 m["source"].as_str().unwrap().to_string(),
4546 )
4547 })
4548 .collect();
4549 assert_eq!(rows.len(), 3, "{rows:?}");
4550 assert_eq!(
4551 (rows[0].0.as_str(), rows[0].2.as_str()),
4552 ("secret_value", "env:APPLY_VAR_A")
4553 );
4554 assert_eq!(rows[0].1, "legal/_/messaging-telegram/telegram_bot_token");
4555 assert_eq!(
4556 (rows[1].0.as_str(), rows[1].2.as_str()),
4557 ("secret_value", "env:APPLY_VAR_B")
4558 );
4559 assert_eq!(rows[2].0, "bundle_artifact");
4560 assert_eq!(rows[2].1, "ghost");
4561 assert!(rows[2].2.starts_with("path:"), "{rows:?}");
4562
4563 let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4565 let msg = err.to_string();
4566 assert!(msg.contains("3 missing input(s)"), "{msg}");
4567 assert!(msg.contains("APPLY_VAR_A"), "{msg}");
4568 assert!(msg.contains("APPLY_VAR_B"), "{msg}");
4569 assert!(msg.contains("ghost"), "{msg}");
4570 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4571 }
4572
4573 #[test]
4574 fn check_reports_missing_but_excludes_it_from_the_verdict() {
4575 let (dir, store) = seeded_store_with_dev_secrets();
4576 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_UNSET_CI_VAR"));
4580 let lookup = |_: &str| None;
4581 let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Check, &lookup)
4582 .expect("check passes without the secret value");
4583 assert_eq!(outcome.result["mode"], "check");
4584 assert_eq!(
4585 outcome.result["missing"].as_array().unwrap().len(),
4586 1,
4587 "{}",
4588 outcome.result
4589 );
4590 assert_eq!(outcome.result["undiffable"], 1);
4591
4592 let mut mixed = full_manifest(&fixture());
4594 mixed["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_UNSET_CI_VAR"}]);
4595 let mixed_path = write_manifest(dir.path(), &mixed);
4596 let err = run_with_lookup(&store, &mixed_path, ApplyMode::Check, &lookup).unwrap_err();
4597 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4598 }
4599
4600 #[test]
4601 fn prompter_fills_missing_secret_and_plan_says_prompted() {
4602 let (dir, store) = seeded_store_with_dev_secrets();
4603 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_PROMPT_VAR"));
4604 let lookup = |_: &str| None;
4605 let prompter = |path: &str, from_env: &str| {
4606 assert_eq!(path, SECRET_PATH);
4607 assert_eq!(from_env, "APPLY_PROMPT_VAR");
4608 Some("tok-prompted-1".to_string())
4609 };
4610
4611 let outcome = run_with_lookup_and_prompter(
4612 &store,
4613 &manifest_path,
4614 ApplyMode::Apply,
4615 &lookup,
4616 Some(&prompter),
4617 )
4618 .expect("prompted apply succeeds");
4619
4620 let envelope = serde_json::to_string(&outcome).unwrap();
4623 assert!(!envelope.contains("tok-prompted-1"), "{envelope}");
4624 let steps = outcome.result["steps"].as_array().unwrap();
4625 let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4626 assert_eq!(put["detail"], "prompted (cannot diff until A9)");
4627 assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4628
4629 let store_path = dir
4631 .path()
4632 .join("local")
4633 .join(super::super::secrets::DEV_STORE_RELATIVE);
4634 let bytes = dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"));
4635 assert_eq!(bytes, b"tok-prompted-1".to_vec());
4636
4637 let audit = std::fs::read_to_string(dir.path().join("local/audit/events.jsonl")).unwrap();
4639 assert!(!audit.contains("tok-prompted-1"));
4640 }
4641
4642 #[test]
4643 fn prompter_decline_leaves_input_missing() {
4644 let (dir, store) = seeded_store_with_dev_secrets();
4645 let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_DECLINE_VAR"));
4646 let lookup = |_: &str| None;
4647 let prompter = |_: &str, _: &str| None;
4648 let err = run_with_lookup_and_prompter(
4649 &store,
4650 &manifest_path,
4651 ApplyMode::Apply,
4652 &lookup,
4653 Some(&prompter),
4654 )
4655 .unwrap_err();
4656 assert!(err.to_string().contains("1 missing input(s)"), "{err}");
4657 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4658 }
4659
4660 fn paste_secrets_manifest() -> Value {
4664 json!({
4665 "schema": ENV_MANIFEST_SCHEMA_V1,
4666 "environment": {"id": "local"},
4667 "secrets": [{"path": SECRET_PATH}]
4668 })
4669 }
4670
4671 fn dev_store_value(dir: &Path) -> Vec<u8> {
4672 let store_path = dir
4673 .join("local")
4674 .join(super::super::secrets::DEV_STORE_RELATIVE);
4675 dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"))
4676 }
4677
4678 #[test]
4679 fn paste_secret_prefilled_value_is_put_and_plan_says_pasted() {
4680 let (dir, store) = seeded_store_with_dev_secrets();
4682 let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4683 let flags = OpFlags {
4684 schema_only: false,
4685 answers: Some(manifest_path.clone()),
4686 };
4687 let mut prefilled = BTreeMap::new();
4688 prefilled.insert(
4689 SECRET_PATH.to_string(),
4690 SecretValue::from("tok-pasted-42".to_string()),
4691 );
4692 let outcome = apply_with_lookups(
4693 &store,
4694 &flags,
4695 ApplyOptions {
4696 mode: ApplyMode::Apply,
4697 non_interactive: true,
4698 prefilled_secrets: prefilled,
4699 ..ApplyOptions::default()
4700 },
4701 &|_| None,
4702 None,
4703 )
4704 .expect("prefilled paste apply succeeds");
4705
4706 let envelope = serde_json::to_string(&outcome).unwrap();
4707 assert!(!envelope.contains("tok-pasted-42"), "{envelope}");
4708 let steps = outcome.result["steps"].as_array().unwrap();
4709 let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4710 assert_eq!(put["detail"], "pasted (cannot diff until A9)");
4711 assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4712 assert_eq!(dev_store_value(dir.path()), b"tok-pasted-42".to_vec());
4713 }
4714
4715 #[test]
4716 fn paste_secret_already_in_store_is_a_noop() {
4717 let (dir, store) = seeded_store_with_dev_secrets();
4721 let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4722 let mut prefilled = BTreeMap::new();
4724 prefilled.insert(
4725 SECRET_PATH.to_string(),
4726 SecretValue::from("tok-stored".to_string()),
4727 );
4728 let flags = OpFlags {
4729 schema_only: false,
4730 answers: Some(manifest_path.clone()),
4731 };
4732 apply_with_lookups(
4733 &store,
4734 &flags,
4735 ApplyOptions {
4736 mode: ApplyMode::Apply,
4737 non_interactive: true,
4738 prefilled_secrets: prefilled,
4739 ..ApplyOptions::default()
4740 },
4741 &|_| None,
4742 None,
4743 )
4744 .expect("seed apply");
4745
4746 let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &|_| None)
4748 .expect("re-apply is a no-op, not a missing-input failure");
4749 let steps = outcome.result["steps"].as_array().unwrap();
4750 assert!(
4751 !steps.iter().any(|s| s["kind"] == "put-secret"),
4752 "an already-stored paste secret needs no put step: {steps:?}"
4753 );
4754 assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4755 assert_eq!(dev_store_value(dir.path()), b"tok-stored".to_vec());
4756 }
4757
4758 #[test]
4759 fn paste_secret_missing_when_absent_and_non_interactive() {
4760 let (dir, store) = seeded_store_with_dev_secrets();
4763 let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4764 let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &|_| None).unwrap_err();
4765 let msg = err.to_string();
4766 assert!(msg.contains("1 missing input(s)"), "{msg}");
4767 assert!(msg.contains("paste"), "names the paste source: {msg}");
4768 assert!(msg.contains(SECRET_PATH), "{msg}");
4769 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4770 }
4771
4772 #[test]
4773 fn paste_secret_prompts_with_value_wording() {
4774 let (dir, store) = seeded_store_with_dev_secrets();
4777 let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4778 let prompter = |path: &str, from_env: &str| {
4779 assert_eq!(path, SECRET_PATH);
4780 assert_eq!(from_env, "", "paste secrets prompt with an empty from_env");
4781 Some("tok-typed".to_string())
4782 };
4783 let outcome = run_with_lookup_and_prompter(
4784 &store,
4785 &manifest_path,
4786 ApplyMode::Apply,
4787 &|_| None,
4788 Some(&prompter),
4789 )
4790 .expect("paste prompt apply succeeds");
4791 let steps = outcome.result["steps"].as_array().unwrap();
4792 let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4793 assert_eq!(put["detail"], "pasted (cannot diff until A9)");
4794 assert_eq!(dev_store_value(dir.path()), b"tok-typed".to_vec());
4795 }
4796
4797 #[test]
4798 fn paste_secret_empty_prefilled_is_treated_as_missing() {
4799 let (dir, store) = seeded_store_with_dev_secrets();
4804 let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4805 let flags = OpFlags {
4806 schema_only: false,
4807 answers: Some(manifest_path.clone()),
4808 };
4809 let mut prefilled = BTreeMap::new();
4810 prefilled.insert(SECRET_PATH.to_string(), SecretValue::from(String::new()));
4811 let err = apply_with_lookups(
4812 &store,
4813 &flags,
4814 ApplyOptions {
4815 mode: ApplyMode::Apply,
4816 non_interactive: true,
4817 prefilled_secrets: prefilled,
4818 ..ApplyOptions::default()
4819 },
4820 &|_| None,
4821 None,
4822 )
4823 .unwrap_err();
4824 let msg = err.to_string();
4825 assert!(msg.contains("1 missing input(s)"), "{msg}");
4826 assert!(msg.contains("paste"), "names the paste source: {msg}");
4827 assert!(!dir.path().join("local/audit/events.jsonl").exists());
4829 }
4830
4831 #[test]
4832 fn emit_answers_template_writes_valid_manifest_and_touches_nothing() {
4833 let dir = tempdir().unwrap();
4834 let store = LocalFsStore::new(dir.path());
4835 let out = dir.path().join("template.env.json");
4836 let outcome = emit_answers_template(&out).expect("template emit succeeds");
4837 assert_eq!(outcome.result["mode"], "emit-answers-template");
4838
4839 let written: EnvManifest = serde_json::from_slice(&std::fs::read(&out).unwrap())
4843 .expect("written template parses as EnvManifest");
4844 written
4845 .validate_shape()
4846 .expect("written template is shape-valid");
4847 assert!(!store.exists(&EnvId::try_from("local").unwrap()).unwrap());
4849 }
4850
4851 #[test]
4854 fn welcome_flow_not_in_links_rejected_by_env_validation() {
4855 let (dir, store) = seeded_store();
4859 let manifest = json!({
4860 "schema": ENV_MANIFEST_SCHEMA_V1,
4861 "environment": {"id": "local"},
4862 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}],
4863 "messaging_endpoints": [{
4864 "name": "n",
4865 "provider_type": "messaging.telegram.bot",
4866 "links": [],
4867 "welcome_flow": {"bundle_id": "quickstart", "pack_id": "p", "flow_id": "f"}
4868 }]
4869 });
4870 let manifest_path = write_manifest(dir.path(), &manifest);
4871 let err = run_apply(&store, &manifest_path).unwrap_err();
4872 match err {
4873 OpError::InvalidArgument(msg) => {
4874 assert!(msg.contains("links[]"), "got: {msg}");
4875 }
4876 other => panic!("expected InvalidArgument mentioning links[], got {other:?}"),
4877 }
4878 let env = load_local(&store);
4880 assert!(env.messaging_endpoints.is_empty());
4881 assert!(env.revisions.is_empty());
4882 }
4883
4884 #[test]
4887 fn hash_json_output_is_stable() {
4888 assert_eq!(
4892 hash_json(&json!({"b": 1, "a": [true, "x"]})),
4893 "f15ef113d6e0c876b9ea9e90ebc36ad3f8b350d44634ba2fc407e978fb8cebeb"
4894 );
4895 }
4896
4897 #[test]
4900 fn deploy_rejects_differing_binding_contract_pin() {
4901 let (_dir, store) = seeded_store();
4907 let mut p = super::super::deploy::BundleDeployPayload {
4908 environment_id: "local".to_string(),
4909 bundle_id: "quickstart".to_string(),
4910 customer_id: None,
4911 bundle_path: Some(fixture()),
4912 bundle_source_uri: None,
4913 remote_pins: None,
4914 idempotency_key: None,
4915 config_overrides: None,
4916 route_binding: Some(super::super::bundles::RouteBindingPayload {
4917 hosts: Vec::new(),
4918 path_prefixes: vec!["/v1".to_string()],
4919 tenant_selector: None,
4920 }),
4921 revenue_share: None,
4922 };
4923 super::super::deploy::deploy(&store, &OpFlags::default(), Some(p.clone()))
4924 .expect("first deploy");
4925 p.route_binding = Some(super::super::bundles::RouteBindingPayload {
4927 hosts: Vec::new(),
4928 path_prefixes: vec!["/v2".to_string()],
4929 tenant_selector: None,
4930 });
4931 let err = super::super::deploy::deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
4932 match err {
4933 OpError::Conflict(msg) => {
4934 assert!(
4935 msg.contains("route_binding differs"),
4936 "expected Conflict about differing binding, got: {msg}"
4937 );
4938 }
4939 other => panic!("expected Conflict, got {other:?}"),
4940 }
4941 }
4942
4943 #[test]
4946 fn pack_binding_add_then_idempotent() {
4947 let (dir, store) = seeded_store();
4948 let manifest = json!({
4949 "schema": ENV_MANIFEST_SCHEMA_V1,
4950 "environment": {"id": "local"},
4951 "packs": [{
4952 "slot": "deployer",
4953 "kind": "greentic.deployer.local@1.0.0",
4954 "pack_ref": "builtin:deployer-local"
4955 }]
4956 });
4957 let manifest_path = write_manifest(dir.path(), &manifest);
4958 let outcome = run_apply(&store, &manifest_path).expect("add pack binding");
4959 let actions = step_actions(&outcome.result);
4960 assert!(
4961 actions.contains(&("add-pack-binding".to_string(), "create".to_string())),
4962 "must plan add-pack-binding: {actions:?}"
4963 );
4964
4965 let env = load_local(&store);
4966 let binding = env.pack_for_slot(CapabilitySlot::Deployer);
4967 assert!(binding.is_some(), "deployer slot must be bound");
4968 let b = binding.unwrap();
4969 assert_eq!(b.kind.to_string(), "greentic.deployer.local@1.0.0");
4970 assert_eq!(b.pack_ref.as_str(), "builtin:deployer-local");
4971
4972 let second = run_apply(&store, &manifest_path).expect("re-apply");
4974 assert_eq!(second.result["changed"], 0, "{}", second.result);
4975 }
4976
4977 #[test]
4978 fn pack_binding_update_on_kind_change() {
4979 let (dir, store) = seeded_store();
4980 let v1 = json!({
4982 "schema": ENV_MANIFEST_SCHEMA_V1,
4983 "environment": {"id": "local"},
4984 "packs": [{
4985 "slot": "deployer",
4986 "kind": "greentic.deployer.local@1.0.0",
4987 "pack_ref": "builtin:deployer-local"
4988 }]
4989 });
4990 let v1_path = write_manifest(dir.path(), &v1);
4991 run_apply(&store, &v1_path).expect("first apply");
4992
4993 let v2 = json!({
4995 "schema": ENV_MANIFEST_SCHEMA_V1,
4996 "environment": {"id": "local"},
4997 "packs": [{
4998 "slot": "deployer",
4999 "kind": "greentic.deployer.local@2.0.0",
5000 "pack_ref": "builtin:deployer-local-v2"
5001 }]
5002 });
5003 let v2_path = write_manifest(dir.path(), &v2);
5004 let plan = run_dry(&store, &v2_path).expect("dry-run");
5005 let actions = step_actions(&plan.result);
5006 assert!(
5007 actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5008 "must plan update-pack-binding: {actions:?}"
5009 );
5010
5011 run_apply(&store, &v2_path).expect("apply update");
5012 let env = load_local(&store);
5013 let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5014 assert_eq!(b.kind.to_string(), "greentic.deployer.local@2.0.0");
5015 assert_eq!(b.pack_ref.as_str(), "builtin:deployer-local-v2");
5016 assert!(b.generation > 0, "generation must bump on update");
5017 }
5018
5019 #[test]
5020 fn apply_stages_pack_answers_into_env_store() {
5021 let (dir, store) = seeded_store();
5022 let answers = json!({"runtime_image": "ghcr.io/x/y:dev", "tunnel": "cloudflared"});
5024 std::fs::write(
5025 dir.path().join("deployer-answers.json"),
5026 serde_json::to_vec_pretty(&answers).unwrap(),
5027 )
5028 .unwrap();
5029 let manifest = json!({
5030 "schema": ENV_MANIFEST_SCHEMA_V1,
5031 "environment": {"id": "local"},
5032 "packs": [{
5033 "slot": "deployer",
5034 "kind": "greentic.deployer.local@1.0.0",
5035 "pack_ref": "builtin:deployer-local",
5036 "answers_ref": "deployer-answers.json"
5037 }]
5038 });
5039 let manifest_path = write_manifest(dir.path(), &manifest);
5040 run_apply(&store, &manifest_path).expect("apply with answers_ref");
5041
5042 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
5044 let staged = env_dir.join("env-packs/deployer/answers.json");
5045 assert!(staged.is_file(), "answers must be staged at {staged:?}");
5046 let staged_val: Value = serde_json::from_slice(&std::fs::read(&staged).unwrap()).unwrap();
5047 assert_eq!(staged_val, answers);
5048
5049 let env = load_local(&store);
5052 let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5053 assert_eq!(
5054 b.answers_ref.as_deref(),
5055 Some(Path::new("env-packs/deployer/answers.json"))
5056 );
5057
5058 let second = run_apply(&store, &manifest_path).expect("re-apply");
5060 assert_eq!(second.result["changed"], 0, "{}", second.result);
5061 }
5062
5063 #[test]
5064 fn apply_restages_pack_answers_on_content_change() {
5065 let (dir, store) = seeded_store();
5066 let p = dir.path().join("deployer-answers.json");
5067 std::fs::write(&p, br#"{"runtime_image":"img:v1"}"#).unwrap();
5068 let manifest = json!({
5069 "schema": ENV_MANIFEST_SCHEMA_V1,
5070 "environment": {"id": "local"},
5071 "packs": [{
5072 "slot": "deployer",
5073 "kind": "greentic.deployer.local@1.0.0",
5074 "pack_ref": "builtin:deployer-local",
5075 "answers_ref": "deployer-answers.json"
5076 }]
5077 });
5078 let manifest_path = write_manifest(dir.path(), &manifest);
5079 run_apply(&store, &manifest_path).expect("first apply");
5080
5081 std::fs::write(&p, br#"{"runtime_image":"img:v2"}"#).unwrap();
5084 let plan = run_dry(&store, &manifest_path).expect("dry-run after edit");
5085 let actions = step_actions(&plan.result);
5086 assert!(
5087 actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5088 "content change must plan update-pack-binding: {actions:?}"
5089 );
5090 run_apply(&store, &manifest_path).expect("re-apply");
5091 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
5092 let staged = std::fs::read(env_dir.join("env-packs/deployer/answers.json")).unwrap();
5093 assert_eq!(staged, br#"{"runtime_image":"img:v2"}"#);
5094 }
5095
5096 #[test]
5097 fn apply_with_oci_bundle_and_linking_endpoint() {
5098 let (dir, store) = seeded_store();
5099 let manifest = json!({
5100 "schema": ENV_MANIFEST_SCHEMA_V1,
5101 "environment": {"id": "local"},
5102 "bundles": [{
5103 "bundle_id": "webchat-bot",
5104 "bundle_path": fixture(),
5105 "bundle_source_uri": "oci://ghcr.io/greenticai/demo/webchat-bot:v1",
5106 "route_binding": {
5107 "path_prefixes": ["/"],
5108 "tenant_selector": {"tenant": "tenant-default", "team": "default"}
5109 }
5110 }],
5111 "messaging_endpoints": [{
5112 "name": "webchat-bot",
5113 "provider_type": "messaging.telegram.bot",
5114 "links": ["webchat-bot"]
5115 }]
5116 });
5117 let manifest_path = write_manifest(dir.path(), &manifest);
5118 run_apply(&store, &manifest_path).expect("apply oci bundle + linking endpoint");
5122 let env = load_local(&store);
5123 assert_eq!(
5124 env.revisions[0].bundle_source_uri.as_deref(),
5125 Some("oci://ghcr.io/greenticai/demo/webchat-bot:v1"),
5126 "manifest bundle_source_uri must reach the staged revision"
5127 );
5128 }
5129
5130 #[test]
5131 fn apply_multi_revision_threads_bundle_source_uri() {
5132 let (dir, store) = seeded_store();
5133 let manifest = json!({
5134 "schema": ENV_MANIFEST_SCHEMA_V1,
5135 "environment": {"id": "local"},
5136 "bundles": [{
5137 "bundle_id": "split-bot",
5138 "revisions": [
5139 {"name": "a", "bundle_path": fixture(), "weight_bps": 10000,
5140 "bundle_source_uri": "oci://ghcr.io/greenticai/demo/split:a"}
5141 ]
5142 }]
5143 });
5144 let manifest_path = write_manifest(dir.path(), &manifest);
5145 run_apply(&store, &manifest_path).expect("apply split with oci source");
5146 let env = load_local(&store);
5147 assert_eq!(
5148 env.revisions[0].bundle_source_uri.as_deref(),
5149 Some("oci://ghcr.io/greenticai/demo/split:a"),
5150 "per-revision bundle_source_uri must reach the staged revision"
5151 );
5152 }
5153
5154 #[test]
5155 fn adding_bundle_source_uri_to_converged_deployment_redeploys_and_sets_it() {
5156 let (dir, store) = seeded_store();
5157 let m1 = json!({
5159 "schema": ENV_MANIFEST_SCHEMA_V1,
5160 "environment": {"id": "local"},
5161 "bundles": [{"bundle_id": "b", "bundle_path": fixture()}]
5162 });
5163 let p1 = write_manifest(dir.path(), &m1);
5164 run_apply(&store, &p1).expect("first apply");
5165 assert!(
5166 load_local(&store)
5167 .revisions
5168 .iter()
5169 .all(|r| r.bundle_source_uri.is_none()),
5170 "baseline revision must have no pull ref"
5171 );
5172
5173 let m2 = json!({
5178 "schema": ENV_MANIFEST_SCHEMA_V1,
5179 "environment": {"id": "local"},
5180 "bundles": [{"bundle_id": "b", "bundle_path": fixture(),
5181 "bundle_source_uri": "oci://ex/b:v1"}]
5182 });
5183 let p2 = write_manifest(dir.path(), &m2);
5184 run_apply(&store, &p2).expect("re-apply with oci source");
5185 let env = load_local(&store);
5186 assert!(
5187 env.revisions
5188 .iter()
5189 .any(|r| r.bundle_source_uri.as_deref() == Some("oci://ex/b:v1")),
5190 "adding bundle_source_uri to a same-digest deployment must (re)deploy a \
5191 revision carrying it; saw: {:?}",
5192 env.revisions
5193 .iter()
5194 .map(|r| r.bundle_source_uri.clone())
5195 .collect::<Vec<_>>()
5196 );
5197 }
5198
5199 #[test]
5200 fn answers_ref_drift_on_binding_is_repaired_even_when_staged_file_matches() {
5201 let (dir, store) = seeded_store();
5202 std::fs::write(dir.path().join("deployer-answers.json"), br#"{"x":1}"#).unwrap();
5203 let manifest = json!({
5204 "schema": ENV_MANIFEST_SCHEMA_V1,
5205 "environment": {"id": "local"},
5206 "packs": [{
5207 "slot": "deployer",
5208 "kind": "greentic.deployer.local@1.0.0",
5209 "pack_ref": "builtin:deployer-local",
5210 "answers_ref": "deployer-answers.json"
5211 }]
5212 });
5213 let mp = write_manifest(dir.path(), &manifest);
5214 run_apply(&store, &mp).expect("first apply stages file + canonical ref");
5215
5216 let mut env = load_local(&store);
5220 for b in &mut env.packs {
5221 if b.slot == CapabilitySlot::Deployer {
5222 b.answers_ref = None;
5223 }
5224 }
5225 store.save(&env).expect("save drifted env");
5226
5227 let plan = run_dry(&store, &mp).expect("dry-run on drifted binding");
5230 let actions = step_actions(&plan.result);
5231 assert!(
5232 actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5233 "a wrong/missing answers_ref must replan update-pack-binding: {actions:?}"
5234 );
5235 run_apply(&store, &mp).expect("re-apply repairs the ref");
5236 let env = load_local(&store);
5237 let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5238 assert_eq!(
5239 b.answers_ref.as_deref(),
5240 Some(Path::new("env-packs/deployer/answers.json")),
5241 "re-apply must restore the canonical staged answers_ref"
5242 );
5243 }
5244
5245 #[test]
5246 fn pack_binding_rejects_messaging_slot() {
5247 let dir = tempdir().unwrap();
5248 let manifest = json!({
5249 "schema": ENV_MANIFEST_SCHEMA_V1,
5250 "environment": {"id": "local"},
5251 "packs": [{
5252 "slot": "messaging",
5253 "kind": "greentic.messaging.telegram@1.0.0",
5254 "pack_ref": "builtin:msg"
5255 }]
5256 });
5257 let manifest_path = write_manifest(dir.path(), &manifest);
5258 let loaded: EnvManifest =
5259 serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
5260 let err = loaded.validate_shape().unwrap_err();
5261 assert!(err.to_string().contains("messaging"), "got: {err}");
5262 }
5263
5264 #[test]
5267 fn extension_add_then_idempotent() {
5268 let (dir, store) = seeded_store();
5269 let manifest = json!({
5270 "schema": ENV_MANIFEST_SCHEMA_V1,
5271 "environment": {"id": "local"},
5272 "extensions": [{
5273 "kind": "greentic.ext.memory@1.0.0",
5274 "pack_ref": "builtin:memory-chronicle",
5275 "instance_id": "default"
5276 }]
5277 });
5278 let manifest_path = write_manifest(dir.path(), &manifest);
5279 let outcome = run_apply(&store, &manifest_path).expect("add extension");
5280 let actions = step_actions(&outcome.result);
5281 assert!(
5282 actions.contains(&("add-extension".to_string(), "create".to_string())),
5283 "must plan add-extension: {actions:?}"
5284 );
5285
5286 let env = load_local(&store);
5287 assert_eq!(env.extensions.len(), 1);
5288 let ext = &env.extensions[0];
5289 assert_eq!(ext.kind.to_string(), "greentic.ext.memory@1.0.0");
5290 assert_eq!(ext.pack_ref.as_str(), "builtin:memory-chronicle");
5291 assert_eq!(ext.instance_id.as_deref(), Some("default"));
5292
5293 let second = run_apply(&store, &manifest_path).expect("re-apply");
5295 assert_eq!(second.result["changed"], 0, "{}", second.result);
5296 }
5297
5298 #[test]
5299 fn extension_update_on_pack_ref_change() {
5300 let (dir, store) = seeded_store();
5301 let v1 = json!({
5302 "schema": ENV_MANIFEST_SCHEMA_V1,
5303 "environment": {"id": "local"},
5304 "extensions": [{
5305 "kind": "greentic.ext.memory@1.0.0",
5306 "pack_ref": "builtin:memory-v1"
5307 }]
5308 });
5309 let v1_path = write_manifest(dir.path(), &v1);
5310 run_apply(&store, &v1_path).expect("first apply");
5311
5312 let v2 = json!({
5313 "schema": ENV_MANIFEST_SCHEMA_V1,
5314 "environment": {"id": "local"},
5315 "extensions": [{
5316 "kind": "greentic.ext.memory@1.0.0",
5317 "pack_ref": "builtin:memory-v2"
5318 }]
5319 });
5320 let v2_path = write_manifest(dir.path(), &v2);
5321 let plan = run_dry(&store, &v2_path).expect("dry-run");
5322 let actions = step_actions(&plan.result);
5323 assert!(
5324 actions.contains(&("update-extension".to_string(), "update".to_string())),
5325 "must plan update-extension: {actions:?}"
5326 );
5327
5328 run_apply(&store, &v2_path).expect("apply update");
5329 let env = load_local(&store);
5330 assert_eq!(env.extensions[0].pack_ref.as_str(), "builtin:memory-v2");
5331 assert!(
5332 env.extensions[0].generation > 0,
5333 "generation must bump on update"
5334 );
5335 }
5336
5337 #[test]
5338 fn extension_bad_instance_id_rejected_by_shape() {
5339 let manifest = json!({
5340 "schema": ENV_MANIFEST_SCHEMA_V1,
5341 "environment": {"id": "local"},
5342 "extensions": [{
5343 "kind": "greentic.ext.memory@1.0.0",
5344 "pack_ref": "builtin:memory",
5345 "instance_id": "BAD_ID!"
5346 }]
5347 });
5348 let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5349 let err = loaded.validate_shape().unwrap_err();
5350 assert!(err.to_string().contains("instance_id"), "got: {err}");
5351 }
5352
5353 #[test]
5354 fn extension_duplicate_key_rejected_by_shape() {
5355 let manifest = json!({
5356 "schema": ENV_MANIFEST_SCHEMA_V1,
5357 "environment": {"id": "local"},
5358 "extensions": [
5359 {"kind": "greentic.ext.memory@1.0.0", "pack_ref": "a", "instance_id": "x"},
5360 {"kind": "greentic.ext.memory@2.0.0", "pack_ref": "b", "instance_id": "x"},
5361 ]
5362 });
5363 let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5364 let err = loaded.validate_shape().unwrap_err();
5365 assert!(err.to_string().contains("duplicate"), "got: {err}");
5366 }
5367
5368 #[test]
5371 fn host_config_reconcile_then_idempotent() {
5372 let (dir, store) = seeded_store();
5373 let manifest = json!({
5374 "schema": ENV_MANIFEST_SCHEMA_V1,
5375 "environment": {
5376 "id": "local",
5377 "name": "Local Dev",
5378 "region": "eu-west-1",
5379 "tenant_org_id": "org-99",
5380 "listen_addr": "0.0.0.0:9090"
5381 }
5382 });
5383 let manifest_path = write_manifest(dir.path(), &manifest);
5384 let outcome = run_apply(&store, &manifest_path).expect("host-config apply");
5385 let actions = step_actions(&outcome.result);
5386 assert!(
5387 actions.contains(&("update-host-config".to_string(), "update".to_string()))
5388 || actions.contains(&("update-host-config".to_string(), "create".to_string())),
5389 "must plan update-host-config: {actions:?}"
5390 );
5391
5392 let env = load_local(&store);
5393 assert_eq!(env.name, "Local Dev");
5394 assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
5395 assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("org-99"));
5396 assert_eq!(
5397 env.host_config.listen_addr,
5398 Some("0.0.0.0:9090".parse().unwrap())
5399 );
5400
5401 let second = run_apply(&store, &manifest_path).expect("re-apply");
5403 assert_eq!(second.result["changed"], 0, "{}", second.result);
5404 }
5405
5406 #[test]
5407 fn gui_enabled_reconcile_then_idempotent() {
5408 let (dir, store) = seeded_store();
5409 let manifest = json!({
5412 "schema": ENV_MANIFEST_SCHEMA_V1,
5413 "environment": {
5414 "id": "local",
5415 "gui_enabled": false
5416 }
5417 });
5418 let manifest_path = write_manifest(dir.path(), &manifest);
5419 let outcome = run_apply(&store, &manifest_path).expect("gui apply");
5420 let actions = step_actions(&outcome.result);
5421 assert!(
5422 actions.contains(&("update-host-config".to_string(), "update".to_string())),
5423 "must plan update-host-config for gui_enabled: {actions:?}"
5424 );
5425
5426 let env = load_local(&store);
5427 assert_eq!(env.host_config.gui_enabled, Some(false));
5428 assert!(
5429 !env.host_config.resolved_gui_enabled(),
5430 "explicit false overrides the local default-on"
5431 );
5432
5433 let second = run_apply(&store, &manifest_path).expect("re-apply");
5435 assert_eq!(second.result["changed"], 0, "{}", second.result);
5436 }
5437
5438 #[test]
5439 fn host_config_bad_listen_addr_rejected_by_shape() {
5440 let manifest = json!({
5441 "schema": ENV_MANIFEST_SCHEMA_V1,
5442 "environment": {
5443 "id": "local",
5444 "listen_addr": "not-an-addr"
5445 }
5446 });
5447 let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5448 let err = loaded.validate_shape().unwrap_err();
5449 assert!(err.to_string().contains("listen_addr"), "got: {err}");
5450 }
5451
5452 #[test]
5455 fn packs_before_secrets_extensions_before_endpoints() {
5456 let (dir, store) = seeded_store_with_dev_secrets();
5457 let manifest = json!({
5458 "schema": ENV_MANIFEST_SCHEMA_V1,
5459 "environment": {"id": "local"},
5460 "packs": [{
5461 "slot": "deployer",
5462 "kind": "greentic.deployer.local@1.0.0",
5463 "pack_ref": "builtin:deployer-local"
5464 }],
5465 "secrets": [{"path": SECRET_PATH, "from_env": "APPLY_ORDER_VAR"}],
5466 "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}],
5467 "extensions": [{
5468 "kind": "greentic.ext.memory@1.0.0",
5469 "pack_ref": "builtin:memory"
5470 }],
5471 "messaging_endpoints": [{
5472 "name": "bot",
5473 "provider_type": "messaging.telegram.bot",
5474 "links": ["quickstart"]
5475 }]
5476 });
5477 let manifest_path = write_manifest(dir.path(), &manifest);
5478 let lookup = |_: &str| Some("tok".to_string());
5479 let plan =
5480 run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup).expect("dry-run");
5481 let steps = plan.result["steps"].as_array().expect("steps");
5482 let pos = |kind: &str| {
5483 steps
5484 .iter()
5485 .position(|s| s["kind"] == kind)
5486 .unwrap_or_else(|| panic!("no `{kind}` step in {steps:?}"))
5487 };
5488 assert!(pos("ensure-environment") < pos("add-pack-binding"));
5489 assert!(pos("add-pack-binding") < pos("put-secret"));
5490 assert!(pos("put-secret") < pos("deploy-bundle"));
5491 assert!(pos("deploy-bundle") < pos("add-extension"));
5492 assert!(pos("add-extension") < pos("add-endpoint"));
5493 }
5494
5495 #[test]
5505 fn fresh_env_pack_on_default_slot_emits_update_not_add() {
5506 let dir = tempdir().unwrap();
5507 let store = LocalFsStore::new(dir.path());
5508 let manifest = json!({
5514 "schema": ENV_MANIFEST_SCHEMA_V1,
5515 "environment": {"id": "local"},
5516 "packs": [
5517 {
5518 "slot": "deployer",
5519 "kind": crate::defaults::LOCAL_DEPLOYER_PACK,
5520 "pack_ref": crate::defaults::LOCAL_DEPLOYER_PACK
5521 },
5522 {
5523 "slot": "telemetry",
5524 "kind": "greentic.telemetry.otlp@2.0.0",
5525 "pack_ref": "builtin:otlp"
5526 },
5527 {
5528 "slot": "revocation",
5529 "kind": "greentic.revocation.crl@1.0.0",
5530 "pack_ref": "builtin:crl"
5531 }
5532 ]
5533 });
5534 let manifest_path = write_manifest(dir.path(), &manifest);
5535 let plan = run_dry(&store, &manifest_path).expect("dry-run on fresh env");
5536 let steps = plan.result["steps"].as_array().expect("steps array");
5537
5538 let find_step = |key: &str| {
5540 steps
5541 .iter()
5542 .find(|s| s["key"] == key)
5543 .unwrap_or_else(|| panic!("no step with key `{key}` in {steps:?}"))
5544 };
5545
5546 let deployer = find_step("deployer");
5549 assert_eq!(
5550 deployer["action"].as_str().unwrap(),
5551 "no-op",
5552 "deployer must be no-op (matches default): {deployer}"
5553 );
5554
5555 let telemetry = find_step("telemetry");
5557 assert_eq!(
5558 telemetry["kind"].as_str().unwrap(),
5559 "update-pack-binding",
5560 "telemetry must be update (differs from default): {telemetry}"
5561 );
5562 assert_eq!(
5563 telemetry["action"].as_str().unwrap(),
5564 "update",
5565 "telemetry action must be update: {telemetry}"
5566 );
5567
5568 let revocation = find_step("revocation");
5570 assert_eq!(
5571 revocation["kind"].as_str().unwrap(),
5572 "add-pack-binding",
5573 "revocation must be add (not in defaults): {revocation}"
5574 );
5575 assert_eq!(
5576 revocation["action"].as_str().unwrap(),
5577 "create",
5578 "revocation action must be create: {revocation}"
5579 );
5580 }
5581}