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![format!("https://{host}")],
1033 modules: Vec::new(),
1034 config: BTreeMap::new(),
1035 seed_sql: None,
1036 ..Default::default()
1039 };
1040 manifest
1041 .validate()
1042 .map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
1043 write_manifest(manifest_path, &manifest)
1044 .map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
1045 Ok(manifest_path.to_path_buf())
1046}
1047
1048#[must_use]
1058pub fn verify(manifest_path: &Path, migrations_dir: &Path) -> VerifyOutcome {
1059 let inputs = match load(manifest_path, migrations_dir) {
1060 Ok(inputs) => inputs,
1061 Err(failures) => {
1062 return VerifyOutcome {
1063 report: DoctorReport { failures },
1064 digest: None,
1065 };
1066 }
1067 };
1068 let (content, _) = compute_plan(
1069 &inputs.manifest,
1070 &inputs.set,
1071 &inputs.lock,
1072 inputs.record.as_ref(),
1073 &inputs.catalog,
1074 );
1075 let mut failures: Vec<DoctorFailure> = Vec::new();
1076 let digest = match plan_digest(&content) {
1077 Ok(digest) => digest,
1078 Err(new_failures) => {
1079 return VerifyOutcome {
1080 report: DoctorReport {
1081 failures: new_failures,
1082 },
1083 digest: None,
1084 };
1085 }
1086 };
1087
1088 let Some(record) = &inputs.record else {
1089 failures.push(failure(
1090 &CODES.not_deployed,
1091 "no deployment is recorded for this manifest — run `fz plan --json`, approve the \
1092 digest, then `fz deploy --plan <digest>`",
1093 ));
1094 return VerifyOutcome {
1095 report: DoctorReport { failures },
1096 digest: Some(digest),
1097 };
1098 };
1099
1100 if let Some(what) = diff_modules(&content.state.modules, &record.plan.state.modules) {
1101 failures.push(failure(
1102 &CODES.composition_drift,
1103 format!("the recorded deployment's module set drifted from the manifest: {what}"),
1104 ));
1105 }
1106 if let Some(what) = diff_config(&content.state.config, &record.plan.state.config) {
1107 failures.push(failure(
1108 &CODES.config_drift,
1109 format!("the recorded deployment's config drifted from the manifest: {what}"),
1110 ));
1111 }
1112 if content.state.env != record.plan.state.env {
1113 failures.push(failure(
1114 &CODES.env_drift,
1115 format!(
1116 "the deployment environment drifted: recorded {}, manifest resolves to {}",
1117 record.plan.state.env, content.state.env
1118 ),
1119 ));
1120 }
1121 let narrow = failures.len();
1122 if record.digest != digest {
1123 let moved = moved_since(&content.state, &record.plan.state);
1124 let message = if moved.is_empty() {
1125 format!(
1126 "the recorded digest does not match a plan recomputed from the manifest, and \
1127 no module, config or environment drift explains it — the venture's host, \
1128 public URL, CORS origins, seed SQL or per-module config changed since the \
1129 deployment was recorded (recorded {}, current {digest})",
1130 record.digest
1131 )
1132 } else {
1133 format!(
1134 "the recorded digest does not match a plan recomputed from the manifest \
1135 (recorded {}, current {digest}): {}",
1136 record.digest,
1137 moved.join("; ")
1138 )
1139 };
1140 if failures.len() == narrow {
1141 failures.push(failure(&CODES.manifest_drift, message));
1142 }
1143 }
1144 VerifyOutcome {
1145 report: DoctorReport { failures },
1146 digest: Some(digest),
1147 }
1148}
1149
1150#[derive(Serialize)]
1153struct FailRef<'a> {
1154 code: &'a str,
1155 message: &'a str,
1156}
1157
1158#[derive(Serialize)]
1160struct BarePayload<'a> {
1161 schema: u32,
1162 ok: bool,
1163 failures: Vec<FailRef<'a>>,
1164}
1165
1166#[derive(Serialize)]
1169struct PlanPayload<'a> {
1170 schema: u32,
1171 ok: bool,
1172 failures: Vec<FailRef<'a>>,
1173 digest: &'a str,
1174 #[serde(flatten)]
1175 content: &'a PlanContent,
1176 baseline: &'a Baseline,
1177}
1178
1179#[derive(Serialize)]
1181struct DeployPayload<'a> {
1182 schema: u32,
1183 ok: bool,
1184 failures: Vec<FailRef<'a>>,
1185 changed: bool,
1186 digest: &'a str,
1187 venture: &'a str,
1188 env: &'a str,
1189 modules_added: &'a [String],
1190 modules_removed: &'a [String],
1191 record: String,
1192}
1193
1194#[derive(Serialize)]
1196struct AddPayload<'a> {
1197 schema: u32,
1198 ok: bool,
1199 failures: Vec<FailRef<'a>>,
1200 changed: bool,
1201 module: &'a str,
1202 manifest: String,
1203}
1204
1205#[derive(Serialize)]
1207struct InitPayload<'a> {
1208 schema: u32,
1209 ok: bool,
1210 failures: Vec<FailRef<'a>>,
1211 name: &'a str,
1212 host: &'a str,
1213 manifest: String,
1214}
1215
1216fn refs(failures: &[DoctorFailure]) -> Vec<FailRef<'_>> {
1217 failures
1218 .iter()
1219 .map(|failure| FailRef {
1220 code: failure.code.code,
1221 message: &failure.message,
1222 })
1223 .collect()
1224}
1225
1226fn emit(payload: &impl Serialize, exit: ExitCode) -> ExitCode {
1230 match serde_json::to_string(payload) {
1231 Ok(line) => {
1232 println!("{line}");
1233 exit
1234 }
1235 Err(err) => {
1236 eprintln!("fz: cannot serialize the report: {err}");
1237 ExitCode::FAILURE
1238 }
1239 }
1240}
1241
1242fn fail(failures: &[DoctorFailure], json: bool) -> ExitCode {
1245 if json {
1246 let payload = BarePayload {
1247 schema: SCHEMA,
1248 ok: false,
1249 failures: refs(failures),
1250 };
1251 return emit(&payload, ExitCode::FAILURE);
1252 }
1253 eprintln!("fz: {}", joined(failures));
1254 ExitCode::FAILURE
1255}
1256
1257#[must_use = "call process::exit with the returned ExitCode"]
1259pub fn plan_cli(
1260 manifest_path: &Path,
1261 migrations_dir: &Path,
1262 json: bool,
1263 non_interactive: bool,
1264) -> ExitCode {
1265 match plan(manifest_path, migrations_dir) {
1266 Ok(outcome) => {
1267 if json {
1268 let payload = PlanPayload {
1269 schema: SCHEMA,
1270 ok: true,
1271 failures: Vec::new(),
1272 digest: &outcome.digest,
1273 content: &outcome.content,
1274 baseline: &outcome.baseline,
1275 };
1276 return emit(&payload, ExitCode::SUCCESS);
1277 }
1278 println!(
1279 "fz plan: {} (env: {})",
1280 outcome.content.state.venture, outcome.content.state.env
1281 );
1282 println!(" digest: {}", outcome.digest);
1283 println!(
1284 " baseline: {} ({})",
1285 outcome.baseline.source,
1286 or_none(&outcome.baseline.modules)
1287 );
1288 println!(" added: {}", or_none(&outcome.content.modules_added));
1289 println!(
1290 " removed: {}",
1291 or_none(&outcome.content.modules_removed)
1292 );
1293 let mut config = Vec::new();
1294 for key in &outcome.content.config_added {
1295 config.push(format!("+{key}"));
1296 }
1297 for key in &outcome.content.config_removed {
1298 config.push(format!("-{key}"));
1299 }
1300 for key in &outcome.content.config_changed {
1301 config.push(format!("~{key}"));
1302 }
1303 println!(
1304 " config: {}",
1305 if config.is_empty() {
1306 "(unchanged)".to_owned()
1307 } else {
1308 config.join(" ")
1309 }
1310 );
1311 println!(
1312 " migrations: not collected for {} / orphaned {}",
1313 or_none(&outcome.content.state.migrations.not_collected),
1314 or_none(&outcome.content.state.migrations.orphaned)
1315 );
1316 for capability in &outcome.content.capabilities {
1317 println!(
1318 " needs: {} ({} module, requires {}, {})",
1319 capability.slug,
1320 capability.tier,
1321 or_none(&capability.requires),
1322 capability.via
1323 );
1324 }
1325 if !non_interactive {
1326 println!();
1327 println!("next:");
1328 println!(
1329 " fz deploy --plan {} # apply exactly this plan",
1330 outcome.digest
1331 );
1332 }
1333 ExitCode::SUCCESS
1334 }
1335 Err(failures) => fail(&failures, json),
1336 }
1337}
1338
1339fn or_none(items: &[String]) -> String {
1341 if items.is_empty() {
1342 "(none)".to_owned()
1343 } else {
1344 items.join(", ")
1345 }
1346}
1347
1348#[must_use = "call process::exit with the returned ExitCode"]
1350pub fn deploy_cli(
1351 approved: Option<&str>,
1352 manifest_path: &Path,
1353 migrations_dir: &Path,
1354 consent: Consent,
1355 json: bool,
1356 non_interactive: bool,
1357) -> ExitCode {
1358 match deploy(approved, manifest_path, migrations_dir, consent) {
1359 Ok(outcome) => {
1360 if json {
1361 let payload = DeployPayload {
1362 schema: SCHEMA,
1363 ok: true,
1364 failures: Vec::new(),
1365 changed: outcome.changed,
1366 digest: &outcome.digest,
1367 venture: &outcome.content.state.venture,
1368 env: &outcome.content.state.env,
1369 modules_added: &outcome.content.modules_added,
1370 modules_removed: &outcome.content.modules_removed,
1371 record: outcome.record_path.display().to_string(),
1372 };
1373 return emit(&payload, ExitCode::SUCCESS);
1374 }
1375 if !outcome.changed {
1376 println!(
1377 "fz deploy: nothing to do — plan {} is already the recorded deployment",
1378 outcome.digest
1379 );
1380 return ExitCode::SUCCESS;
1381 }
1382 println!(
1383 "fz deploy: {} (env: {})",
1384 outcome.content.state.venture, outcome.content.state.env
1385 );
1386 println!(" digest: {}", outcome.digest);
1387 println!(" added: {}", or_none(&outcome.content.modules_added));
1388 println!(
1389 " removed: {}",
1390 or_none(&outcome.content.modules_removed)
1391 );
1392 println!(" record: {}", outcome.record_path.display());
1393 if !non_interactive {
1394 println!();
1395 println!("next (needs a human or credentials fz does not hold):");
1396 println!(
1397 " fz migrations collect # materialise the wrangler migration files"
1398 );
1399 println!(" fz doctor # harness validity, production rules");
1400 println!(" worker-build --release # compile the venture to wasm");
1401 println!(" wrangler deploy # stand up the Worker + D1");
1402 }
1403 ExitCode::SUCCESS
1404 }
1405 Err(failures) => fail(&failures, json),
1406 }
1407}
1408
1409#[must_use = "call process::exit with the returned ExitCode"]
1411pub fn add_cli(module: &str, manifest_path: &Path, json: bool, non_interactive: bool) -> ExitCode {
1412 match add(module, manifest_path) {
1413 Ok(outcome) => {
1414 if json {
1415 let payload = AddPayload {
1416 schema: SCHEMA,
1417 ok: true,
1418 failures: Vec::new(),
1419 changed: outcome.changed,
1420 module: &outcome.module,
1421 manifest: outcome.manifest.display().to_string(),
1422 };
1423 return emit(&payload, ExitCode::SUCCESS);
1424 }
1425 if outcome.changed {
1426 println!(
1427 "fz add: added `{}` to {} — the desired composition only; nothing was \
1428 deployed, provisioned or migrated",
1429 outcome.module,
1430 outcome.manifest.display()
1431 );
1432 if !non_interactive {
1433 println!();
1434 println!("next:");
1435 println!(" fz plan --json # see what this would change and get its digest");
1436 }
1437 } else {
1438 println!(
1439 "fz add: `{}` is already in {} — nothing changed",
1440 outcome.module,
1441 outcome.manifest.display()
1442 );
1443 }
1444 ExitCode::SUCCESS
1445 }
1446 Err(failures) => fail(&failures, json),
1447 }
1448}
1449
1450#[must_use = "call process::exit with the returned ExitCode"]
1452pub fn init_cli(
1453 name: &str,
1454 host: &str,
1455 manifest_path: &Path,
1456 force: bool,
1457 json: bool,
1458 non_interactive: bool,
1459) -> ExitCode {
1460 match init(name, host, manifest_path, force) {
1461 Ok(path) => {
1462 if json {
1463 let payload = InitPayload {
1464 schema: SCHEMA,
1465 ok: true,
1466 failures: Vec::new(),
1467 name,
1468 host,
1469 manifest: path.display().to_string(),
1470 };
1471 return emit(&payload, ExitCode::SUCCESS);
1472 }
1473 println!(
1474 "fz init: wrote {} (venture {name}, host {host}, no modules yet)",
1475 path.display()
1476 );
1477 if !non_interactive {
1478 println!();
1479 println!("next:");
1480 println!(
1481 " fz add <module> # grow the desired composition, one module at a time"
1482 );
1483 }
1484 ExitCode::SUCCESS
1485 }
1486 Err(failures) => fail(&failures, json),
1487 }
1488}
1489
1490#[must_use = "call process::exit with the returned ExitCode"]
1493pub fn verify_cli(manifest_path: &Path, migrations_dir: &Path, json: bool) -> ExitCode {
1494 let outcome = verify(manifest_path, migrations_dir);
1495 if json {
1496 match outcome.report.render_json() {
1497 Ok(payload) => {
1498 println!("{payload}");
1499 return if outcome.report.ok() {
1500 ExitCode::SUCCESS
1501 } else {
1502 ExitCode::FAILURE
1503 };
1504 }
1505 Err(err) => {
1506 eprintln!("fz: cannot serialize the report: {err}");
1507 return ExitCode::FAILURE;
1508 }
1509 }
1510 }
1511 if outcome.report.ok() {
1512 match outcome.digest {
1513 Some(digest) => println!(
1514 "fz verify: the recorded deployment matches the manifest (digest {digest})"
1515 ),
1516 None => println!("fz verify: no problems found"),
1517 }
1518 ExitCode::SUCCESS
1519 } else {
1520 eprintln!("fz: {}", joined(&outcome.report.failures));
1521 ExitCode::FAILURE
1522 }
1523}
1524
1525pub(crate) fn dispatch(command: &crate::Command) -> Option<ExitCode> {
1528 match command {
1529 crate::Command::Plan {
1530 manifest,
1531 migrations,
1532 json,
1533 non_interactive,
1534 } => Some(plan_cli(manifest, migrations, *json, *non_interactive)),
1535 crate::Command::Deploy {
1536 plan,
1537 manifest,
1538 migrations,
1539 i_am_deploying_to_production,
1540 i_am_removing_modules,
1541 json,
1542 non_interactive,
1543 } => Some(deploy_cli(
1544 plan.as_deref(),
1545 manifest,
1546 migrations,
1547 Consent {
1548 production: *i_am_deploying_to_production,
1549 removal: *i_am_removing_modules,
1550 },
1551 *json,
1552 *non_interactive,
1553 )),
1554 crate::Command::Add {
1555 module,
1556 manifest,
1557 json,
1558 non_interactive,
1559 } => Some(add_cli(module, manifest, *json, *non_interactive)),
1560 crate::Command::Init {
1561 name,
1562 host,
1563 manifest,
1564 force,
1565 json,
1566 non_interactive,
1567 } => Some(init_cli(
1568 name,
1569 host,
1570 manifest,
1571 *force,
1572 *json,
1573 *non_interactive,
1574 )),
1575 crate::Command::Verify {
1576 manifest,
1577 migrations,
1578 json,
1579 ..
1580 } => Some(verify_cli(manifest, migrations, *json)),
1581 _ => None,
1582 }
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587 use super::{compute_plan, init, plan_digest, resolved_env};
1588 use crate::lock::Lock;
1589 use cratefield_core::VentureEnv;
1590 use cratefield_manifest::{ModuleRef, VentureManifest, builtin};
1591 use std::collections::BTreeMap;
1592
1593 fn manifest(modules: &[&str], config: &[(&str, &str)]) -> VentureManifest {
1594 VentureManifest {
1595 name: "acme".to_owned(),
1596 host: "acme.factory0.dev".to_owned(),
1597 public_url: None,
1598 cors_origins: vec!["https://acme.example".to_owned()],
1602 modules: modules
1603 .iter()
1604 .map(|slug| ModuleRef::Slug((*slug).to_owned()))
1605 .collect(),
1606 config: config
1607 .iter()
1608 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
1609 .collect::<BTreeMap<_, _>>(),
1610 seed_sql: None,
1611 ..Default::default()
1612 }
1613 }
1614
1615 #[test]
1616 fn env_reads_the_manifest_config_key() {
1617 assert_eq!(resolved_env(&manifest(&[], &[])), VentureEnv::Development);
1618 assert_eq!(
1619 resolved_env(&manifest(&[], &[("ENV", "production")])),
1620 VentureEnv::Production
1621 );
1622 assert_eq!(
1623 resolved_env(&manifest(&[], &[("ENV", "nonsense")])),
1624 VentureEnv::Development,
1625 "unrecognised values fall back to development"
1626 );
1627 }
1628
1629 #[test]
1633 fn digest_is_stable_and_input_sensitive() {
1634 let catalog = builtin();
1635 let first = manifest(&["waitlist"], &[]);
1636 let set = first.resolve(&catalog).expect("resolves");
1637 let (content, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1638 let (again, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
1639 let digest = plan_digest(&content).expect("digests");
1640 assert_eq!(digest, plan_digest(&again).expect("digests"));
1641
1642 let changed = manifest(&["waitlist"], &[("ENV", "production")]);
1643 let changed_set = changed.resolve(&catalog).expect("resolves");
1644 let (changed_content, _) =
1645 compute_plan(&changed, &changed_set, &Lock::default(), None, &catalog);
1646 assert_ne!(
1647 digest,
1648 plan_digest(&changed_content).expect("digests"),
1649 "a config change must move the digest"
1650 );
1651 assert_eq!(changed_content.state.env, "production");
1652 }
1653
1654 #[test]
1657 fn digest_is_order_insensitive_over_the_selection() {
1658 let catalog = builtin();
1659 let a = manifest(&["waitlist", "email-signup"], &[]);
1660 let b = manifest(&["email-signup", "waitlist"], &[]);
1661 let set_a = a.resolve(&catalog).expect("resolves");
1662 let set_b = b.resolve(&catalog).expect("resolves");
1663 let (content_a, _) = compute_plan(&a, &set_a, &Lock::default(), None, &catalog);
1664 let (content_b, _) = compute_plan(&b, &set_b, &Lock::default(), None, &catalog);
1665 assert_eq!(
1666 plan_digest(&content_a).expect("digests"),
1667 plan_digest(&content_b).expect("digests")
1668 );
1669 }
1670
1671 #[test]
1676 fn init_scaffolds_a_manifest_the_rest_of_the_toolchain_accepts() {
1677 let dir = cratefield_testing::TempDir::new("fz-init");
1678 let path = dir.path().join("venture.json");
1679 init("acme-signups", "acme.factory0.dev", &path, false).expect("init writes one");
1680 let written = std::fs::read_to_string(&path).expect("it is there");
1681 let manifest = VentureManifest::from_json_str(&written).expect("it parses");
1682 manifest.validate().expect("and it is one fz build accepts");
1683 assert_eq!(manifest.cors_origins, ["https://acme.factory0.dev"]);
1684 }
1685
1686 #[test]
1687 fn init_refuses_a_name_that_is_not_one_and_writes_nothing() {
1688 let dir = cratefield_testing::TempDir::new("fz-init");
1692 let path = dir.path().join("venture.json");
1693 let failures = init("My Venture", "acme.factory0.dev", &path, false)
1694 .expect_err("a venture name is a slug");
1695 assert!(
1696 failures
1697 .iter()
1698 .any(|failure| failure.message.contains("name")),
1699 "{failures:?}"
1700 );
1701 assert!(!path.exists(), "a manifest that cannot build was written");
1702 }
1703}