Skip to main content

bynk/deploy/
ledger.rs

1use super::*;
2
3#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
4pub(crate) struct DeployLock {
5    // No serde `default`: a ledger with no `version` is not a fresh project, it
6    // is corruption (a truncated write), and must fail the read rather than
7    // parse as an empty v1 ledger that re-mints every namespace (#736).
8    version: u32,
9    #[serde(default)]
10    pub(crate) environments: BTreeMap<String, Environment>,
11}
12
13fn lock_version() -> u32 {
14    1
15}
16
17#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
18pub(crate) struct Environment {
19    #[serde(default)]
20    pub(crate) kv: BTreeMap<String, KvNamespace>,
21    /// Slice 2: which Workers this project has ever pushed. Additive and
22    /// `default`ed, so a slice-0 ledger still reads.
23    ///
24    /// KV state alone could not answer "does this Worker exist on the account?"
25    /// — a context with no KV has no `kv` entry at all — and `--context` must
26    /// know, because a Service Binding to an absent Worker fails at upload.
27    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28    workers: BTreeMap<String, WorkerRecord>,
29    /// Slice 1: the queue names this project has created at least once.
30    ///
31    /// Environment-wide rather than per-worker, because a queue is an account
32    /// resource addressed by name, not something a Worker owns — two contexts
33    /// consuming `"jobs"` mean the same queue.
34    ///
35    /// **Authoritative for nothing** (ADR 0194 D2). It exists so the plan can
36    /// say `create` or `reuse` without a `wrangler queues list` call; the
37    /// provision step attempts the create regardless, so a queue deleted
38    /// out-of-band comes back rather than being skipped on this set's word.
39    /// Additive and `default`ed, so a slice-0 or slice-2 ledger still reads.
40    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
41    queues: BTreeSet<String>,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
45pub(crate) struct KvNamespace {
46    pub(crate) id: String,
47}
48
49/// What the ledger remembers about one pushed Worker. A struct rather than a
50/// bare bool so slice 3's secrets have somewhere to land.
51#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
52struct WorkerRecord {
53    deployed: bool,
54    /// v0.177 (#643): the contract hash this Worker *provides* per `on call`
55    /// service, as of the push that recorded it — what is **live**.
56    ///
57    /// This is what makes a skew visible before a request finds it: a later
58    /// `deploy --context A` compares A's compiled `expects` against these, and
59    /// refuses rather than shipping a caller that will 409 in production.
60    ///
61    /// `None` means **no record** — a Worker pushed by a pre-v0.177 driver, which
62    /// has nothing to say about contracts either way. `Some({})` means the
63    /// Worker is *known* to provide no `on call` service at all.
64    ///
65    /// The distinction is load-bearing, and an empty map cannot carry it: a
66    /// callee that removes **all** its services emits no manifest, so a
67    /// bare-`BTreeMap` field would record `{}` — indistinguishable from "old
68    /// ledger" — and the gate's `continue` would let a total, real skew through.
69    /// `Option` keeps "silence is not a match" while still catching removal.
70    ///
71    /// Additive and `default`ed, so a pre-v0.177 ledger still reads.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    contracts: Option<BTreeMap<String, String>>,
74}
75
76impl DeployLock {
77    pub(crate) fn is_deployed(&self, environment: &str, worker: &str) -> bool {
78        self.environments
79            .get(environment)
80            .and_then(|env| env.workers.get(worker))
81            .is_some_and(|record| record.deployed)
82    }
83
84    pub(crate) fn record_deployed(
85        &mut self,
86        environment: &str,
87        worker: &str,
88        contracts: Option<BTreeMap<String, String>>,
89    ) {
90        self.environments
91            .entry(environment.to_string())
92            .or_default()
93            .workers
94            .insert(
95                worker.to_string(),
96                WorkerRecord {
97                    deployed: true,
98                    contracts,
99                },
100            );
101    }
102
103    /// v0.177 (#643): what the ledger believes `worker` currently provides.
104    ///
105    /// `None` for both "never deployed" and "deployed before contracts were
106    /// recorded" — in each case the ledger cannot speak, and the gate must not
107    /// invent an answer.
108    pub(crate) fn live_contracts(
109        &self,
110        environment: &str,
111        worker: &str,
112    ) -> Option<&BTreeMap<String, String>> {
113        self.environments
114            .get(environment)
115            .and_then(|env| env.workers.get(worker))
116            .and_then(|record| record.contracts.as_ref())
117    }
118
119    pub(crate) fn has_queue(&self, environment: &str, queue: &str) -> bool {
120        self.environments
121            .get(environment)
122            .is_some_and(|env| env.queues.contains(queue))
123    }
124
125    /// Note that this project has created `queue`. Returns whether the ledger
126    /// changed, so a re-run that provisions nothing also writes nothing.
127    pub(crate) fn record_queue(&mut self, environment: &str, queue: &str) -> bool {
128        self.environments
129            .entry(environment.to_string())
130            .or_default()
131            .queues
132            .insert(queue.to_string())
133    }
134}
135
136/// Slice 5: every ledger entry for this environment that the current build no
137/// longer declares. Owned, not borrowed — ledger-derived names don't share
138/// `Plan<'a>`'s lifetime over `order`/`resources` (the `PlanSecret.name:
139/// String` precedent below).
140///
141/// `kv` and `workers` are independent checks, deliberately not merged: both
142/// are keyed by worker name in the ledger (`Environment`, above), so a
143/// context removed from source that had KV is reported as **two** orphans,
144/// one per map, not one combined line. `--prune` (when it lands) treats them
145/// independently too — the `kv` line is prunable, the `workers` line is
146/// report-only (a whole-Worker delete is a materially larger blast radius
147/// than a namespace or a queue, and out of this slice's scope).
148#[derive(Debug, Default, PartialEq, Eq, Serialize)]
149pub(crate) struct Orphans {
150    pub(crate) kv: Vec<String>,
151    pub(crate) workers: Vec<String>,
152    pub(crate) queues: Vec<String>,
153}
154
155impl Orphans {
156    /// Whether `--prune` would actually delete anything. **Not** the same as
157    /// checking whether every field is empty (review, #840): `workers`
158    /// orphans are report-only (DECISION C — never `wrangler delete`), so a
159    /// project whose only orphan is an unprunable Worker must not trigger
160    /// `confirm_prune`'s prompt at all — `Delete 0 resource(s)?` is a bug,
161    /// not a valid state.
162    pub(crate) fn has_prunable(&self) -> bool {
163        !self.kv.is_empty() || !self.queues.is_empty()
164    }
165}
166
167/// The orphan diff: ledger vs. the current build's full declared resource
168/// set, regardless of `--context` — `resources` already spans the *whole*
169/// project (`project_resources`, called from `run()` over `available` — every
170/// worker `workers::discover_workers` found — not the `--context`-narrowed
171/// `order`/`selected`), so `resources.keys()` alone is the full live-worker
172/// set and this reuses data `run()` already has rather than reading anything
173/// new. No live Cloudflare call: the report half of reconciliation costs
174/// nothing and needs no auth, keeping `--dry-run`'s "never authenticates"
175/// promise intact.
176///
177/// Pure, so the diff — including the shared-queue case (a queue two contexts
178/// still consume must never appear orphaned because a *third* context that
179/// used to consume it was removed) — is tested without a build tree.
180pub(crate) fn find_orphans(
181    lock: &DeployLock,
182    environment: &str,
183    resources: &BTreeMap<String, Resources>,
184) -> Orphans {
185    let Some(env) = lock.environments.get(environment) else {
186        return Orphans::default();
187    };
188    let live_queues: BTreeSet<&str> = resources
189        .values()
190        .flat_map(|r| r.queues.iter().map(String::as_str))
191        .collect();
192    Orphans {
193        kv: env
194            .kv
195            .keys()
196            .filter(|worker| !resources.contains_key(worker.as_str()))
197            .cloned()
198            .collect(),
199        workers: env
200            .workers
201            .keys()
202            .filter(|worker| !resources.contains_key(worker.as_str()))
203            .cloned()
204            .collect(),
205        queues: env
206            .queues
207            .iter()
208            .filter(|queue| !live_queues.contains(queue.as_str()))
209            .cloned()
210            .collect(),
211    }
212}
213
214pub(crate) fn recorded_kv<'a>(
215    lock: &'a DeployLock,
216    worker: &str,
217    environment: &str,
218) -> Option<&'a str> {
219    lock.environments
220        .get(environment)
221        .and_then(|env| env.kv.get(worker))
222        .map(|kv| kv.id.as_str())
223}
224
225/// Slice 5, DECISION B, extracted for direct testing (review, #840): should
226/// `deploy_one` trust a recorded KV id, or treat it as though nothing were
227/// recorded at all?
228///
229/// - No record at all → never trust (nothing to trust).
230/// - A record, and the live fetch never ran or failed (`None`) → trust it,
231///   unconditionally — exactly pre-slice-5 behaviour, so a fetch outage
232///   never blocks a deploy that would have succeeded before this slice.
233/// - A record, and a live id set — trust it only if the id is actually in
234///   that set. Absent means Cloudflare no longer recognises it: re-provision,
235///   the same as an unrecorded id would.
236pub(crate) fn should_trust_recorded_kv(
237    recorded: Option<&str>,
238    live_kv_ids: Option<&BTreeSet<String>>,
239) -> bool {
240    match (recorded, live_kv_ids) {
241        (Some(id), Some(live)) => live.contains(id),
242        (Some(_), None) => true,
243        (None, _) => false,
244    }
245}
246
247pub(crate) fn read_lock(path: &Path) -> Result<DeployLock, String> {
248    if !path.exists() {
249        return Ok(DeployLock {
250            version: lock_version(),
251            ..Default::default()
252        });
253    }
254    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
255    // A zero-byte or whitespace-only ledger is a truncated write, not an empty
256    // project. Accepting it as an empty v1 ledger would tell the planner that no
257    // namespaces exist and re-mint every one — the exact orphaning the ledger
258    // exists to prevent (#736, ADR 0180). Fail hard so the operator restores it.
259    if text.trim().is_empty() {
260        return Err(format!(
261            "deploy ledger `{}` is empty or truncated (corrupt); refusing to \
262             treat it as a fresh project — restore it from version control",
263            path.display()
264        ));
265    }
266    // A file that does not parse — including one truncated mid-table or missing
267    // its now-required `version` — is corruption too, and gets the same
268    // restore-it guidance rather than a bare toml diagnostic. A version we simply
269    // do not support is a distinct case (a newer or older format), not corruption.
270    let lock: DeployLock = toml::from_str(&text).map_err(|e| {
271        format!(
272            "deploy ledger `{}` is corrupt ({e}) — restore it from version control",
273            path.display()
274        )
275    })?;
276    if lock.version != lock_version() {
277        return Err(format!("unsupported deploy lock version {}", lock.version));
278    }
279    Ok(lock)
280}
281
282/// Distinguishes concurrent temp ledgers written by the same process.
283static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
284
285pub(crate) fn write_lock(path: &Path, lock: &DeployLock) -> Result<(), String> {
286    let body = toml::to_string_pretty(lock).map_err(|e| e.to_string())?;
287    // Atomic, durable replace: write a sibling temp file, fsync it, then rename
288    // it over the ledger. A power loss or kill can then only leave the intact old
289    // file or the intact new one — never a truncated ledger that reads as empty
290    // (#736). Atomicity-for-readers (the rename) is not enough on its own: after
291    // a crash the rename can be journaled while the temp's data blocks are still
292    // only in the page cache, so we `sync_all` the data before the rename and
293    // fsync the directory after it to make the new name itself durable.
294    let dir = path
295        .parent()
296        .filter(|p| !p.as_os_str().is_empty())
297        .unwrap_or_else(|| Path::new("."));
298    let file_name = path
299        .file_name()
300        .and_then(|n| n.to_str())
301        .unwrap_or(LOCK_FILE);
302
303    // A per-process counter keeps the temp name unique within a process, and
304    // `create_new` makes the create exclusive — a stale temp from a prior crash
305    // or a pre-planted symlink at this path is refused rather than followed.
306    let (tmp, mut file) = loop {
307        let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
308        let candidate = dir.join(format!(".{file_name}.{}.{n}.tmp", std::process::id()));
309        match std::fs::OpenOptions::new()
310            .write(true)
311            .create_new(true)
312            .open(&candidate)
313        {
314            Ok(f) => break (candidate, f),
315            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
316            Err(e) => return Err(e.to_string()),
317        }
318    };
319
320    // From here on, any failure must remove the temp so a full disk or crash
321    // does not litter the project with `.bynk.deploy.lock.*.tmp` files.
322    let write_then_sync = file
323        .write_all(body.as_bytes())
324        .and_then(|()| file.sync_all());
325    if let Err(e) = write_then_sync {
326        let _ = std::fs::remove_file(&tmp);
327        return Err(e.to_string());
328    }
329    drop(file);
330
331    // Preserve the ledger's existing permissions across the replace.
332    if let Ok(meta) = std::fs::metadata(path) {
333        let _ = std::fs::set_permissions(&tmp, meta.permissions());
334    }
335
336    if let Err(e) = std::fs::rename(&tmp, path) {
337        let _ = std::fs::remove_file(&tmp);
338        return Err(e.to_string());
339    }
340    // Best-effort: make the rename itself durable. Directory fsync is a no-op or
341    // unsupported on some platforms, so a failure here is not fatal.
342    if let Ok(dir_file) = std::fs::File::open(dir) {
343        let _ = dir_file.sync_all();
344    }
345    Ok(())
346}
347
348/// Fill a generated worker configuration from the committed deploy ledger.
349/// This is shared with `bynk dev -- --remote`; local dev leaves placeholders
350/// alone because Miniflare does not read the Cloudflare namespace id.
351///
352/// `environment` (slice 4, #837 review): before `--env` existed every real
353/// deploy recorded into `"default"` regardless, so hardcoding it here always
354/// matched. A project deployed only under a non-default `--env` now has
355/// nothing under `"default"` — reading the wrong section would misreport a
356/// provisioned project as never deployed, so this reads whichever section
357/// `bynk dev --env NAME -- --remote` names (default `"default"`, unchanged).
358pub fn materialise_deploy_state(
359    project_root: &Path,
360    worker: &str,
361    config: &Path,
362    environment: &str,
363) -> Result<bool, String> {
364    // P7.4 (#1305): structural, not a substring search — closes R7.6/R8.20.
365    let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
366    if !bynk_emit::emitter::wrangler::wrangler_needs_kv_materialisation(&text)? {
367        return Ok(false);
368    }
369    let lock = read_lock(&project_root.join(LOCK_FILE))?;
370    let Some(id) = lock
371        .environments
372        .get(environment)
373        .and_then(|env| env.kv.get(worker))
374        .map(|namespace| namespace.id.as_str())
375    else {
376        return Err(format!(
377            "remote KV for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
378        ));
379    };
380    if materialise_kv_id(config, id) {
381        Ok(true)
382    } else {
383        Err("could not write generated configuration".into())
384    }
385}
386
387// ---------------------------------------------------------------------------
388// Slice 5: reconciliation — orphan pruning
389// ---------------------------------------------------------------------------
390
391/// Print exactly what `--prune` is about to delete, then ask once for the
392/// whole batch — the "strictly stronger gate" the track doc (§6) calls for on
393/// top of [`confirm`]'s creation gate. `--yes` alone does **not** imply this:
394/// a CI job that wants unattended pruning must pass `--yes` **and**
395/// `--prune` together, the same non-interactive-requires-`--yes` shape
396/// [`confirm`] already uses, just with its own prompt so a script that only
397/// meant to authorise *creation* cannot accidentally also authorise deletion.
398pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans) -> bool {
399    for kv in &orphans.kv {
400        eprintln!("bynk: will delete KV namespace for `{kv}`");
401    }
402    for queue in &orphans.queues {
403        eprintln!("bynk: will delete queue `{queue}`");
404    }
405    if yes {
406        return true;
407    }
408    if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
409        eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
410        return false;
411    }
412    let count = orphans.kv.len() + orphans.queues.len();
413    eprint!("Delete {count} resource(s)? [y/N] ");
414    let _ = io::stderr().flush();
415    let mut answer = String::new();
416    io::stdin().read_line(&mut answer).is_ok()
417        && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
418}
419
420/// Delete every KV and queue orphan `confirm_prune` just named — never a
421/// worker (DECISION C: `wrangler delete`'s blast radius, routes/domains/crons
422/// along with the script, is categorically larger than a namespace or a
423/// queue, and pruning a whole Worker is explicitly out of this slice).
424///
425/// The ledger entry is stripped whether the delete found something to remove
426/// or found it already gone (DECISION E) — both mean "this resource is not
427/// there", and treating only a clean delete as ledger-worthy would wedge a
428/// half-completed prune: crash between a successful Cloudflare delete and the
429/// ledger write, and the next run would re-report the same orphan and
430/// re-issue a delete Cloudflare now rejects as not-found.
431pub(crate) fn prune_orphans(
432    provenance: &Provenance,
433    project_root: &Path,
434    lock: &mut DeployLock,
435    lock_path: &Path,
436    environment: &str,
437    orphans: &Orphans,
438) -> Result<(), DeployFailure> {
439    for worker in &orphans.kv {
440        let Some(id) = lock
441            .environments
442            .get(environment)
443            .and_then(|env| env.kv.get(worker))
444            .map(|ns| ns.id.clone())
445        else {
446            continue;
447        };
448        delete_kv_namespace(provenance, &id, project_root).map_err(|e| {
449            DeployFailure::driver(format!(
450                "could not delete the orphaned KV namespace for `{worker}`: {e}"
451            ))
452        })?;
453        if let Some(env) = lock.environments.get_mut(environment) {
454            env.kv.remove(worker);
455        }
456        write_lock(lock_path, lock).map_err(|e| {
457            DeployFailure::driver(format!(
458                "deleted the orphaned KV namespace for `{worker}` but could not record it in {}: {e}",
459                lock_path.display()
460            ))
461        })?;
462    }
463    for queue in &orphans.queues {
464        let physical = env_qualify(environment, queue);
465        delete_queue(provenance, &physical, project_root).map_err(|e| {
466            DeployFailure::driver(format!(
467                "could not delete the orphaned queue `{physical}`: {e}"
468            ))
469        })?;
470        if let Some(env) = lock.environments.get_mut(environment) {
471            env.queues.remove(queue);
472        }
473        write_lock(lock_path, lock).map_err(|e| {
474            DeployFailure::driver(format!(
475                "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
476                lock_path.display()
477            ))
478        })?;
479    }
480    Ok(())
481}
482
483#[cfg(test)]
484pub(crate) mod tests {
485    use super::*;
486
487    fn names(v: &[&str]) -> Vec<String> {
488        v.iter().map(|s| s.to_string()).collect()
489    }
490
491    pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
492        let mut lock = DeployLock::default();
493        for worker in workers {
494            lock.record_deployed("default", worker, Some(Default::default()));
495        }
496        lock
497    }
498
499    /// Record `worker`'s KV namespace, as a real deploy does before it pushes.
500    pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
501        lock.environments
502            .entry("default".into())
503            .or_default()
504            .kv
505            .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
506        lock
507    }
508
509    /// Mark `queue` as one this project has already created.
510    pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
511        lock.record_queue("default", queue);
512        lock
513    }
514
515    fn scratch_lock_path(label: &str) -> std::path::PathBuf {
516        let unique = std::time::SystemTime::now()
517            .duration_since(std::time::UNIX_EPOCH)
518            .unwrap()
519            .as_nanos();
520        std::env::temp_dir().join(format!(
521            "bynk-{label}-{}-{unique}.deploy.lock",
522            std::process::id()
523        ))
524    }
525
526    fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
527        let unique = std::time::SystemTime::now()
528            .duration_since(std::time::UNIX_EPOCH)
529            .unwrap()
530            .as_nanos();
531        let dir =
532            std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
533        std::fs::create_dir_all(&dir).unwrap();
534        dir
535    }
536
537    fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
538        std::fs::read_dir(dir)
539            .unwrap()
540            .filter_map(|e| e.ok().map(|e| e.path()))
541            .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
542            .collect()
543    }
544
545    #[test]
546    fn lock_round_trip_is_environment_keyed() {
547        let lock = DeployLock {
548            version: 1,
549            environments: BTreeMap::from([(
550                "default".into(),
551                Environment {
552                    kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
553                    workers: BTreeMap::from([(
554                        "api".into(),
555                        WorkerRecord {
556                            deployed: true,
557                            contracts: Default::default(),
558                        },
559                    )]),
560                    queues: BTreeSet::from(["intake".to_string()]),
561                },
562            )]),
563        };
564        assert_eq!(
565            toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
566            lock
567        );
568    }
569
570    #[test]
571    fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
572        // Both tables are additive: a ledger committed before slice 2 (workers)
573        // or slice 1 (queues) must keep working, reporting nothing recorded
574        // rather than failing to parse. #600 D4: the version stays 1, so this
575        // is the whole migration story.
576        let lock: DeployLock = toml::from_str(
577            r#"
578            version = 1
579            [environments.default.kv.api]
580            id = "abc"
581        "#,
582        )
583        .expect("a slice-0 ledger must still parse");
584        assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
585        assert!(!lock.is_deployed("default", "api"));
586        assert!(!lock.has_queue("default", "intake"));
587    }
588
589    #[test]
590    fn the_queue_set_serialises_as_names_under_the_environment() {
591        // The committed shape is a documented surface — a reviewer reads this
592        // file in a diff. Queues are environment-wide names, not a per-worker
593        // table, and carry no id.
594        let mut lock = DeployLock {
595            version: 1,
596            ..Default::default()
597        };
598        lock.record_queue("default", "job-intake");
599        lock.record_queue("default", "job-retry");
600        let text = toml::to_string_pretty(&lock).unwrap();
601        assert!(
602            text.contains("[environments.default]") && text.contains("queues = ["),
603            "the queue set is environment-wide, not a per-worker table: {text}"
604        );
605        for queue in ["job-intake", "job-retry"] {
606            assert!(
607                text.contains(&format!("\"{queue}\"")),
608                "{queue} is recorded"
609            );
610        }
611        assert!(
612            !text.contains("id"),
613            "a queue is addressed by name — the ledger has no id to record: {text}"
614        );
615        assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
616    }
617
618    #[test]
619    fn an_empty_queue_set_is_not_written_at_all() {
620        // A project with no queues must not grow an empty `queues = []` line in
621        // a committed file for a slice it does not use.
622        let mut lock = DeployLock {
623            version: 1,
624            ..Default::default()
625        };
626        lock.record_deployed("default", "api", Some(Default::default()));
627        assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
628    }
629
630    #[test]
631    fn materialises_only_the_placeholder() {
632        // P7.4 (#1305): the real shape `emit_wrangler_toml` produces — a
633        // `[[kv_namespaces]]` stanza, not a bare root `id` key (the old
634        // fixture's shortcut, which only worked because the old
635        // implementation was a whole-file substring replace that didn't
636        // care where the placeholder sat).
637        let unique = std::time::SystemTime::now()
638            .duration_since(std::time::UNIX_EPOCH)
639            .unwrap()
640            .as_nanos();
641        let path =
642            std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
643        std::fs::write(
644            &path,
645            format!(
646                "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
647            ),
648        )
649        .unwrap();
650        assert!(materialise_kv_id(&path, "abc"));
651        let text = std::fs::read_to_string(&path).unwrap();
652        let parsed: toml::Table = text.parse().unwrap();
653        assert_eq!(
654            parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
655            Some("abc")
656        );
657        let _ = std::fs::remove_file(path);
658    }
659
660    #[test]
661    fn two_environments_do_not_cross_contaminate_the_ledger() {
662        let mut lock = DeployLock {
663            version: 1,
664            ..Default::default()
665        };
666        lock.record_deployed("staging", "api", None);
667        lock.record_queue("staging", "jobs");
668        lock.environments
669            .entry("staging".into())
670            .or_default()
671            .kv
672            .insert(
673                "api".into(),
674                KvNamespace {
675                    id: "kv-staging".into(),
676                },
677            );
678
679        // "default" was never touched — a `--env staging` run must not
680        // fabricate or leak into the section a plain `bynk deploy` reads.
681        assert!(!lock.is_deployed("default", "api"));
682        assert!(!lock.has_queue("default", "jobs"));
683        assert_eq!(recorded_kv(&lock, "api", "default"), None);
684
685        assert!(lock.is_deployed("staging", "api"));
686        assert!(lock.has_queue("staging", "jobs"));
687        assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
688    }
689
690    /// #837 review: `materialise_deploy_state` (shared with `bynk dev --
691    /// --remote`) hardcoded `"default"` even after `--env` shipped. Before
692    /// `--env` existed every real deploy recorded into `"default"`
693    /// regardless, so that always matched — but a project deployed *only*
694    /// under `bynk deploy --env staging` now has nothing under `"default"`,
695    /// and reading the wrong section misreports a provisioned project as
696    /// never deployed.
697    #[test]
698    fn materialise_deploy_state_reads_the_named_environment_not_default() {
699        let unique = std::time::SystemTime::now()
700            .duration_since(std::time::UNIX_EPOCH)
701            .unwrap()
702            .as_nanos();
703        let dir = std::env::temp_dir().join(format!(
704            "bynk-materialise-state-{}-{unique}",
705            std::process::id()
706        ));
707        std::fs::create_dir_all(&dir).unwrap();
708        let project_root = dir.clone();
709        let config = dir.join("wrangler.toml");
710        std::fs::write(
711            &config,
712            format!(
713                "[[kv_namespaces]]\nbinding = \"BYNK_KV\"\nid = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"\n"
714            ),
715        )
716        .unwrap();
717
718        // Provisioned under "staging" alone — the scenario the review named:
719        // a project that has never had a plain `bynk deploy` (no "default").
720        let mut lock = DeployLock {
721            version: 1,
722            ..Default::default()
723        };
724        lock.environments
725            .entry("staging".into())
726            .or_default()
727            .kv
728            .insert(
729                "api".into(),
730                KvNamespace {
731                    id: "kv-staging".into(),
732                },
733            );
734        std::fs::write(
735            project_root.join(LOCK_FILE),
736            toml::to_string_pretty(&lock).unwrap(),
737        )
738        .unwrap();
739
740        // Reading "default" (what this function did unconditionally before
741        // the fix) must fail helpfully, not silently mis-materialise.
742        let err = materialise_deploy_state(&project_root, "api", &config, "default")
743            .expect_err("nothing is recorded under \"default\" — this must not silently pass");
744        assert!(
745            err.contains("environment `default`"),
746            "the error should name which environment it looked under: {err}"
747        );
748
749        // Reading "staging" — the environment it was actually deployed under
750        // — must succeed and materialise that environment's id.
751        assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
752        let text = std::fs::read_to_string(&config).unwrap();
753        let parsed: toml::Table = text.parse().unwrap();
754        assert_eq!(
755            parsed["kv_namespaces"].as_array().unwrap()[0]["id"].as_str(),
756            Some("kv-staging")
757        );
758
759        let _ = std::fs::remove_dir_all(dir);
760    }
761
762    /// P7.4 (#1305): a project with no KV binding at all must skip the lock
763    /// lookup entirely and return `Ok(false)` — not error just because
764    /// nothing is recorded for it (there's nothing to materialise). No lock
765    /// file exists in this project root at all, proving the early exit
766    /// really does short-circuit before `read_lock` runs.
767    #[test]
768    fn materialise_deploy_state_is_a_no_op_without_a_kv_binding() {
769        let unique = std::time::SystemTime::now()
770            .duration_since(std::time::UNIX_EPOCH)
771            .unwrap()
772            .as_nanos();
773        let dir = std::env::temp_dir().join(format!(
774            "bynk-materialise-state-no-kv-{}-{unique}",
775            std::process::id()
776        ));
777        std::fs::create_dir_all(&dir).unwrap();
778        let config = dir.join("wrangler.toml");
779        std::fs::write(&config, "name = \"api\"\nmain = \"index.ts\"\n").unwrap();
780
781        assert_eq!(
782            materialise_deploy_state(&dir, "api", &config, "default"),
783            Ok(false)
784        );
785
786        let _ = std::fs::remove_dir_all(dir);
787    }
788
789    // ---- #839 slice 5: reconciliation maturity + orphan reporting -------
790
791    #[test]
792    fn a_removed_context_with_kv_is_two_orphans_not_one() {
793        // `kv` and `workers` are both keyed by worker name — a context that
794        // had KV and was deleted from source shows up in both maps, so the
795        // diff must report both, independently (DECISION A).
796        let mut lock = DeployLock {
797            version: 1,
798            ..Default::default()
799        };
800        lock.environments
801            .entry("default".into())
802            .or_default()
803            .kv
804            .insert(
805                "gone".into(),
806                KvNamespace {
807                    id: "kv-gone".into(),
808                },
809            );
810        lock.record_deployed("default", "gone", None);
811        // "still-here" is in the current build, "gone" is not.
812        let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
813
814        let orphans = find_orphans(&lock, "default", &resources);
815        assert_eq!(orphans.kv, names(&["gone"]));
816        assert_eq!(orphans.workers, names(&["gone"]));
817        assert!(orphans.queues.is_empty());
818    }
819
820    #[test]
821    fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
822        // The false-positive risk DECISION A's diff exists to avoid: a queue
823        // consumed by two contexts must not be reported orphaned just because
824        // a *third*, now-removed context used to consume it too.
825        let mut lock = DeployLock {
826            version: 1,
827            ..Default::default()
828        };
829        lock.record_queue("default", "jobs");
830        let resources = BTreeMap::from([
831            ("orders".into(), Resources::default().consumes(&["jobs"])),
832            ("billing".into(), Resources::default().consumes(&["jobs"])),
833        ]);
834
835        let orphans = find_orphans(&lock, "default", &resources);
836        assert!(
837            orphans.queues.is_empty(),
838            "jobs is still consumed by two live contexts: {orphans:?}"
839        );
840    }
841
842    #[test]
843    fn a_queue_no_context_declares_anymore_is_orphaned() {
844        let mut lock = DeployLock {
845            version: 1,
846            ..Default::default()
847        };
848        lock.record_queue("default", "stale-jobs");
849        let resources = BTreeMap::from([("orders".into(), Resources::default())]);
850
851        let orphans = find_orphans(&lock, "default", &resources);
852        assert_eq!(orphans.queues, names(&["stale-jobs"]));
853    }
854
855    #[test]
856    fn find_orphans_is_scoped_to_the_named_environment() {
857        // A "staging" orphan must never leak into "default"'s report, and
858        // vice versa — environments (slice 4) stay fully independent.
859        let mut lock = DeployLock {
860            version: 1,
861            ..Default::default()
862        };
863        lock.environments
864            .entry("staging".into())
865            .or_default()
866            .kv
867            .insert(
868                "gone".into(),
869                KvNamespace {
870                    id: "kv-staging-gone".into(),
871                },
872            );
873        let resources = BTreeMap::new();
874
875        assert!(
876            find_orphans(&lock, "default", &resources).kv.is_empty(),
877            "default"
878        );
879        assert_eq!(
880            find_orphans(&lock, "staging", &resources).kv,
881            names(&["gone"])
882        );
883    }
884
885    #[test]
886    fn an_absent_environment_has_no_orphans() {
887        let lock = DeployLock::default();
888        let orphans = find_orphans(&lock, "default", &BTreeMap::new());
889        assert_eq!(orphans, Orphans::default());
890    }
891
892    #[test]
893    fn has_prunable_ignores_worker_only_orphans() {
894        // The bug the review caught: a project whose only orphan is an
895        // unprunable Worker must not trigger confirm_prune's "Delete 0
896        // resource(s)?" prompt at all.
897        let worker_only = Orphans {
898            workers: names(&["gone"]),
899            ..Default::default()
900        };
901        assert!(!worker_only.has_prunable());
902
903        let with_kv = Orphans {
904            kv: names(&["gone"]),
905            ..Default::default()
906        };
907        assert!(with_kv.has_prunable());
908
909        let with_queue = Orphans {
910            queues: names(&["gone"]),
911            ..Default::default()
912        };
913        assert!(with_queue.has_prunable());
914
915        assert!(!Orphans::default().has_prunable());
916    }
917
918    #[test]
919    fn a_written_ledger_reads_back_identically() {
920        // The floor the atomic write must not disturb: a real ledger survives a
921        // write/read round-trip unchanged.
922        let path = scratch_lock_path("roundtrip");
923        let mut lock = DeployLock {
924            version: 1,
925            ..Default::default()
926        };
927        lock.environments
928            .entry("default".into())
929            .or_default()
930            .kv
931            .insert(
932                "api".into(),
933                KvNamespace {
934                    id: "kv-123".into(),
935                },
936            );
937        write_lock(&path, &lock).unwrap();
938        assert_eq!(read_lock(&path).unwrap(), lock);
939        let _ = std::fs::remove_file(path);
940    }
941
942    #[test]
943    fn an_empty_ledger_is_corruption_not_a_fresh_project() {
944        // #736: a truncated write leaves a zero-byte file. Reading it as an
945        // empty v1 ledger would re-mint every namespace, so it must fail hard —
946        // whereas a genuinely absent file is a fresh project and reads clean.
947        let path = scratch_lock_path("empty");
948        std::fs::write(&path, "").unwrap();
949        assert!(
950            read_lock(&path).is_err(),
951            "a zero-byte ledger must be rejected, not treated as no environments"
952        );
953        std::fs::write(&path, "   \n\t\n").unwrap();
954        assert!(
955            read_lock(&path).is_err(),
956            "a whitespace-only ledger is just as corrupt"
957        );
958        let _ = std::fs::remove_file(&path);
959        assert!(
960            read_lock(&path).is_ok(),
961            "an absent ledger is a fresh project, not corruption"
962        );
963    }
964
965    #[test]
966    fn a_ledger_without_a_version_is_rejected() {
967        // With the serde default gone, a file that parses but carries no
968        // `version` is corruption rather than a silent empty v1 ledger.
969        let path = scratch_lock_path("noversion");
970        std::fs::write(&path, "[environments]\n").unwrap();
971        assert!(read_lock(&path).is_err());
972        let _ = std::fs::remove_file(path);
973    }
974
975    #[test]
976    fn a_successful_write_leaves_no_temp_litter() {
977        // The atomic write renames its temp over the ledger; nothing sibling to
978        // the ledger may survive the write.
979        let dir = scratch_lock_dir("nolitter");
980        let path = dir.join(LOCK_FILE);
981        let lock = DeployLock {
982            version: 1,
983            ..Default::default()
984        };
985        write_lock(&path, &lock).unwrap();
986        write_lock(&path, &lock).unwrap(); // over an existing ledger, too
987        assert!(
988            temp_litter(&dir).is_empty(),
989            "no `.tmp` sibling may outlive the rename"
990        );
991        assert_eq!(read_lock(&path).unwrap(), lock);
992        let _ = std::fs::remove_dir_all(dir);
993    }
994
995    #[cfg(unix)]
996    #[test]
997    fn a_rewrite_preserves_the_ledger_permissions() {
998        use std::os::unix::fs::PermissionsExt;
999        // A committed ledger's mode must survive a rewrite, or the atomic replace
1000        // would silently loosen or tighten it via the temp file's fresh mode.
1001        let dir = scratch_lock_dir("perms");
1002        let path = dir.join(LOCK_FILE);
1003        let lock = DeployLock {
1004            version: 1,
1005            ..Default::default()
1006        };
1007        write_lock(&path, &lock).unwrap();
1008        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1009        write_lock(&path, &lock).unwrap();
1010        assert_eq!(
1011            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1012            0o600,
1013            "the rewrite must carry the ledger's own mode across the rename"
1014        );
1015        let _ = std::fs::remove_dir_all(dir);
1016    }
1017}