1use crate::codes::{CODES, DoctorCodeDef};
30use crate::doctor::DoctorFailure;
31use crate::doctor::DoctorReport;
32use crate::lock::{Lock, read_lock, sha256_hex};
33use cratefield_core::VentureEnv;
34use cratefield_manifest::{Catalog, ModuleSet, VentureManifest, builtin};
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38use std::process::ExitCode;
39
40pub const SCHEMA: u32 = 1;
43
44pub const RECORD_FILE: &str = ".harness-deploy.json";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct DeployRecord {
53 pub schema: u32,
55 pub digest: String,
57 pub plan: PlanContent,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PlanState {
70 pub venture: String,
72 pub host: String,
74 pub public_url: Option<String>,
76 pub cors_origins: Vec<String>,
78 pub env: String,
81 pub modules: Vec<String>,
84 pub selected: Vec<SelectedModule>,
88 pub config: BTreeMap<String, String>,
90 pub seed_sql: Option<String>,
92 pub migrations: MigrationState,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PlanContent {
103 #[serde(flatten)]
105 pub state: PlanState,
106 pub modules_added: Vec<String>,
109 pub modules_removed: Vec<String>,
114 pub capabilities: Vec<Capability>,
118 pub config_added: Vec<String>,
120 pub config_removed: Vec<String>,
122 pub config_changed: Vec<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
129pub struct SelectedModule {
130 pub slug: String,
132 pub config: BTreeMap<String, String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
138pub struct Capability {
139 pub slug: String,
141 pub tier: String,
143 pub requires: Vec<String>,
146 pub via: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
156pub struct MigrationState {
157 pub collected: BTreeMap<String, usize>,
159 pub not_collected: Vec<String>,
161 pub orphaned: Vec<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168pub struct Baseline {
169 pub source: &'static str,
171 pub modules: Vec<String>,
173}
174
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct Consent {
183 pub production: bool,
186 pub removal: bool,
189}
190
191#[derive(Debug, Clone)]
193pub struct PlanOutcome {
194 pub content: PlanContent,
196 pub digest: String,
199 pub baseline: Baseline,
201}
202
203#[derive(Debug, Clone)]
205pub struct DeployOutcome {
206 pub content: PlanContent,
208 pub digest: String,
210 pub changed: bool,
213 pub record_path: PathBuf,
215}
216
217#[derive(Debug, Clone)]
219pub struct AddOutcome {
220 pub changed: bool,
223 pub module: String,
225 pub manifest: PathBuf,
227}
228
229#[derive(Debug)]
233pub struct VerifyOutcome {
234 pub report: DoctorReport,
236 pub digest: Option<String>,
238}
239
240fn failure(code: &'static DoctorCodeDef, message: impl Into<String>) -> DoctorFailure {
242 DoctorFailure {
243 code,
244 message: message.into(),
245 }
246}
247
248fn joined(failures: &[DoctorFailure]) -> String {
250 failures
251 .iter()
252 .map(|f| f.message.as_str())
253 .collect::<Vec<_>>()
254 .join("\n - ")
255}
256
257fn resolved_env(manifest: &VentureManifest) -> VentureEnv {
264 manifest
265 .config
266 .get("ENV")
267 .and_then(|value| VentureEnv::parse(value))
268 .unwrap_or_default()
269}
270
271fn record_path(manifest_path: &Path) -> PathBuf {
273 let mut path = manifest_path.parent().map_or_else(PathBuf::new, |parent| {
274 if parent.as_os_str().is_empty() {
275 PathBuf::new()
276 } else {
277 parent.to_path_buf()
278 }
279 });
280 path.push(RECORD_FILE);
281 path
282}
283
284fn read_record(manifest_path: &Path) -> Result<Option<DeployRecord>, String> {
287 let path = record_path(manifest_path);
288 let raw = match std::fs::read_to_string(&path) {
289 Ok(raw) => raw,
290 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
291 Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
292 };
293 let record: DeployRecord = serde_json::from_str(&raw)
294 .map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
295 if record.schema != SCHEMA {
296 return Err(format!(
297 "{}: unsupported schema {} (this fz speaks {SCHEMA})",
298 path.display(),
299 record.schema
300 ));
301 }
302 Ok(Some(record))
303}
304
305fn write_record(manifest_path: &Path, record: &DeployRecord) -> Result<PathBuf, String> {
310 let path = record_path(manifest_path);
311 let body = serde_json::to_string_pretty(record)
312 .map_err(|err| format!("cannot serialize the deploy record: {err}"))?;
313 let mut tmp = path.clone();
314 tmp.set_extension("tmp");
315 std::fs::write(&tmp, body + "\n")
316 .map_err(|err| format!("cannot write {}: {err}", tmp.display()))?;
317 std::fs::rename(&tmp, &path).map_err(|err| {
318 format!(
319 "cannot finalize {} (partial file left at {}): {err}",
320 path.display(),
321 tmp.display()
322 )
323 })?;
324 Ok(path)
325}
326
327fn write_manifest(path: &Path, manifest: &VentureManifest) -> Result<(), String> {
331 let body = match path.extension().and_then(|ext| ext.to_str()) {
332 Some("toml") => toml::to_string_pretty(manifest)
333 .map_err(|err| format!("cannot serialize TOML manifest: {err}"))?,
334 _ => serde_json::to_string_pretty(manifest)
335 .map_err(|err| format!("cannot serialize manifest: {err}"))?,
336 };
337 std::fs::write(path, body + "\n")
338 .map_err(|err| format!("cannot write {}: {err}", path.display()))
339}
340
341struct Inputs {
344 manifest: VentureManifest,
345 set: ModuleSet,
346 lock: Lock,
347 record: Option<DeployRecord>,
348 catalog: Catalog,
349}
350
351fn load(manifest_path: &Path, migrations_dir: &Path) -> Result<Inputs, Vec<DoctorFailure>> {
355 let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
356 vec![failure(
357 &CODES.manifest_unreadable,
358 format!("cannot read {}: {err}", manifest_path.display()),
359 )]
360 })?;
361 let manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
362 vec![failure(
363 &CODES.manifest_unreadable,
364 format!("{}: {err}", manifest_path.display()),
365 )]
366 })?;
367 let catalog = builtin();
368 let set = manifest
369 .resolve(&catalog)
370 .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
371
372 let mut failures: Vec<DoctorFailure> = Vec::new();
373 let lock = match read_lock(migrations_dir) {
374 Ok(lock) => lock,
375 Err(err) => {
376 failures.push(failure(&CODES.lockfile_unreadable, err));
377 Lock::default()
378 }
379 };
380 let record = match read_record(manifest_path) {
381 Ok(record) => record,
382 Err(err) => {
383 failures.push(failure(&CODES.deploy_record_unreadable, err));
384 None
385 }
386 };
387 if failures.is_empty() {
388 Ok(Inputs {
389 manifest,
390 set,
391 lock,
392 record,
393 catalog,
394 })
395 } else {
396 Err(failures)
397 }
398}
399
400fn lockfile_modules(lock: &Lock) -> Vec<String> {
404 let mut modules: Vec<String> = lock
405 .keys()
406 .filter_map(|key| key.split('/').next())
407 .filter(|module| !module.is_empty())
408 .map(str::to_owned)
409 .collect();
410 modules.sort_unstable();
411 modules.dedup();
412 modules
413}
414
415fn baseline_of(lock: &Lock, record: Option<&DeployRecord>) -> Baseline {
418 if let Some(record) = record {
419 let mut modules = record.plan.state.modules.clone();
420 modules.sort_unstable();
421 return Baseline {
422 source: "deploy-record",
423 modules,
424 };
425 }
426 let modules = lockfile_modules(lock);
427 if modules.is_empty() {
428 Baseline {
429 source: "nothing",
430 modules: Vec::new(),
431 }
432 } else {
433 Baseline {
434 source: "lockfile",
435 modules,
436 }
437 }
438}
439
440fn capabilities_of(added: &[String], selected: &[&str], catalog: &Catalog) -> Vec<Capability> {
444 added
445 .iter()
446 .filter_map(|slug| {
447 let module = catalog.modules.iter().find(|m| &m.slug == slug)?;
448 let tier = match module.tier {
449 cratefield_manifest::Tier::Core => "core",
450 cratefield_manifest::Tier::Optional => "optional",
451 };
452 let mut requires = module.depends_on.clone();
453 requires.sort_unstable();
454 Some(Capability {
455 slug: slug.clone(),
456 tier: tier.to_owned(),
457 requires,
458 via: if selected.contains(&slug.as_str()) {
459 "selected".to_owned()
460 } else {
461 "dependency".to_owned()
462 },
463 })
464 })
465 .collect()
466}
467
468fn config_deltas(
470 manifest: &VentureManifest,
471 recorded: &BTreeMap<String, String>,
472) -> (Vec<String>, Vec<String>, Vec<String>) {
473 let config_added: Vec<String> = manifest
474 .config
475 .keys()
476 .filter(|key| !recorded.contains_key(*key))
477 .cloned()
478 .collect();
479 let config_removed: Vec<String> = recorded
480 .keys()
481 .filter(|key| !manifest.config.contains_key(*key))
482 .cloned()
483 .collect();
484 let config_changed: Vec<String> = manifest
485 .config
486 .iter()
487 .filter(|(key, value)| {
488 recorded
489 .get(*key)
490 .is_some_and(|recorded| recorded != *value)
491 })
492 .map(|(key, _)| key.clone())
493 .collect();
494 (config_added, config_removed, config_changed)
495}
496
497fn migration_state(desired: &[String], lock: &Lock) -> MigrationState {
499 let mut state = MigrationState::default();
500 for key in lock.keys() {
501 let Some(module) = key.split('/').next() else {
502 continue;
503 };
504 if desired.iter().any(|slug| slug == module) {
505 *state.collected.entry(module.to_owned()).or_insert(0) += 1;
506 } else {
507 state.orphaned.push(key.clone());
508 }
509 }
510 state.not_collected = desired
511 .iter()
512 .filter(|slug| !state.collected.contains_key(*slug))
513 .cloned()
514 .collect();
515 state.orphaned.sort_unstable();
516 state
517}
518
519#[must_use]
522fn compute_plan(
523 manifest: &VentureManifest,
524 set: &ModuleSet,
525 lock: &Lock,
526 record: Option<&DeployRecord>,
527 catalog: &Catalog,
528) -> (PlanContent, Baseline) {
529 let mut desired = set.slugs().to_vec();
530 desired.sort_unstable();
531 let baseline = baseline_of(lock, record);
532 let modules_added: Vec<String> = desired
533 .iter()
534 .filter(|slug| !baseline.modules.contains(slug))
535 .cloned()
536 .collect();
537 let modules_removed: Vec<String> = baseline
538 .modules
539 .iter()
540 .filter(|slug| !desired.contains(slug))
541 .cloned()
542 .collect();
543
544 let selected_slugs = manifest.module_slugs();
545 let capabilities = capabilities_of(&modules_added, &selected_slugs, catalog);
546 let recorded_config: &BTreeMap<String, String> = match record {
547 Some(record) => &record.plan.state.config,
548 None => &BTreeMap::new(),
549 };
550 let (config_added, config_removed, config_changed) = config_deltas(manifest, recorded_config);
551
552 let mut selected: Vec<SelectedModule> = manifest
553 .modules
554 .iter()
555 .map(|module| SelectedModule {
556 slug: module.slug().to_owned(),
557 config: module.config(),
558 })
559 .collect();
560 selected.sort_unstable();
561
562 let migrations = migration_state(&desired, lock);
563 let mut cors_origins = manifest.cors_origins.clone();
564 cors_origins.sort_unstable();
565
566 let state = PlanState {
567 venture: manifest.name.clone(),
568 host: manifest.host.clone(),
569 public_url: manifest.public_url.clone(),
570 cors_origins,
571 env: resolved_env(manifest).as_str().to_owned(),
572 modules: desired,
573 selected,
574 config: manifest.config.clone(),
575 seed_sql: manifest.seed_sql.clone(),
576 migrations,
577 };
578 let content = PlanContent {
579 state,
580 modules_added,
581 modules_removed,
582 capabilities,
583 config_added,
584 config_removed,
585 config_changed,
586 };
587 (content, baseline)
588}
589
590fn plan_digest(content: &PlanContent) -> Result<String, Vec<DoctorFailure>> {
600 serde_json::to_vec(&content.state)
601 .map(|bytes| sha256_hex(&bytes))
602 .map_err(|err| {
603 vec![failure(
604 &CODES.manifest_invalid,
605 format!("the plan content does not serialize: {err}"),
606 )]
607 })
608}
609
610pub fn plan(
619 manifest_path: &Path,
620 migrations_dir: &Path,
621) -> Result<PlanOutcome, Vec<DoctorFailure>> {
622 let inputs = load(manifest_path, migrations_dir)?;
623 let (content, baseline) = compute_plan(
624 &inputs.manifest,
625 &inputs.set,
626 &inputs.lock,
627 inputs.record.as_ref(),
628 &inputs.catalog,
629 );
630 let digest = plan_digest(&content)?;
631 Ok(PlanOutcome {
632 content,
633 digest,
634 baseline,
635 })
636}
637
638fn moved_since(current: &PlanState, recorded: &PlanState) -> Vec<String> {
643 let mut moved: Vec<String> = Vec::new();
644 if let Some(what) = diff_modules(¤t.modules, &recorded.modules) {
645 moved.push(format!("modules: {what}"));
646 }
647 if let Some(what) = diff_config(¤t.config, &recorded.config) {
648 moved.push(format!("config: {what}"));
649 }
650 if current.env != recorded.env {
651 moved.push(format!("environment: {} -> {}", recorded.env, current.env));
652 }
653 if current.host != recorded.host {
654 moved.push(format!("host: {} -> {}", recorded.host, current.host));
655 }
656 if current.public_url != recorded.public_url {
657 moved.push("public URL changed".to_owned());
658 }
659 if current.cors_origins != recorded.cors_origins {
660 moved.push("CORS origins changed".to_owned());
661 }
662 if current.seed_sql != recorded.seed_sql {
663 moved.push("seed SQL changed".to_owned());
664 }
665 if current.selected != recorded.selected {
666 moved.push("the module selection or its per-module config changed".to_owned());
667 }
668 moved
669}
670
671fn diff_modules(current: &[String], recorded: &[String]) -> Option<String> {
673 let added: Vec<&String> = current
674 .iter()
675 .filter(|slug| !recorded.contains(slug))
676 .collect();
677 let removed: Vec<&String> = recorded
678 .iter()
679 .filter(|slug| !current.contains(slug))
680 .collect();
681 if added.is_empty() && removed.is_empty() {
682 return None;
683 }
684 let mut parts: Vec<String> = Vec::new();
685 if !added.is_empty() {
686 parts.push(format!(
687 "added {}",
688 added
689 .iter()
690 .map(|slug| slug.as_str())
691 .collect::<Vec<_>>()
692 .join(", ")
693 ));
694 }
695 if !removed.is_empty() {
696 parts.push(format!(
697 "removed {}",
698 removed
699 .iter()
700 .map(|slug| slug.as_str())
701 .collect::<Vec<_>>()
702 .join(", ")
703 ));
704 }
705 Some(parts.join("; "))
706}
707
708fn diff_config(
710 current: &BTreeMap<String, String>,
711 recorded: &BTreeMap<String, String>,
712) -> Option<String> {
713 let added: Vec<&String> = current
714 .keys()
715 .filter(|key| !recorded.contains_key(*key))
716 .collect();
717 let removed: Vec<&String> = recorded
718 .keys()
719 .filter(|key| !current.contains_key(*key))
720 .collect();
721 let changed: Vec<&String> = current
722 .iter()
723 .filter(|(key, value)| recorded.get(*key).is_some_and(|r| r != *value))
724 .map(|(key, _)| key)
725 .collect();
726 if added.is_empty() && removed.is_empty() && changed.is_empty() {
727 return None;
728 }
729 let mut parts: Vec<String> = Vec::new();
730 let list = |keys: &[&String]| {
731 keys.iter()
732 .map(|key| key.as_str())
733 .collect::<Vec<_>>()
734 .join(", ")
735 };
736 if !added.is_empty() {
737 parts.push(format!("added {}", list(&added)));
738 }
739 if !removed.is_empty() {
740 parts.push(format!("removed {}", list(&removed)));
741 }
742 if !changed.is_empty() {
743 parts.push(format!("changed {}", list(&changed)));
744 }
745 Some(parts.join("; "))
746}
747
748pub fn deploy(
771 approved: Option<&str>,
772 manifest_path: &Path,
773 migrations_dir: &Path,
774 consent: Consent,
775) -> Result<DeployOutcome, Vec<DoctorFailure>> {
776 let Some(approved) = approved else {
777 return Err(vec![failure(
778 &CODES.deploy_plan_required,
779 "deploy requires an approved plan — run `fz plan --json` and pass its `digest` \
780 as --plan; there is no default",
781 )]);
782 };
783 let inputs = load(manifest_path, migrations_dir)?;
784 let (planned, _) = compute_plan(
785 &inputs.manifest,
786 &inputs.set,
787 &inputs.lock,
788 inputs.record.as_ref(),
789 &inputs.catalog,
790 );
791 let digest = plan_digest(&planned)?;
792
793 let mut failures: Vec<DoctorFailure> = Vec::new();
794 if approved != digest {
795 failures.push(failure(
796 &CODES.stale_plan,
797 stale_plan_message(&planned, inputs.record.as_ref(), approved, &digest),
798 ));
799 }
800 if resolved_env(&inputs.manifest) == VentureEnv::Production && !consent.production {
801 failures.push(failure(
802 &CODES.production_deploy_unauthorized,
803 "this venture resolves to production (manifest config ENV=production) — deploying \
804 needs a second, explicit consent: pass --i-am-deploying-to-production alongside \
805 the approved digest",
806 ));
807 }
808 for (key, entry) in &inputs.lock {
818 if let Err(err) = crate::collect::verify_locked(migrations_dir, key, entry) {
819 let code = match &err {
820 crate::collect::LockedMigrationError::Missing { .. } => {
821 &CODES.locked_migration_missing
822 }
823 crate::collect::LockedMigrationError::Edited { .. } => {
824 &CODES.locked_migration_edited
825 }
826 };
827 failures.push(failure(code, err.to_string()));
828 }
829 }
830
831 if !planned.modules_removed.is_empty() && !consent.removal {
832 let orphans = &planned.state.migrations.orphaned;
840 let leftovers = if orphans.is_empty() {
841 String::new()
842 } else {
843 format!(
844 " The migrations {} stay applied and the tables they created keep their rows; \
845 dropping them is a separate, manual step (docs/ROLLBACK.md).",
846 orphans.join(", ")
847 )
848 };
849 failures.push(failure(
850 &CODES.destructive_change_unauthorized,
851 format!(
852 "this plan removes {} from the served composition: those modules stop being \
853 served, and no data is deleted.{leftovers} Pass --i-am-removing-modules to \
854 accept that",
855 planned.modules_removed.join(", ")
856 ),
857 ));
858 }
859 if !failures.is_empty() {
860 return Err(failures);
861 }
862
863 let record_path = record_path(manifest_path);
864 if inputs
865 .record
866 .as_ref()
867 .is_some_and(|record| record.digest == digest)
868 {
869 return Ok(DeployOutcome {
870 content: planned,
871 digest,
872 changed: false,
873 record_path,
874 });
875 }
876 let record = DeployRecord {
877 schema: SCHEMA,
878 digest: digest.clone(),
879 plan: planned.clone(),
880 };
881 let record_path = write_record(manifest_path, &record)
882 .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
883 Ok(DeployOutcome {
884 content: planned,
885 digest,
886 changed: true,
887 record_path,
888 })
889}
890
891fn stale_plan_message(
896 planned: &PlanContent,
897 record: Option<&DeployRecord>,
898 approved: &str,
899 digest: &str,
900) -> String {
901 let mut message = format!(
902 "the approved digest {approved} does not match a plan recomputed from the current \
903 inputs (the current digest is {digest})"
904 );
905 let Some(record) = record else {
906 let mut what: Vec<String> = Vec::new();
907 if !planned.modules_added.is_empty() {
908 what.push(format!("add {}", planned.modules_added.join(", ")));
909 }
910 if !planned.modules_removed.is_empty() {
911 what.push(format!("remove {}", planned.modules_removed.join(", ")));
912 }
913 if what.is_empty() {
914 message
915 .push_str("; no deployment is recorded yet, so there is nothing to diff against");
916 } else {
917 message.push_str("; no deployment is recorded yet and the current plan would ");
918 message.push_str(&what.join(" and "));
919 }
920 return message;
921 };
922 let moved = moved_since(&planned.state, &record.plan.state);
923 if moved.is_empty() {
924 message.push_str(
925 "; the recorded deployment matches every plan field, so the approved digest was \
926 computed by a different fz or an older plan format",
927 );
928 } else {
929 message.push_str("; since the recorded deployment: ");
930 message.push_str(&moved.join("; "));
931 }
932 message
933}
934
935pub fn add(module: &str, manifest_path: &Path) -> Result<AddOutcome, Vec<DoctorFailure>> {
945 let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
946 vec![failure(
947 &CODES.manifest_unreadable,
948 format!("cannot read {}: {err}", manifest_path.display()),
949 )]
950 })?;
951 let mut manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
952 vec![failure(
953 &CODES.manifest_unreadable,
954 format!("{}: {err}", manifest_path.display()),
955 )]
956 })?;
957 let catalog = builtin();
958 manifest
959 .resolve(&catalog)
960 .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
961
962 if manifest.module_slugs().contains(&module) {
963 return Ok(AddOutcome {
964 changed: false,
965 module: module.to_owned(),
966 manifest: manifest_path.to_path_buf(),
967 });
968 }
969
970 let known: Vec<&str> = catalog.modules.iter().map(|m| m.slug.as_str()).collect();
971 if !known.contains(&module) {
972 return Err(vec![failure(
973 &CODES.module_unknown,
974 format!(
975 "`{module}` is not a module in the catalog; available: {}",
976 known.join(", ")
977 ),
978 )]);
979 }
980 manifest
981 .modules
982 .push(cratefield_manifest::ModuleRef::Slug(module.to_owned()));
983 manifest
984 .resolve(&catalog)
985 .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
986 write_manifest(manifest_path, &manifest)
987 .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
988 Ok(AddOutcome {
989 changed: true,
990 module: module.to_owned(),
991 manifest: manifest_path.to_path_buf(),
992 })
993}
994
995pub fn init(
1004 name: &str,
1005 host: &str,
1006 manifest_path: &Path,
1007 force: bool,
1008) -> Result<PathBuf, Vec<DoctorFailure>> {
1009 if manifest_path.exists() && !force {
1010 return Err(vec![failure(
1011 &CODES.manifest_exists,
1012 format!(
1013 "{} already exists — init refuses to overwrite a manifest; pass --force to \
1014 replace it",
1015 manifest_path.display()
1016 ),
1017 )]);
1018 }
1019 let manifest = VentureManifest {
1020 name: name.to_owned(),
1021 host: host.to_owned(),
1022 public_url: None,
1023 cors_origins: Vec::new(),
1024 modules: Vec::new(),
1025 config: BTreeMap::new(),
1026 seed_sql: None,
1027 };
1028 manifest
1029 .validate()
1030 .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
1031 write_manifest(manifest_path, &manifest)
1032 .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
1033 Ok(manifest_path.to_path_buf())
1034}
1035
1036#[must_use]
1046pub fn verify(manifest_path: &Path, migrations_dir: &Path) -> VerifyOutcome {
1047 let inputs = match load(manifest_path, migrations_dir) {
1048 Ok(inputs) => inputs,
1049 Err(failures) => {
1050 return VerifyOutcome {
1051 report: DoctorReport { failures },
1052 digest: None,
1053 };
1054 }
1055 };
1056 let (content, _) = compute_plan(
1057 &inputs.manifest,
1058 &inputs.set,
1059 &inputs.lock,
1060 inputs.record.as_ref(),
1061 &inputs.catalog,
1062 );
1063 let mut failures: Vec<DoctorFailure> = Vec::new();
1064 let digest = match plan_digest(&content) {
1065 Ok(digest) => digest,
1066 Err(new_failures) => {
1067 return VerifyOutcome {
1068 report: DoctorReport {
1069 failures: new_failures,
1070 },
1071 digest: None,
1072 };
1073 }
1074 };
1075
1076 let Some(record) = &inputs.record else {
1077 failures.push(failure(
1078 &CODES.not_deployed,
1079 "no deployment is recorded for this manifest — run `fz plan --json`, approve the \
1080 digest, then `fz deploy --plan <digest>`",
1081 ));
1082 return VerifyOutcome {
1083 report: DoctorReport { failures },
1084 digest: Some(digest),
1085 };
1086 };
1087
1088 if let Some(what) = diff_modules(&content.state.modules, &record.plan.state.modules) {
1089 failures.push(failure(
1090 &CODES.composition_drift,
1091 format!("the recorded deployment's module set drifted from the manifest: {what}"),
1092 ));
1093 }
1094 if let Some(what) = diff_config(&content.state.config, &record.plan.state.config) {
1095 failures.push(failure(
1096 &CODES.config_drift,
1097 format!("the recorded deployment's config drifted from the manifest: {what}"),
1098 ));
1099 }
1100 if content.state.env != record.plan.state.env {
1101 failures.push(failure(
1102 &CODES.env_drift,
1103 format!(
1104 "the deployment environment drifted: recorded {}, manifest resolves to {}",
1105 record.plan.state.env, content.state.env
1106 ),
1107 ));
1108 }
1109 let narrow = failures.len();
1110 if record.digest != digest {
1111 let moved = moved_since(&content.state, &record.plan.state);
1112 let message = if moved.is_empty() {
1113 format!(
1114 "the recorded digest does not match a plan recomputed from the manifest, and \
1115 no module, config or environment drift explains it — the venture's host, \
1116 public URL, CORS origins, seed SQL or per-module config changed since the \
1117 deployment was recorded (recorded {}, current {digest})",
1118 record.digest
1119 )
1120 } else {
1121 format!(
1122 "the recorded digest does not match a plan recomputed from the manifest \
1123 (recorded {}, current {digest}): {}",
1124 record.digest,
1125 moved.join("; ")
1126 )
1127 };
1128 if failures.len() == narrow {
1129 failures.push(failure(&CODES.manifest_drift, message));
1130 }
1131 }
1132 VerifyOutcome {
1133 report: DoctorReport { failures },
1134 digest: Some(digest),
1135 }
1136}
1137
1138#[derive(Serialize)]
1141struct FailRef<'a> {
1142 code: &'a str,
1143 message: &'a str,
1144}
1145
1146#[derive(Serialize)]
1148struct BarePayload<'a> {
1149 schema: u32,
1150 ok: bool,
1151 failures: Vec<FailRef<'a>>,
1152}
1153
1154#[derive(Serialize)]
1157struct PlanPayload<'a> {
1158 schema: u32,
1159 ok: bool,
1160 failures: Vec<FailRef<'a>>,
1161 digest: &'a str,
1162 #[serde(flatten)]
1163 content: &'a PlanContent,
1164 baseline: &'a Baseline,
1165}
1166
1167#[derive(Serialize)]
1169struct DeployPayload<'a> {
1170 schema: u32,
1171 ok: bool,
1172 failures: Vec<FailRef<'a>>,
1173 changed: bool,
1174 digest: &'a str,
1175 venture: &'a str,
1176 env: &'a str,
1177 modules_added: &'a [String],
1178 modules_removed: &'a [String],
1179 record: String,
1180}
1181
1182#[derive(Serialize)]
1184struct AddPayload<'a> {
1185 schema: u32,
1186 ok: bool,
1187 failures: Vec<FailRef<'a>>,
1188 changed: bool,
1189 module: &'a str,
1190 manifest: String,
1191}
1192
1193#[derive(Serialize)]
1195struct InitPayload<'a> {
1196 schema: u32,
1197 ok: bool,
1198 failures: Vec<FailRef<'a>>,
1199 name: &'a str,
1200 host: &'a str,
1201 manifest: String,
1202}
1203
1204fn refs(failures: &[DoctorFailure]) -> Vec<FailRef<'_>> {
1205 failures
1206 .iter()
1207 .map(|failure| FailRef {
1208 code: failure.code.code,
1209 message: &failure.message,
1210 })
1211 .collect()
1212}
1213
1214fn emit(payload: &impl Serialize, exit: ExitCode) -> ExitCode {
1218 match serde_json::to_string(payload) {
1219 Ok(line) => {
1220 println!("{line}");
1221 exit
1222 }
1223 Err(err) => {
1224 eprintln!("fz: cannot serialize the report: {err}");
1225 ExitCode::FAILURE
1226 }
1227 }
1228}
1229
1230fn fail(failures: &[DoctorFailure], json: bool) -> ExitCode {
1233 if json {
1234 let payload = BarePayload {
1235 schema: SCHEMA,
1236 ok: false,
1237 failures: refs(failures),
1238 };
1239 return emit(&payload, ExitCode::FAILURE);
1240 }
1241 eprintln!("fz: {}", joined(failures));
1242 ExitCode::FAILURE
1243}
1244
1245#[must_use = "call process::exit with the returned ExitCode"]
1247pub fn plan_cli(
1248 manifest_path: &Path,
1249 migrations_dir: &Path,
1250 json: bool,
1251 non_interactive: bool,
1252) -> ExitCode {
1253 match plan(manifest_path, migrations_dir) {
1254 Ok(outcome) => {
1255 if json {
1256 let payload = PlanPayload {
1257 schema: SCHEMA,
1258 ok: true,
1259 failures: Vec::new(),
1260 digest: &outcome.digest,
1261 content: &outcome.content,
1262 baseline: &outcome.baseline,
1263 };
1264 return emit(&payload, ExitCode::SUCCESS);
1265 }
1266 println!(
1267 "fz plan: {} (env: {})",
1268 outcome.content.state.venture, outcome.content.state.env
1269 );
1270 println!(" digest: {}", outcome.digest);
1271 println!(
1272 " baseline: {} ({})",
1273 outcome.baseline.source,
1274 or_none(&outcome.baseline.modules)
1275 );
1276 println!(" added: {}", or_none(&outcome.content.modules_added));
1277 println!(
1278 " removed: {}",
1279 or_none(&outcome.content.modules_removed)
1280 );
1281 let mut config = Vec::new();
1282 for key in &outcome.content.config_added {
1283 config.push(format!("+{key}"));
1284 }
1285 for key in &outcome.content.config_removed {
1286 config.push(format!("-{key}"));
1287 }
1288 for key in &outcome.content.config_changed {
1289 config.push(format!("~{key}"));
1290 }
1291 println!(
1292 " config: {}",
1293 if config.is_empty() {
1294 "(unchanged)".to_owned()
1295 } else {
1296 config.join(" ")
1297 }
1298 );
1299 println!(
1300 " migrations: not collected for {} / orphaned {}",
1301 or_none(&outcome.content.state.migrations.not_collected),
1302 or_none(&outcome.content.state.migrations.orphaned)
1303 );
1304 for capability in &outcome.content.capabilities {
1305 println!(
1306 " needs: {} ({} module, requires {}, {})",
1307 capability.slug,
1308 capability.tier,
1309 or_none(&capability.requires),
1310 capability.via
1311 );
1312 }
1313 if !non_interactive {
1314 println!();
1315 println!("next:");
1316 println!(
1317 " fz deploy --plan {} # apply exactly this plan",
1318 outcome.digest
1319 );
1320 }
1321 ExitCode::SUCCESS
1322 }
1323 Err(failures) => fail(&failures, json),
1324 }
1325}
1326
1327fn or_none(items: &[String]) -> String {
1329 if items.is_empty() {
1330 "(none)".to_owned()
1331 } else {
1332 items.join(", ")
1333 }
1334}
1335
1336#[must_use = "call process::exit with the returned ExitCode"]
1338pub fn deploy_cli(
1339 approved: Option<&str>,
1340 manifest_path: &Path,
1341 migrations_dir: &Path,
1342 consent: Consent,
1343 json: bool,
1344 non_interactive: bool,
1345) -> ExitCode {
1346 match deploy(approved, manifest_path, migrations_dir, consent) {
1347 Ok(outcome) => {
1348 if json {
1349 let payload = DeployPayload {
1350 schema: SCHEMA,
1351 ok: true,
1352 failures: Vec::new(),
1353 changed: outcome.changed,
1354 digest: &outcome.digest,
1355 venture: &outcome.content.state.venture,
1356 env: &outcome.content.state.env,
1357 modules_added: &outcome.content.modules_added,
1358 modules_removed: &outcome.content.modules_removed,
1359 record: outcome.record_path.display().to_string(),
1360 };
1361 return emit(&payload, ExitCode::SUCCESS);
1362 }
1363 if !outcome.changed {
1364 println!(
1365 "fz deploy: nothing to do — plan {} is already the recorded deployment",
1366 outcome.digest
1367 );
1368 return ExitCode::SUCCESS;
1369 }
1370 println!(
1371 "fz deploy: {} (env: {})",
1372 outcome.content.state.venture, outcome.content.state.env
1373 );
1374 println!(" digest: {}", outcome.digest);
1375 println!(" added: {}", or_none(&outcome.content.modules_added));
1376 println!(
1377 " removed: {}",
1378 or_none(&outcome.content.modules_removed)
1379 );
1380 println!(" record: {}", outcome.record_path.display());
1381 if !non_interactive {
1382 println!();
1383 println!("next (needs a human or credentials fz does not hold):");
1384 println!(
1385 " fz migrations collect # materialise the wrangler migration files"
1386 );
1387 println!(" fz doctor # harness validity, production rules");
1388 println!(" worker-build --release # compile the venture to wasm");
1389 println!(" wrangler deploy # stand up the Worker + D1");
1390 }
1391 ExitCode::SUCCESS
1392 }
1393 Err(failures) => fail(&failures, json),
1394 }
1395}
1396
1397#[must_use = "call process::exit with the returned ExitCode"]
1399pub fn add_cli(module: &str, manifest_path: &Path, json: bool, non_interactive: bool) -> ExitCode {
1400 match add(module, manifest_path) {
1401 Ok(outcome) => {
1402 if json {
1403 let payload = AddPayload {
1404 schema: SCHEMA,
1405 ok: true,
1406 failures: Vec::new(),
1407 changed: outcome.changed,
1408 module: &outcome.module,
1409 manifest: outcome.manifest.display().to_string(),
1410 };
1411 return emit(&payload, ExitCode::SUCCESS);
1412 }
1413 if outcome.changed {
1414 println!(
1415 "fz add: added `{}` to {} — the desired composition only; nothing was \
1416 deployed, provisioned or migrated",
1417 outcome.module,
1418 outcome.manifest.display()
1419 );
1420 if !non_interactive {
1421 println!();
1422 println!("next:");
1423 println!(" fz plan --json # see what this would change and get its digest");
1424 }
1425 } else {
1426 println!(
1427 "fz add: `{}` is already in {} — nothing changed",
1428 outcome.module,
1429 outcome.manifest.display()
1430 );
1431 }
1432 ExitCode::SUCCESS
1433 }
1434 Err(failures) => fail(&failures, json),
1435 }
1436}
1437
1438#[must_use = "call process::exit with the returned ExitCode"]
1440pub fn init_cli(
1441 name: &str,
1442 host: &str,
1443 manifest_path: &Path,
1444 force: bool,
1445 json: bool,
1446 non_interactive: bool,
1447) -> ExitCode {
1448 match init(name, host, manifest_path, force) {
1449 Ok(path) => {
1450 if json {
1451 let payload = InitPayload {
1452 schema: SCHEMA,
1453 ok: true,
1454 failures: Vec::new(),
1455 name,
1456 host,
1457 manifest: path.display().to_string(),
1458 };
1459 return emit(&payload, ExitCode::SUCCESS);
1460 }
1461 println!(
1462 "fz init: wrote {} (venture {name}, host {host}, no modules yet)",
1463 path.display()
1464 );
1465 if !non_interactive {
1466 println!();
1467 println!("next:");
1468 println!(
1469 " fz add <module> # grow the desired composition, one module at a time"
1470 );
1471 }
1472 ExitCode::SUCCESS
1473 }
1474 Err(failures) => fail(&failures, json),
1475 }
1476}
1477
1478#[must_use = "call process::exit with the returned ExitCode"]
1481pub fn verify_cli(manifest_path: &Path, migrations_dir: &Path, json: bool) -> ExitCode {
1482 let outcome = verify(manifest_path, migrations_dir);
1483 if json {
1484 match outcome.report.render_json() {
1485 Ok(payload) => {
1486 println!("{payload}");
1487 return if outcome.report.ok() {
1488 ExitCode::SUCCESS
1489 } else {
1490 ExitCode::FAILURE
1491 };
1492 }
1493 Err(err) => {
1494 eprintln!("fz: cannot serialize the report: {err}");
1495 return ExitCode::FAILURE;
1496 }
1497 }
1498 }
1499 if outcome.report.ok() {
1500 match outcome.digest {
1501 Some(digest) => println!(
1502 "fz verify: the recorded deployment matches the manifest (digest {digest})"
1503 ),
1504 None => println!("fz verify: no problems found"),
1505 }
1506 ExitCode::SUCCESS
1507 } else {
1508 eprintln!("fz: {}", joined(&outcome.report.failures));
1509 ExitCode::FAILURE
1510 }
1511}
1512
1513pub(crate) fn dispatch(command: &crate::Command) -> Option<ExitCode> {
1516 match command {
1517 crate::Command::Plan {
1518 manifest,
1519 migrations,
1520 json,
1521 non_interactive,
1522 } => Some(plan_cli(manifest, migrations, *json, *non_interactive)),
1523 crate::Command::Deploy {
1524 plan,
1525 manifest,
1526 migrations,
1527 i_am_deploying_to_production,
1528 i_am_removing_modules,
1529 json,
1530 non_interactive,
1531 } => Some(deploy_cli(
1532 plan.as_deref(),
1533 manifest,
1534 migrations,
1535 Consent {
1536 production: *i_am_deploying_to_production,
1537 removal: *i_am_removing_modules,
1538 },
1539 *json,
1540 *non_interactive,
1541 )),
1542 crate::Command::Add {
1543 module,
1544 manifest,
1545 json,
1546 non_interactive,
1547 } => Some(add_cli(module, manifest, *json, *non_interactive)),
1548 crate::Command::Init {
1549 name,
1550 host,
1551 manifest,
1552 force,
1553 json,
1554 non_interactive,
1555 } => Some(init_cli(
1556 name,
1557 host,
1558 manifest,
1559 *force,
1560 *json,
1561 *non_interactive,
1562 )),
1563 crate::Command::Verify {
1564 manifest,
1565 migrations,
1566 json,
1567 ..
1568 } => Some(verify_cli(manifest, migrations, *json)),
1569 _ => None,
1570 }
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575 use super::{compute_plan, plan_digest, resolved_env};
1576 use crate::lock::Lock;
1577 use cratefield_core::VentureEnv;
1578 use cratefield_manifest::{ModuleRef, VentureManifest, builtin};
1579 use std::collections::BTreeMap;
1580
1581 fn manifest(modules: &[&str], config: &[(&str, &str)]) -> VentureManifest {
1582 VentureManifest {
1583 name: "acme".to_owned(),
1584 host: "acme.factory0.dev".to_owned(),
1585 public_url: None,
1586 cors_origins: Vec::new(),
1587 modules: modules
1588 .iter()
1589 .map(|slug| ModuleRef::Slug((*slug).to_owned()))
1590 .collect(),
1591 config: config
1592 .iter()
1593 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
1594 .collect::<BTreeMap<_, _>>(),
1595 seed_sql: None,
1596 }
1597 }
1598
1599 #[test]
1600 fn env_reads_the_manifest_config_key() {
1601 assert_eq!(resolved_env(&manifest(&[], &[])), VentureEnv::Development);
1602 assert_eq!(
1603 resolved_env(&manifest(&[], &[("ENV", "production")])),
1604 VentureEnv::Production
1605 );
1606 assert_eq!(
1607 resolved_env(&manifest(&[], &[("ENV", "nonsense")])),
1608 VentureEnv::Development,
1609 "unrecognised values fall back to development"
1610 );
1611 }
1612
1613 #[test]
1617 fn digest_is_stable_and_input_sensitive() {
1618 let catalog = builtin();
1619 let first = manifest(&["waitlist"], &[]);
1620 let set = first.resolve(&catalog).expect("resolves");
1621 let (content, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1622 let (again, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1623 let digest = plan_digest(&content).expect("digests");
1624 assert_eq!(digest, plan_digest(&again).expect("digests"));
1625
1626 let changed = manifest(&["waitlist"], &[("ENV", "production")]);
1627 let changed_set = changed.resolve(&catalog).expect("resolves");
1628 let (changed_content, _) =
1629 compute_plan(&changed, &changed_set, &Lock::default(), None, &catalog);
1630 assert_ne!(
1631 digest,
1632 plan_digest(&changed_content).expect("digests"),
1633 "a config change must move the digest"
1634 );
1635 assert_eq!(changed_content.state.env, "production");
1636 }
1637
1638 #[test]
1641 fn digest_is_order_insensitive_over_the_selection() {
1642 let catalog = builtin();
1643 let a = manifest(&["waitlist", "email-signup"], &[]);
1644 let b = manifest(&["email-signup", "waitlist"], &[]);
1645 let set_a = a.resolve(&catalog).expect("resolves");
1646 let set_b = b.resolve(&catalog).expect("resolves");
1647 let (content_a, _) = compute_plan(&a, &set_a, &Lock::default(), None, &catalog);
1648 let (content_b, _) = compute_plan(&b, &set_b, &Lock::default(), None, &catalog);
1649 assert_eq!(
1650 plan_digest(&content_a).expect("digests"),
1651 plan_digest(&content_b).expect("digests")
1652 );
1653 }
1654}