use crate::codes::{CODES, DoctorCodeDef};
use crate::doctor::DoctorFailure;
use crate::doctor::DoctorReport;
use crate::lock::{Lock, read_lock, sha256_hex};
use cratefield_core::VentureEnv;
use cratefield_manifest::{Catalog, ModuleSet, VentureManifest, builtin};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
pub const SCHEMA: u32 = 1;
pub const RECORD_FILE: &str = ".harness-deploy.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployRecord {
pub schema: u32,
pub digest: String,
pub plan: PlanContent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanState {
pub venture: String,
pub host: String,
pub public_url: Option<String>,
pub cors_origins: Vec<String>,
pub env: String,
pub modules: Vec<String>,
pub selected: Vec<SelectedModule>,
pub config: BTreeMap<String, String>,
pub seed_sql: Option<String>,
pub migrations: MigrationState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanContent {
#[serde(flatten)]
pub state: PlanState,
pub modules_added: Vec<String>,
pub modules_removed: Vec<String>,
pub capabilities: Vec<Capability>,
pub config_added: Vec<String>,
pub config_removed: Vec<String>,
pub config_changed: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SelectedModule {
pub slug: String,
pub config: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Capability {
pub slug: String,
pub tier: String,
pub requires: Vec<String>,
pub via: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct MigrationState {
pub collected: BTreeMap<String, usize>,
pub not_collected: Vec<String>,
pub orphaned: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Baseline {
pub source: &'static str,
pub modules: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Consent {
pub production: bool,
pub removal: bool,
}
#[derive(Debug, Clone)]
pub struct PlanOutcome {
pub content: PlanContent,
pub digest: String,
pub baseline: Baseline,
}
#[derive(Debug, Clone)]
pub struct DeployOutcome {
pub content: PlanContent,
pub digest: String,
pub changed: bool,
pub record_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct AddOutcome {
pub changed: bool,
pub module: String,
pub manifest: PathBuf,
}
#[derive(Debug)]
pub struct VerifyOutcome {
pub report: DoctorReport,
pub digest: Option<String>,
}
fn failure(code: &'static DoctorCodeDef, message: impl Into<String>) -> DoctorFailure {
DoctorFailure {
code,
message: message.into(),
}
}
fn joined(failures: &[DoctorFailure]) -> String {
failures
.iter()
.map(|f| f.message.as_str())
.collect::<Vec<_>>()
.join("\n - ")
}
fn resolved_env(manifest: &VentureManifest) -> VentureEnv {
manifest
.config
.get("ENV")
.and_then(|value| VentureEnv::parse(value))
.unwrap_or_default()
}
fn record_path(manifest_path: &Path) -> PathBuf {
let mut path = manifest_path.parent().map_or_else(PathBuf::new, |parent| {
if parent.as_os_str().is_empty() {
PathBuf::new()
} else {
parent.to_path_buf()
}
});
path.push(RECORD_FILE);
path
}
fn read_record(manifest_path: &Path) -> Result<Option<DeployRecord>, String> {
let path = record_path(manifest_path);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
};
let record: DeployRecord = serde_json::from_str(&raw)
.map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
if record.schema != SCHEMA {
return Err(format!(
"{}: unsupported schema {} (this fz speaks {SCHEMA})",
path.display(),
record.schema
));
}
Ok(Some(record))
}
fn write_record(manifest_path: &Path, record: &DeployRecord) -> Result<PathBuf, String> {
let path = record_path(manifest_path);
let body = serde_json::to_string_pretty(record)
.map_err(|err| format!("cannot serialize the deploy record: {err}"))?;
let mut tmp = path.clone();
tmp.set_extension("tmp");
std::fs::write(&tmp, body + "\n")
.map_err(|err| format!("cannot write {}: {err}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|err| {
format!(
"cannot finalize {} (partial file left at {}): {err}",
path.display(),
tmp.display()
)
})?;
Ok(path)
}
fn write_manifest(path: &Path, manifest: &VentureManifest) -> Result<(), String> {
let body = match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => toml::to_string_pretty(manifest)
.map_err(|err| format!("cannot serialize TOML manifest: {err}"))?,
_ => serde_json::to_string_pretty(manifest)
.map_err(|err| format!("cannot serialize manifest: {err}"))?,
};
std::fs::write(path, body + "\n")
.map_err(|err| format!("cannot write {}: {err}", path.display()))
}
struct Inputs {
manifest: VentureManifest,
set: ModuleSet,
lock: Lock,
record: Option<DeployRecord>,
catalog: Catalog,
}
fn load(manifest_path: &Path, migrations_dir: &Path) -> Result<Inputs, Vec<DoctorFailure>> {
let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
vec![failure(
&CODES.manifest_unreadable,
format!("cannot read {}: {err}", manifest_path.display()),
)]
})?;
let manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
vec![failure(
&CODES.manifest_unreadable,
format!("{}: {err}", manifest_path.display()),
)]
})?;
let catalog = builtin();
let set = manifest
.resolve(&catalog)
.map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
let mut failures: Vec<DoctorFailure> = Vec::new();
let lock = match read_lock(migrations_dir) {
Ok(lock) => lock,
Err(err) => {
failures.push(failure(&CODES.lockfile_unreadable, err));
Lock::default()
}
};
let record = match read_record(manifest_path) {
Ok(record) => record,
Err(err) => {
failures.push(failure(&CODES.deploy_record_unreadable, err));
None
}
};
if failures.is_empty() {
Ok(Inputs {
manifest,
set,
lock,
record,
catalog,
})
} else {
Err(failures)
}
}
fn lockfile_modules(lock: &Lock) -> Vec<String> {
let mut modules: Vec<String> = lock
.keys()
.filter_map(|key| key.split('/').next())
.filter(|module| !module.is_empty())
.map(str::to_owned)
.collect();
modules.sort_unstable();
modules.dedup();
modules
}
fn baseline_of(lock: &Lock, record: Option<&DeployRecord>) -> Baseline {
if let Some(record) = record {
let mut modules = record.plan.state.modules.clone();
modules.sort_unstable();
return Baseline {
source: "deploy-record",
modules,
};
}
let modules = lockfile_modules(lock);
if modules.is_empty() {
Baseline {
source: "nothing",
modules: Vec::new(),
}
} else {
Baseline {
source: "lockfile",
modules,
}
}
}
fn capabilities_of(added: &[String], selected: &[&str], catalog: &Catalog) -> Vec<Capability> {
added
.iter()
.filter_map(|slug| {
let module = catalog.modules.iter().find(|m| &m.slug == slug)?;
let tier = match module.tier {
cratefield_manifest::Tier::Core => "core",
cratefield_manifest::Tier::Optional => "optional",
};
let mut requires = module.depends_on.clone();
requires.sort_unstable();
Some(Capability {
slug: slug.clone(),
tier: tier.to_owned(),
requires,
via: if selected.contains(&slug.as_str()) {
"selected".to_owned()
} else {
"dependency".to_owned()
},
})
})
.collect()
}
fn config_deltas(
manifest: &VentureManifest,
recorded: &BTreeMap<String, String>,
) -> (Vec<String>, Vec<String>, Vec<String>) {
let config_added: Vec<String> = manifest
.config
.keys()
.filter(|key| !recorded.contains_key(*key))
.cloned()
.collect();
let config_removed: Vec<String> = recorded
.keys()
.filter(|key| !manifest.config.contains_key(*key))
.cloned()
.collect();
let config_changed: Vec<String> = manifest
.config
.iter()
.filter(|(key, value)| {
recorded
.get(*key)
.is_some_and(|recorded| recorded != *value)
})
.map(|(key, _)| key.clone())
.collect();
(config_added, config_removed, config_changed)
}
fn migration_state(desired: &[String], lock: &Lock) -> MigrationState {
let mut state = MigrationState::default();
for key in lock.keys() {
let Some(module) = key.split('/').next() else {
continue;
};
if desired.iter().any(|slug| slug == module) {
*state.collected.entry(module.to_owned()).or_insert(0) += 1;
} else {
state.orphaned.push(key.clone());
}
}
state.not_collected = desired
.iter()
.filter(|slug| !state.collected.contains_key(*slug))
.cloned()
.collect();
state.orphaned.sort_unstable();
state
}
#[must_use]
fn compute_plan(
manifest: &VentureManifest,
set: &ModuleSet,
lock: &Lock,
record: Option<&DeployRecord>,
catalog: &Catalog,
) -> (PlanContent, Baseline) {
let mut desired = set.slugs().to_vec();
desired.sort_unstable();
let baseline = baseline_of(lock, record);
let modules_added: Vec<String> = desired
.iter()
.filter(|slug| !baseline.modules.contains(slug))
.cloned()
.collect();
let modules_removed: Vec<String> = baseline
.modules
.iter()
.filter(|slug| !desired.contains(slug))
.cloned()
.collect();
let selected_slugs = manifest.module_slugs();
let capabilities = capabilities_of(&modules_added, &selected_slugs, catalog);
let recorded_config: &BTreeMap<String, String> = match record {
Some(record) => &record.plan.state.config,
None => &BTreeMap::new(),
};
let (config_added, config_removed, config_changed) = config_deltas(manifest, recorded_config);
let mut selected: Vec<SelectedModule> = manifest
.modules
.iter()
.map(|module| SelectedModule {
slug: module.slug().to_owned(),
config: module.config(),
})
.collect();
selected.sort_unstable();
let migrations = migration_state(&desired, lock);
let mut cors_origins = manifest.cors_origins.clone();
cors_origins.sort_unstable();
let state = PlanState {
venture: manifest.name.clone(),
host: manifest.host.clone(),
public_url: manifest.public_url.clone(),
cors_origins,
env: resolved_env(manifest).as_str().to_owned(),
modules: desired,
selected,
config: manifest.config.clone(),
seed_sql: manifest.seed_sql.clone(),
migrations,
};
let content = PlanContent {
state,
modules_added,
modules_removed,
capabilities,
config_added,
config_removed,
config_changed,
};
(content, baseline)
}
fn plan_digest(content: &PlanContent) -> Result<String, Vec<DoctorFailure>> {
serde_json::to_vec(&content.state)
.map(|bytes| sha256_hex(&bytes))
.map_err(|err| {
vec![failure(
&CODES.manifest_invalid,
format!("the plan content does not serialize: {err}"),
)]
})
}
pub fn plan(
manifest_path: &Path,
migrations_dir: &Path,
) -> Result<PlanOutcome, Vec<DoctorFailure>> {
let inputs = load(manifest_path, migrations_dir)?;
let (content, baseline) = compute_plan(
&inputs.manifest,
&inputs.set,
&inputs.lock,
inputs.record.as_ref(),
&inputs.catalog,
);
let digest = plan_digest(&content)?;
Ok(PlanOutcome {
content,
digest,
baseline,
})
}
fn moved_since(current: &PlanState, recorded: &PlanState) -> Vec<String> {
let mut moved: Vec<String> = Vec::new();
if let Some(what) = diff_modules(¤t.modules, &recorded.modules) {
moved.push(format!("modules: {what}"));
}
if let Some(what) = diff_config(¤t.config, &recorded.config) {
moved.push(format!("config: {what}"));
}
if current.env != recorded.env {
moved.push(format!("environment: {} -> {}", recorded.env, current.env));
}
if current.host != recorded.host {
moved.push(format!("host: {} -> {}", recorded.host, current.host));
}
if current.public_url != recorded.public_url {
moved.push("public URL changed".to_owned());
}
if current.cors_origins != recorded.cors_origins {
moved.push("CORS origins changed".to_owned());
}
if current.seed_sql != recorded.seed_sql {
moved.push("seed SQL changed".to_owned());
}
if current.selected != recorded.selected {
moved.push("the module selection or its per-module config changed".to_owned());
}
moved
}
fn diff_modules(current: &[String], recorded: &[String]) -> Option<String> {
let added: Vec<&String> = current
.iter()
.filter(|slug| !recorded.contains(slug))
.collect();
let removed: Vec<&String> = recorded
.iter()
.filter(|slug| !current.contains(slug))
.collect();
if added.is_empty() && removed.is_empty() {
return None;
}
let mut parts: Vec<String> = Vec::new();
if !added.is_empty() {
parts.push(format!(
"added {}",
added
.iter()
.map(|slug| slug.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
if !removed.is_empty() {
parts.push(format!(
"removed {}",
removed
.iter()
.map(|slug| slug.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
Some(parts.join("; "))
}
fn diff_config(
current: &BTreeMap<String, String>,
recorded: &BTreeMap<String, String>,
) -> Option<String> {
let added: Vec<&String> = current
.keys()
.filter(|key| !recorded.contains_key(*key))
.collect();
let removed: Vec<&String> = recorded
.keys()
.filter(|key| !current.contains_key(*key))
.collect();
let changed: Vec<&String> = current
.iter()
.filter(|(key, value)| recorded.get(*key).is_some_and(|r| r != *value))
.map(|(key, _)| key)
.collect();
if added.is_empty() && removed.is_empty() && changed.is_empty() {
return None;
}
let mut parts: Vec<String> = Vec::new();
let list = |keys: &[&String]| {
keys.iter()
.map(|key| key.as_str())
.collect::<Vec<_>>()
.join(", ")
};
if !added.is_empty() {
parts.push(format!("added {}", list(&added)));
}
if !removed.is_empty() {
parts.push(format!("removed {}", list(&removed)));
}
if !changed.is_empty() {
parts.push(format!("changed {}", list(&changed)));
}
Some(parts.join("; "))
}
pub fn deploy(
approved: Option<&str>,
manifest_path: &Path,
migrations_dir: &Path,
consent: Consent,
) -> Result<DeployOutcome, Vec<DoctorFailure>> {
let Some(approved) = approved else {
return Err(vec![failure(
&CODES.deploy_plan_required,
"deploy requires an approved plan — run `fz plan --json` and pass its `digest` \
as --plan; there is no default",
)]);
};
let inputs = load(manifest_path, migrations_dir)?;
let (planned, _) = compute_plan(
&inputs.manifest,
&inputs.set,
&inputs.lock,
inputs.record.as_ref(),
&inputs.catalog,
);
let digest = plan_digest(&planned)?;
let mut failures: Vec<DoctorFailure> = Vec::new();
if approved != digest {
failures.push(failure(
&CODES.stale_plan,
stale_plan_message(&planned, inputs.record.as_ref(), approved, &digest),
));
}
if resolved_env(&inputs.manifest) == VentureEnv::Production && !consent.production {
failures.push(failure(
&CODES.production_deploy_unauthorized,
"this venture resolves to production (manifest config ENV=production) — deploying \
needs a second, explicit consent: pass --i-am-deploying-to-production alongside \
the approved digest",
));
}
for (key, entry) in &inputs.lock {
if let Err(err) = crate::collect::verify_locked(migrations_dir, key, entry) {
let code = match &err {
crate::collect::LockedMigrationError::Missing { .. } => {
&CODES.locked_migration_missing
}
crate::collect::LockedMigrationError::Edited { .. } => {
&CODES.locked_migration_edited
}
};
failures.push(failure(code, err.to_string()));
}
}
if !planned.modules_removed.is_empty() && !consent.removal {
let orphans = &planned.state.migrations.orphaned;
let leftovers = if orphans.is_empty() {
String::new()
} else {
format!(
" The migrations {} stay applied and the tables they created keep their rows; \
dropping them is a separate, manual step (docs/ROLLBACK.md).",
orphans.join(", ")
)
};
failures.push(failure(
&CODES.destructive_change_unauthorized,
format!(
"this plan removes {} from the served composition: those modules stop being \
served, and no data is deleted.{leftovers} Pass --i-am-removing-modules to \
accept that",
planned.modules_removed.join(", ")
),
));
}
if !failures.is_empty() {
return Err(failures);
}
let record_path = record_path(manifest_path);
if inputs
.record
.as_ref()
.is_some_and(|record| record.digest == digest)
{
return Ok(DeployOutcome {
content: planned,
digest,
changed: false,
record_path,
});
}
let record = DeployRecord {
schema: SCHEMA,
digest: digest.clone(),
plan: planned.clone(),
};
let record_path = write_record(manifest_path, &record)
.map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
Ok(DeployOutcome {
content: planned,
digest,
changed: true,
record_path,
})
}
fn stale_plan_message(
planned: &PlanContent,
record: Option<&DeployRecord>,
approved: &str,
digest: &str,
) -> String {
let mut message = format!(
"the approved digest {approved} does not match a plan recomputed from the current \
inputs (the current digest is {digest})"
);
let Some(record) = record else {
let mut what: Vec<String> = Vec::new();
if !planned.modules_added.is_empty() {
what.push(format!("add {}", planned.modules_added.join(", ")));
}
if !planned.modules_removed.is_empty() {
what.push(format!("remove {}", planned.modules_removed.join(", ")));
}
if what.is_empty() {
message
.push_str("; no deployment is recorded yet, so there is nothing to diff against");
} else {
message.push_str("; no deployment is recorded yet and the current plan would ");
message.push_str(&what.join(" and "));
}
return message;
};
let moved = moved_since(&planned.state, &record.plan.state);
if moved.is_empty() {
message.push_str(
"; the recorded deployment matches every plan field, so the approved digest was \
computed by a different fz or an older plan format",
);
} else {
message.push_str("; since the recorded deployment: ");
message.push_str(&moved.join("; "));
}
message
}
pub fn add(module: &str, manifest_path: &Path) -> Result<AddOutcome, Vec<DoctorFailure>> {
let raw = std::fs::read_to_string(manifest_path).map_err(|err| {
vec![failure(
&CODES.manifest_unreadable,
format!("cannot read {}: {err}", manifest_path.display()),
)]
})?;
let mut manifest = crate::build::parse(manifest_path, &raw).map_err(|err| {
vec![failure(
&CODES.manifest_unreadable,
format!("{}: {err}", manifest_path.display()),
)]
})?;
let catalog = builtin();
manifest
.resolve(&catalog)
.map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
if manifest.module_slugs().contains(&module) {
return Ok(AddOutcome {
changed: false,
module: module.to_owned(),
manifest: manifest_path.to_path_buf(),
});
}
let known: Vec<&str> = catalog.modules.iter().map(|m| m.slug.as_str()).collect();
if !known.contains(&module) {
return Err(vec![failure(
&CODES.module_unknown,
format!(
"`{module}` is not a module in the catalog; available: {}",
known.join(", ")
),
)]);
}
manifest
.modules
.push(cratefield_manifest::ModuleRef::Slug(module.to_owned()));
manifest
.resolve(&catalog)
.map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
write_manifest(manifest_path, &manifest)
.map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
Ok(AddOutcome {
changed: true,
module: module.to_owned(),
manifest: manifest_path.to_path_buf(),
})
}
pub fn init(
name: &str,
host: &str,
manifest_path: &Path,
force: bool,
) -> Result<PathBuf, Vec<DoctorFailure>> {
if manifest_path.exists() && !force {
return Err(vec![failure(
&CODES.manifest_exists,
format!(
"{} already exists — init refuses to overwrite a manifest; pass --force to \
replace it",
manifest_path.display()
),
)]);
}
let manifest = VentureManifest {
name: name.to_owned(),
host: host.to_owned(),
public_url: Some(format!("https://{host}")),
cors_origins: vec![format!("https://{host}")],
modules: Vec::new(),
config: BTreeMap::new(),
seed_sql: None,
..Default::default()
};
manifest
.validate()
.map_err(|err| vec![failure(&CODES.manifest_invalid, err.to_string())])?;
write_manifest(manifest_path, &manifest)
.map_err(|err| vec![failure(&CODES.manifest_write_failed, err)])?;
Ok(manifest_path.to_path_buf())
}
#[must_use]
pub fn verify(manifest_path: &Path, migrations_dir: &Path) -> VerifyOutcome {
let inputs = match load(manifest_path, migrations_dir) {
Ok(inputs) => inputs,
Err(failures) => {
return VerifyOutcome {
report: DoctorReport { failures },
digest: None,
};
}
};
let (content, _) = compute_plan(
&inputs.manifest,
&inputs.set,
&inputs.lock,
inputs.record.as_ref(),
&inputs.catalog,
);
let mut failures: Vec<DoctorFailure> = Vec::new();
let digest = match plan_digest(&content) {
Ok(digest) => digest,
Err(new_failures) => {
return VerifyOutcome {
report: DoctorReport {
failures: new_failures,
},
digest: None,
};
}
};
let Some(record) = &inputs.record else {
failures.push(failure(
&CODES.not_deployed,
"no deployment is recorded for this manifest — run `fz plan --json`, approve the \
digest, then `fz deploy --plan <digest>`",
));
return VerifyOutcome {
report: DoctorReport { failures },
digest: Some(digest),
};
};
if let Some(what) = diff_modules(&content.state.modules, &record.plan.state.modules) {
failures.push(failure(
&CODES.composition_drift,
format!("the recorded deployment's module set drifted from the manifest: {what}"),
));
}
if let Some(what) = diff_config(&content.state.config, &record.plan.state.config) {
failures.push(failure(
&CODES.config_drift,
format!("the recorded deployment's config drifted from the manifest: {what}"),
));
}
if content.state.env != record.plan.state.env {
failures.push(failure(
&CODES.env_drift,
format!(
"the deployment environment drifted: recorded {}, manifest resolves to {}",
record.plan.state.env, content.state.env
),
));
}
let narrow = failures.len();
if record.digest != digest {
let moved = moved_since(&content.state, &record.plan.state);
let message = if moved.is_empty() {
format!(
"the recorded digest does not match a plan recomputed from the manifest, and \
no module, config or environment drift explains it — the venture's host, \
public URL, CORS origins, seed SQL or per-module config changed since the \
deployment was recorded (recorded {}, current {digest})",
record.digest
)
} else {
format!(
"the recorded digest does not match a plan recomputed from the manifest \
(recorded {}, current {digest}): {}",
record.digest,
moved.join("; ")
)
};
if failures.len() == narrow {
failures.push(failure(&CODES.manifest_drift, message));
}
}
VerifyOutcome {
report: DoctorReport { failures },
digest: Some(digest),
}
}
#[derive(Serialize)]
struct FailRef<'a> {
code: &'a str,
message: &'a str,
}
#[derive(Serialize)]
struct BarePayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailRef<'a>>,
}
#[derive(Serialize)]
struct PlanPayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailRef<'a>>,
digest: &'a str,
#[serde(flatten)]
content: &'a PlanContent,
baseline: &'a Baseline,
}
#[derive(Serialize)]
struct DeployPayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailRef<'a>>,
changed: bool,
digest: &'a str,
venture: &'a str,
env: &'a str,
modules_added: &'a [String],
modules_removed: &'a [String],
record: String,
}
#[derive(Serialize)]
struct AddPayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailRef<'a>>,
changed: bool,
module: &'a str,
manifest: String,
}
#[derive(Serialize)]
struct InitPayload<'a> {
schema: u32,
ok: bool,
failures: Vec<FailRef<'a>>,
name: &'a str,
host: &'a str,
manifest: String,
}
fn refs(failures: &[DoctorFailure]) -> Vec<FailRef<'_>> {
failures
.iter()
.map(|failure| FailRef {
code: failure.code.code,
message: &failure.message,
})
.collect()
}
fn emit(payload: &impl Serialize, exit: ExitCode) -> ExitCode {
match serde_json::to_string(payload) {
Ok(line) => {
println!("{line}");
exit
}
Err(err) => {
eprintln!("fz: cannot serialize the report: {err}");
ExitCode::FAILURE
}
}
}
fn fail(failures: &[DoctorFailure], json: bool) -> ExitCode {
if json {
let payload = BarePayload {
schema: SCHEMA,
ok: false,
failures: refs(failures),
};
return emit(&payload, ExitCode::FAILURE);
}
eprintln!("fz: {}", joined(failures));
ExitCode::FAILURE
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn plan_cli(
manifest_path: &Path,
migrations_dir: &Path,
json: bool,
non_interactive: bool,
) -> ExitCode {
match plan(manifest_path, migrations_dir) {
Ok(outcome) => {
if json {
let payload = PlanPayload {
schema: SCHEMA,
ok: true,
failures: Vec::new(),
digest: &outcome.digest,
content: &outcome.content,
baseline: &outcome.baseline,
};
return emit(&payload, ExitCode::SUCCESS);
}
println!(
"fz plan: {} (env: {})",
outcome.content.state.venture, outcome.content.state.env
);
println!(" digest: {}", outcome.digest);
println!(
" baseline: {} ({})",
outcome.baseline.source,
or_none(&outcome.baseline.modules)
);
println!(" added: {}", or_none(&outcome.content.modules_added));
println!(
" removed: {}",
or_none(&outcome.content.modules_removed)
);
let mut config = Vec::new();
for key in &outcome.content.config_added {
config.push(format!("+{key}"));
}
for key in &outcome.content.config_removed {
config.push(format!("-{key}"));
}
for key in &outcome.content.config_changed {
config.push(format!("~{key}"));
}
println!(
" config: {}",
if config.is_empty() {
"(unchanged)".to_owned()
} else {
config.join(" ")
}
);
println!(
" migrations: not collected for {} / orphaned {}",
or_none(&outcome.content.state.migrations.not_collected),
or_none(&outcome.content.state.migrations.orphaned)
);
for capability in &outcome.content.capabilities {
println!(
" needs: {} ({} module, requires {}, {})",
capability.slug,
capability.tier,
or_none(&capability.requires),
capability.via
);
}
if !non_interactive {
println!();
println!("next:");
println!(
" fz deploy --plan {} # apply exactly this plan",
outcome.digest
);
}
ExitCode::SUCCESS
}
Err(failures) => fail(&failures, json),
}
}
fn or_none(items: &[String]) -> String {
if items.is_empty() {
"(none)".to_owned()
} else {
items.join(", ")
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn deploy_cli(
approved: Option<&str>,
manifest_path: &Path,
migrations_dir: &Path,
consent: Consent,
json: bool,
non_interactive: bool,
) -> ExitCode {
match deploy(approved, manifest_path, migrations_dir, consent) {
Ok(outcome) => {
if json {
let payload = DeployPayload {
schema: SCHEMA,
ok: true,
failures: Vec::new(),
changed: outcome.changed,
digest: &outcome.digest,
venture: &outcome.content.state.venture,
env: &outcome.content.state.env,
modules_added: &outcome.content.modules_added,
modules_removed: &outcome.content.modules_removed,
record: outcome.record_path.display().to_string(),
};
return emit(&payload, ExitCode::SUCCESS);
}
if !outcome.changed {
println!(
"fz deploy: nothing to do — plan {} is already the recorded deployment",
outcome.digest
);
return ExitCode::SUCCESS;
}
println!(
"fz deploy: {} (env: {})",
outcome.content.state.venture, outcome.content.state.env
);
println!(" digest: {}", outcome.digest);
println!(" added: {}", or_none(&outcome.content.modules_added));
println!(
" removed: {}",
or_none(&outcome.content.modules_removed)
);
println!(" record: {}", outcome.record_path.display());
if !non_interactive {
println!();
println!("next (needs a human or credentials fz does not hold):");
println!(
" fz migrations collect # materialise the wrangler migration files"
);
println!(" fz doctor # harness validity, production rules");
println!(" worker-build --release # compile the venture to wasm");
println!(" wrangler deploy # stand up the Worker + D1");
}
ExitCode::SUCCESS
}
Err(failures) => fail(&failures, json),
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn add_cli(module: &str, manifest_path: &Path, json: bool, non_interactive: bool) -> ExitCode {
match add(module, manifest_path) {
Ok(outcome) => {
if json {
let payload = AddPayload {
schema: SCHEMA,
ok: true,
failures: Vec::new(),
changed: outcome.changed,
module: &outcome.module,
manifest: outcome.manifest.display().to_string(),
};
return emit(&payload, ExitCode::SUCCESS);
}
if outcome.changed {
println!(
"fz add: added `{}` to {} — the desired composition only; nothing was \
deployed, provisioned or migrated",
outcome.module,
outcome.manifest.display()
);
if !non_interactive {
println!();
println!("next:");
println!(" fz plan --json # see what this would change and get its digest");
}
} else {
println!(
"fz add: `{}` is already in {} — nothing changed",
outcome.module,
outcome.manifest.display()
);
}
ExitCode::SUCCESS
}
Err(failures) => fail(&failures, json),
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn init_cli(
name: &str,
host: &str,
manifest_path: &Path,
force: bool,
json: bool,
non_interactive: bool,
) -> ExitCode {
match init(name, host, manifest_path, force) {
Ok(path) => {
if json {
let payload = InitPayload {
schema: SCHEMA,
ok: true,
failures: Vec::new(),
name,
host,
manifest: path.display().to_string(),
};
return emit(&payload, ExitCode::SUCCESS);
}
println!(
"fz init: wrote {} (venture {name}, host {host}, no modules yet)",
path.display()
);
if !non_interactive {
println!();
println!("next:");
println!(
" fz add <module> # grow the desired composition, one module at a time"
);
}
ExitCode::SUCCESS
}
Err(failures) => fail(&failures, json),
}
}
#[must_use = "call process::exit with the returned ExitCode"]
pub fn verify_cli(manifest_path: &Path, migrations_dir: &Path, json: bool) -> ExitCode {
let outcome = verify(manifest_path, migrations_dir);
if json {
match outcome.report.render_json() {
Ok(payload) => {
println!("{payload}");
return if outcome.report.ok() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
Err(err) => {
eprintln!("fz: cannot serialize the report: {err}");
return ExitCode::FAILURE;
}
}
}
if outcome.report.ok() {
match outcome.digest {
Some(digest) => println!(
"fz verify: the recorded deployment matches the manifest (digest {digest})"
),
None => println!("fz verify: no problems found"),
}
ExitCode::SUCCESS
} else {
eprintln!("fz: {}", joined(&outcome.report.failures));
ExitCode::FAILURE
}
}
pub(crate) fn dispatch(command: &crate::Command) -> Option<ExitCode> {
match command {
crate::Command::Plan {
manifest,
migrations,
json,
non_interactive,
} => Some(plan_cli(manifest, migrations, *json, *non_interactive)),
crate::Command::Deploy {
plan,
manifest,
migrations,
i_am_deploying_to_production,
i_am_removing_modules,
json,
non_interactive,
} => Some(deploy_cli(
plan.as_deref(),
manifest,
migrations,
Consent {
production: *i_am_deploying_to_production,
removal: *i_am_removing_modules,
},
*json,
*non_interactive,
)),
crate::Command::Add {
module,
manifest,
json,
non_interactive,
} => Some(add_cli(module, manifest, *json, *non_interactive)),
crate::Command::Init {
name,
host,
manifest,
force,
json,
non_interactive,
} => Some(init_cli(
name,
host,
manifest,
*force,
*json,
*non_interactive,
)),
crate::Command::Verify {
manifest,
migrations,
json,
..
} => Some(verify_cli(manifest, migrations, *json)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{compute_plan, init, plan_digest, resolved_env};
use crate::lock::Lock;
use cratefield_core::VentureEnv;
use cratefield_manifest::{ModuleRef, VentureManifest, builtin};
use std::collections::BTreeMap;
fn manifest(modules: &[&str], config: &[(&str, &str)]) -> VentureManifest {
VentureManifest {
name: "acme".to_owned(),
host: "acme.factory0.dev".to_owned(),
public_url: None,
cors_origins: vec!["https://acme.example".to_owned()],
modules: modules
.iter()
.map(|slug| ModuleRef::Slug((*slug).to_owned()))
.collect(),
config: config
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect::<BTreeMap<_, _>>(),
seed_sql: None,
..Default::default()
}
}
#[test]
fn env_reads_the_manifest_config_key() {
assert_eq!(resolved_env(&manifest(&[], &[])), VentureEnv::Development);
assert_eq!(
resolved_env(&manifest(&[], &[("ENV", "production")])),
VentureEnv::Production
);
assert_eq!(
resolved_env(&manifest(&[], &[("ENV", "nonsense")])),
VentureEnv::Development,
"unrecognised values fall back to development"
);
}
#[test]
fn digest_is_stable_and_input_sensitive() {
let catalog = builtin();
let first = manifest(&["waitlist"], &[]);
let set = first.resolve(&catalog).expect("resolves");
let (content, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
let (again, _) = compute_plan(&first, &set, &Lock::default(), None, &catalog);
let digest = plan_digest(&content).expect("digests");
assert_eq!(digest, plan_digest(&again).expect("digests"));
let changed = manifest(&["waitlist"], &[("ENV", "production")]);
let changed_set = changed.resolve(&catalog).expect("resolves");
let (changed_content, _) =
compute_plan(&changed, &changed_set, &Lock::default(), None, &catalog);
assert_ne!(
digest,
plan_digest(&changed_content).expect("digests"),
"a config change must move the digest"
);
assert_eq!(changed_content.state.env, "production");
}
#[test]
fn digest_is_order_insensitive_over_the_selection() {
let catalog = builtin();
let a = manifest(&["waitlist", "email-signup"], &[]);
let b = manifest(&["email-signup", "waitlist"], &[]);
let set_a = a.resolve(&catalog).expect("resolves");
let set_b = b.resolve(&catalog).expect("resolves");
let (content_a, _) = compute_plan(&a, &set_a, &Lock::default(), None, &catalog);
let (content_b, _) = compute_plan(&b, &set_b, &Lock::default(), None, &catalog);
assert_eq!(
plan_digest(&content_a).expect("digests"),
plan_digest(&content_b).expect("digests")
);
}
#[test]
fn init_scaffolds_a_manifest_the_rest_of_the_toolchain_accepts() {
let dir = cratefield_testing::TempDir::new("fz-init");
let path = dir.path().join("venture.json");
init("acme-signups", "acme.factory0.dev", &path, false).expect("init writes one");
let written = std::fs::read_to_string(&path).expect("it is there");
let manifest = VentureManifest::from_json_str(&written).expect("it parses");
manifest.validate().expect("and it is one fz build accepts");
assert_eq!(manifest.cors_origins, ["https://acme.factory0.dev"]);
assert_eq!(
manifest.public_url.as_deref(),
Some("https://acme.factory0.dev")
);
}
#[test]
fn init_refuses_a_name_that_is_not_one_and_writes_nothing() {
let dir = cratefield_testing::TempDir::new("fz-init");
let path = dir.path().join("venture.json");
let failures = init("My Venture", "acme.factory0.dev", &path, false)
.expect_err("a venture name is a slug");
assert!(
failures
.iter()
.any(|failure| failure.message.contains("name")),
"{failures:?}"
);
assert!(!path.exists(), "a manifest that cannot build was written");
}
}