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    let text = std::fs::read_to_string(config).map_err(|e| e.to_string())?;
365    if !text.contains(KV_NAMESPACE_ID_PLACEHOLDER) {
366        return Ok(false);
367    }
368    let lock = read_lock(&project_root.join(LOCK_FILE))?;
369    let Some(id) = lock
370        .environments
371        .get(environment)
372        .and_then(|env| env.kv.get(worker))
373        .map(|namespace| namespace.id.as_str())
374    else {
375        return Err(format!(
376            "remote KV for `{worker}` has not been provisioned under environment `{environment}`; run `bynk deploy --env {environment}` first"
377        ));
378    };
379    if materialise_kv_id(config, id) {
380        Ok(true)
381    } else {
382        Err("could not write generated configuration".into())
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Slice 5: reconciliation — orphan pruning
388// ---------------------------------------------------------------------------
389
390/// Print exactly what `--prune` is about to delete, then ask once for the
391/// whole batch — the "strictly stronger gate" the track doc (§6) calls for on
392/// top of [`confirm`]'s creation gate. `--yes` alone does **not** imply this:
393/// a CI job that wants unattended pruning must pass `--yes` **and**
394/// `--prune` together, the same non-interactive-requires-`--yes` shape
395/// [`confirm`] already uses, just with its own prompt so a script that only
396/// meant to authorise *creation* cannot accidentally also authorise deletion.
397pub(crate) fn confirm_prune(yes: bool, orphans: &Orphans) -> bool {
398    for kv in &orphans.kv {
399        eprintln!("bynk: will delete KV namespace for `{kv}`");
400    }
401    for queue in &orphans.queues {
402        eprintln!("bynk: will delete queue `{queue}`");
403    }
404    if yes {
405        return true;
406    }
407    if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
408        eprintln!("bynk: refusing to prune in a non-interactive session without --yes");
409        return false;
410    }
411    let count = orphans.kv.len() + orphans.queues.len();
412    eprint!("Delete {count} resource(s)? [y/N] ");
413    let _ = io::stderr().flush();
414    let mut answer = String::new();
415    io::stdin().read_line(&mut answer).is_ok()
416        && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
417}
418
419/// Delete every KV and queue orphan `confirm_prune` just named — never a
420/// worker (DECISION C: `wrangler delete`'s blast radius, routes/domains/crons
421/// along with the script, is categorically larger than a namespace or a
422/// queue, and pruning a whole Worker is explicitly out of this slice).
423///
424/// The ledger entry is stripped whether the delete found something to remove
425/// or found it already gone (DECISION E) — both mean "this resource is not
426/// there", and treating only a clean delete as ledger-worthy would wedge a
427/// half-completed prune: crash between a successful Cloudflare delete and the
428/// ledger write, and the next run would re-report the same orphan and
429/// re-issue a delete Cloudflare now rejects as not-found.
430pub(crate) fn prune_orphans(
431    provenance: &Provenance,
432    project_root: &Path,
433    lock: &mut DeployLock,
434    lock_path: &Path,
435    environment: &str,
436    orphans: &Orphans,
437) -> Result<(), DeployFailure> {
438    for worker in &orphans.kv {
439        let Some(id) = lock
440            .environments
441            .get(environment)
442            .and_then(|env| env.kv.get(worker))
443            .map(|ns| ns.id.clone())
444        else {
445            continue;
446        };
447        delete_kv_namespace(provenance, &id, project_root).map_err(|e| {
448            DeployFailure::driver(format!(
449                "could not delete the orphaned KV namespace for `{worker}`: {e}"
450            ))
451        })?;
452        if let Some(env) = lock.environments.get_mut(environment) {
453            env.kv.remove(worker);
454        }
455        write_lock(lock_path, lock).map_err(|e| {
456            DeployFailure::driver(format!(
457                "deleted the orphaned KV namespace for `{worker}` but could not record it in {}: {e}",
458                lock_path.display()
459            ))
460        })?;
461    }
462    for queue in &orphans.queues {
463        let physical = env_qualify(environment, queue);
464        delete_queue(provenance, &physical, project_root).map_err(|e| {
465            DeployFailure::driver(format!(
466                "could not delete the orphaned queue `{physical}`: {e}"
467            ))
468        })?;
469        if let Some(env) = lock.environments.get_mut(environment) {
470            env.queues.remove(queue);
471        }
472        write_lock(lock_path, lock).map_err(|e| {
473            DeployFailure::driver(format!(
474                "deleted the orphaned queue `{physical}` but could not record it in {}: {e}",
475                lock_path.display()
476            ))
477        })?;
478    }
479    Ok(())
480}
481
482#[cfg(test)]
483pub(crate) mod tests {
484    use super::*;
485
486    fn names(v: &[&str]) -> Vec<String> {
487        v.iter().map(|s| s.to_string()).collect()
488    }
489
490    pub(crate) fn lock_with_deployed(workers: &[&str]) -> DeployLock {
491        let mut lock = DeployLock::default();
492        for worker in workers {
493            lock.record_deployed("default", worker, Some(Default::default()));
494        }
495        lock
496    }
497
498    /// Record `worker`'s KV namespace, as a real deploy does before it pushes.
499    pub(crate) fn with_kv(mut lock: DeployLock, worker: &str) -> DeployLock {
500        lock.environments
501            .entry("default".into())
502            .or_default()
503            .kv
504            .insert(worker.to_string(), KvNamespace { id: "ns-id".into() });
505        lock
506    }
507
508    /// Mark `queue` as one this project has already created.
509    pub(crate) fn with_queue(mut lock: DeployLock, queue: &str) -> DeployLock {
510        lock.record_queue("default", queue);
511        lock
512    }
513
514    fn scratch_lock_path(label: &str) -> std::path::PathBuf {
515        let unique = std::time::SystemTime::now()
516            .duration_since(std::time::UNIX_EPOCH)
517            .unwrap()
518            .as_nanos();
519        std::env::temp_dir().join(format!(
520            "bynk-{label}-{}-{unique}.deploy.lock",
521            std::process::id()
522        ))
523    }
524
525    fn scratch_lock_dir(label: &str) -> std::path::PathBuf {
526        let unique = std::time::SystemTime::now()
527            .duration_since(std::time::UNIX_EPOCH)
528            .unwrap()
529            .as_nanos();
530        let dir =
531            std::env::temp_dir().join(format!("bynk-{label}-{}-{unique}", std::process::id()));
532        std::fs::create_dir_all(&dir).unwrap();
533        dir
534    }
535
536    fn temp_litter(dir: &Path) -> Vec<std::path::PathBuf> {
537        std::fs::read_dir(dir)
538            .unwrap()
539            .filter_map(|e| e.ok().map(|e| e.path()))
540            .filter(|p| p.extension().is_some_and(|x| x == "tmp"))
541            .collect()
542    }
543
544    #[test]
545    fn lock_round_trip_is_environment_keyed() {
546        let lock = DeployLock {
547            version: 1,
548            environments: BTreeMap::from([(
549                "default".into(),
550                Environment {
551                    kv: BTreeMap::from([("api".into(), KvNamespace { id: "abc".into() })]),
552                    workers: BTreeMap::from([(
553                        "api".into(),
554                        WorkerRecord {
555                            deployed: true,
556                            contracts: Default::default(),
557                        },
558                    )]),
559                    queues: BTreeSet::from(["intake".to_string()]),
560                },
561            )]),
562        };
563        assert_eq!(
564            toml::from_str::<DeployLock>(&toml::to_string_pretty(&lock).unwrap()).unwrap(),
565            lock
566        );
567    }
568
569    #[test]
570    fn a_slice_0_ledger_without_workers_or_queues_still_reads() {
571        // Both tables are additive: a ledger committed before slice 2 (workers)
572        // or slice 1 (queues) must keep working, reporting nothing recorded
573        // rather than failing to parse. #600 D4: the version stays 1, so this
574        // is the whole migration story.
575        let lock: DeployLock = toml::from_str(
576            r#"
577            version = 1
578            [environments.default.kv.api]
579            id = "abc"
580        "#,
581        )
582        .expect("a slice-0 ledger must still parse");
583        assert_eq!(recorded_kv(&lock, "api", "default"), Some("abc"));
584        assert!(!lock.is_deployed("default", "api"));
585        assert!(!lock.has_queue("default", "intake"));
586    }
587
588    #[test]
589    fn the_queue_set_serialises_as_names_under_the_environment() {
590        // The committed shape is a documented surface — a reviewer reads this
591        // file in a diff. Queues are environment-wide names, not a per-worker
592        // table, and carry no id.
593        let mut lock = DeployLock {
594            version: 1,
595            ..Default::default()
596        };
597        lock.record_queue("default", "job-intake");
598        lock.record_queue("default", "job-retry");
599        let text = toml::to_string_pretty(&lock).unwrap();
600        assert!(
601            text.contains("[environments.default]") && text.contains("queues = ["),
602            "the queue set is environment-wide, not a per-worker table: {text}"
603        );
604        for queue in ["job-intake", "job-retry"] {
605            assert!(
606                text.contains(&format!("\"{queue}\"")),
607                "{queue} is recorded"
608            );
609        }
610        assert!(
611            !text.contains("id"),
612            "a queue is addressed by name — the ledger has no id to record: {text}"
613        );
614        assert_eq!(toml::from_str::<DeployLock>(&text).unwrap(), lock);
615    }
616
617    #[test]
618    fn an_empty_queue_set_is_not_written_at_all() {
619        // A project with no queues must not grow an empty `queues = []` line in
620        // a committed file for a slice it does not use.
621        let mut lock = DeployLock {
622            version: 1,
623            ..Default::default()
624        };
625        lock.record_deployed("default", "api", Some(Default::default()));
626        assert!(!toml::to_string_pretty(&lock).unwrap().contains("queues"));
627    }
628
629    #[test]
630    fn materialises_only_the_placeholder() {
631        let unique = std::time::SystemTime::now()
632            .duration_since(std::time::UNIX_EPOCH)
633            .unwrap()
634            .as_nanos();
635        let path =
636            std::env::temp_dir().join(format!("bynk-deploy-{}-{}", std::process::id(), unique));
637        std::fs::write(&path, format!("id = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"")).unwrap();
638        assert!(materialise_kv_id(&path, "abc"));
639        assert_eq!(std::fs::read_to_string(&path).unwrap(), "id = \"abc\"");
640        let _ = std::fs::remove_file(path);
641    }
642
643    #[test]
644    fn two_environments_do_not_cross_contaminate_the_ledger() {
645        let mut lock = DeployLock {
646            version: 1,
647            ..Default::default()
648        };
649        lock.record_deployed("staging", "api", None);
650        lock.record_queue("staging", "jobs");
651        lock.environments
652            .entry("staging".into())
653            .or_default()
654            .kv
655            .insert(
656                "api".into(),
657                KvNamespace {
658                    id: "kv-staging".into(),
659                },
660            );
661
662        // "default" was never touched — a `--env staging` run must not
663        // fabricate or leak into the section a plain `bynk deploy` reads.
664        assert!(!lock.is_deployed("default", "api"));
665        assert!(!lock.has_queue("default", "jobs"));
666        assert_eq!(recorded_kv(&lock, "api", "default"), None);
667
668        assert!(lock.is_deployed("staging", "api"));
669        assert!(lock.has_queue("staging", "jobs"));
670        assert_eq!(recorded_kv(&lock, "api", "staging"), Some("kv-staging"));
671    }
672
673    /// #837 review: `materialise_deploy_state` (shared with `bynk dev --
674    /// --remote`) hardcoded `"default"` even after `--env` shipped. Before
675    /// `--env` existed every real deploy recorded into `"default"`
676    /// regardless, so that always matched — but a project deployed *only*
677    /// under `bynk deploy --env staging` now has nothing under `"default"`,
678    /// and reading the wrong section misreports a provisioned project as
679    /// never deployed.
680    #[test]
681    fn materialise_deploy_state_reads_the_named_environment_not_default() {
682        let unique = std::time::SystemTime::now()
683            .duration_since(std::time::UNIX_EPOCH)
684            .unwrap()
685            .as_nanos();
686        let dir = std::env::temp_dir().join(format!(
687            "bynk-materialise-state-{}-{unique}",
688            std::process::id()
689        ));
690        std::fs::create_dir_all(&dir).unwrap();
691        let project_root = dir.clone();
692        let config = dir.join("wrangler.toml");
693        std::fs::write(&config, format!("id = \"{KV_NAMESPACE_ID_PLACEHOLDER}\"")).unwrap();
694
695        // Provisioned under "staging" alone — the scenario the review named:
696        // a project that has never had a plain `bynk deploy` (no "default").
697        let mut lock = DeployLock {
698            version: 1,
699            ..Default::default()
700        };
701        lock.environments
702            .entry("staging".into())
703            .or_default()
704            .kv
705            .insert(
706                "api".into(),
707                KvNamespace {
708                    id: "kv-staging".into(),
709                },
710            );
711        std::fs::write(
712            project_root.join(LOCK_FILE),
713            toml::to_string_pretty(&lock).unwrap(),
714        )
715        .unwrap();
716
717        // Reading "default" (what this function did unconditionally before
718        // the fix) must fail helpfully, not silently mis-materialise.
719        let err = materialise_deploy_state(&project_root, "api", &config, "default")
720            .expect_err("nothing is recorded under \"default\" — this must not silently pass");
721        assert!(
722            err.contains("environment `default`"),
723            "the error should name which environment it looked under: {err}"
724        );
725
726        // Reading "staging" — the environment it was actually deployed under
727        // — must succeed and materialise that environment's id.
728        assert!(materialise_deploy_state(&project_root, "api", &config, "staging").unwrap());
729        assert_eq!(
730            std::fs::read_to_string(&config).unwrap(),
731            "id = \"kv-staging\""
732        );
733
734        let _ = std::fs::remove_dir_all(dir);
735    }
736
737    // ---- #839 slice 5: reconciliation maturity + orphan reporting -------
738
739    #[test]
740    fn a_removed_context_with_kv_is_two_orphans_not_one() {
741        // `kv` and `workers` are both keyed by worker name — a context that
742        // had KV and was deleted from source shows up in both maps, so the
743        // diff must report both, independently (DECISION A).
744        let mut lock = DeployLock {
745            version: 1,
746            ..Default::default()
747        };
748        lock.environments
749            .entry("default".into())
750            .or_default()
751            .kv
752            .insert(
753                "gone".into(),
754                KvNamespace {
755                    id: "kv-gone".into(),
756                },
757            );
758        lock.record_deployed("default", "gone", None);
759        // "still-here" is in the current build, "gone" is not.
760        let resources = BTreeMap::from([("still-here".into(), Resources::default())]);
761
762        let orphans = find_orphans(&lock, "default", &resources);
763        assert_eq!(orphans.kv, names(&["gone"]));
764        assert_eq!(orphans.workers, names(&["gone"]));
765        assert!(orphans.queues.is_empty());
766    }
767
768    #[test]
769    fn a_queue_two_contexts_share_is_never_orphaned_while_either_declares_it() {
770        // The false-positive risk DECISION A's diff exists to avoid: a queue
771        // consumed by two contexts must not be reported orphaned just because
772        // a *third*, now-removed context used to consume it too.
773        let mut lock = DeployLock {
774            version: 1,
775            ..Default::default()
776        };
777        lock.record_queue("default", "jobs");
778        let resources = BTreeMap::from([
779            ("orders".into(), Resources::default().consumes(&["jobs"])),
780            ("billing".into(), Resources::default().consumes(&["jobs"])),
781        ]);
782
783        let orphans = find_orphans(&lock, "default", &resources);
784        assert!(
785            orphans.queues.is_empty(),
786            "jobs is still consumed by two live contexts: {orphans:?}"
787        );
788    }
789
790    #[test]
791    fn a_queue_no_context_declares_anymore_is_orphaned() {
792        let mut lock = DeployLock {
793            version: 1,
794            ..Default::default()
795        };
796        lock.record_queue("default", "stale-jobs");
797        let resources = BTreeMap::from([("orders".into(), Resources::default())]);
798
799        let orphans = find_orphans(&lock, "default", &resources);
800        assert_eq!(orphans.queues, names(&["stale-jobs"]));
801    }
802
803    #[test]
804    fn find_orphans_is_scoped_to_the_named_environment() {
805        // A "staging" orphan must never leak into "default"'s report, and
806        // vice versa — environments (slice 4) stay fully independent.
807        let mut lock = DeployLock {
808            version: 1,
809            ..Default::default()
810        };
811        lock.environments
812            .entry("staging".into())
813            .or_default()
814            .kv
815            .insert(
816                "gone".into(),
817                KvNamespace {
818                    id: "kv-staging-gone".into(),
819                },
820            );
821        let resources = BTreeMap::new();
822
823        assert!(
824            find_orphans(&lock, "default", &resources).kv.is_empty(),
825            "default"
826        );
827        assert_eq!(
828            find_orphans(&lock, "staging", &resources).kv,
829            names(&["gone"])
830        );
831    }
832
833    #[test]
834    fn an_absent_environment_has_no_orphans() {
835        let lock = DeployLock::default();
836        let orphans = find_orphans(&lock, "default", &BTreeMap::new());
837        assert_eq!(orphans, Orphans::default());
838    }
839
840    #[test]
841    fn has_prunable_ignores_worker_only_orphans() {
842        // The bug the review caught: a project whose only orphan is an
843        // unprunable Worker must not trigger confirm_prune's "Delete 0
844        // resource(s)?" prompt at all.
845        let worker_only = Orphans {
846            workers: names(&["gone"]),
847            ..Default::default()
848        };
849        assert!(!worker_only.has_prunable());
850
851        let with_kv = Orphans {
852            kv: names(&["gone"]),
853            ..Default::default()
854        };
855        assert!(with_kv.has_prunable());
856
857        let with_queue = Orphans {
858            queues: names(&["gone"]),
859            ..Default::default()
860        };
861        assert!(with_queue.has_prunable());
862
863        assert!(!Orphans::default().has_prunable());
864    }
865
866    #[test]
867    fn a_written_ledger_reads_back_identically() {
868        // The floor the atomic write must not disturb: a real ledger survives a
869        // write/read round-trip unchanged.
870        let path = scratch_lock_path("roundtrip");
871        let mut lock = DeployLock {
872            version: 1,
873            ..Default::default()
874        };
875        lock.environments
876            .entry("default".into())
877            .or_default()
878            .kv
879            .insert(
880                "api".into(),
881                KvNamespace {
882                    id: "kv-123".into(),
883                },
884            );
885        write_lock(&path, &lock).unwrap();
886        assert_eq!(read_lock(&path).unwrap(), lock);
887        let _ = std::fs::remove_file(path);
888    }
889
890    #[test]
891    fn an_empty_ledger_is_corruption_not_a_fresh_project() {
892        // #736: a truncated write leaves a zero-byte file. Reading it as an
893        // empty v1 ledger would re-mint every namespace, so it must fail hard —
894        // whereas a genuinely absent file is a fresh project and reads clean.
895        let path = scratch_lock_path("empty");
896        std::fs::write(&path, "").unwrap();
897        assert!(
898            read_lock(&path).is_err(),
899            "a zero-byte ledger must be rejected, not treated as no environments"
900        );
901        std::fs::write(&path, "   \n\t\n").unwrap();
902        assert!(
903            read_lock(&path).is_err(),
904            "a whitespace-only ledger is just as corrupt"
905        );
906        let _ = std::fs::remove_file(&path);
907        assert!(
908            read_lock(&path).is_ok(),
909            "an absent ledger is a fresh project, not corruption"
910        );
911    }
912
913    #[test]
914    fn a_ledger_without_a_version_is_rejected() {
915        // With the serde default gone, a file that parses but carries no
916        // `version` is corruption rather than a silent empty v1 ledger.
917        let path = scratch_lock_path("noversion");
918        std::fs::write(&path, "[environments]\n").unwrap();
919        assert!(read_lock(&path).is_err());
920        let _ = std::fs::remove_file(path);
921    }
922
923    #[test]
924    fn a_successful_write_leaves_no_temp_litter() {
925        // The atomic write renames its temp over the ledger; nothing sibling to
926        // the ledger may survive the write.
927        let dir = scratch_lock_dir("nolitter");
928        let path = dir.join(LOCK_FILE);
929        let lock = DeployLock {
930            version: 1,
931            ..Default::default()
932        };
933        write_lock(&path, &lock).unwrap();
934        write_lock(&path, &lock).unwrap(); // over an existing ledger, too
935        assert!(
936            temp_litter(&dir).is_empty(),
937            "no `.tmp` sibling may outlive the rename"
938        );
939        assert_eq!(read_lock(&path).unwrap(), lock);
940        let _ = std::fs::remove_dir_all(dir);
941    }
942
943    #[cfg(unix)]
944    #[test]
945    fn a_rewrite_preserves_the_ledger_permissions() {
946        use std::os::unix::fs::PermissionsExt;
947        // A committed ledger's mode must survive a rewrite, or the atomic replace
948        // would silently loosen or tighten it via the temp file's fresh mode.
949        let dir = scratch_lock_dir("perms");
950        let path = dir.join(LOCK_FILE);
951        let lock = DeployLock {
952            version: 1,
953            ..Default::default()
954        };
955        write_lock(&path, &lock).unwrap();
956        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
957        write_lock(&path, &lock).unwrap();
958        assert_eq!(
959            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
960            0o600,
961            "the rewrite must carry the ledger's own mode across the rename"
962        );
963        let _ = std::fs::remove_dir_all(dir);
964    }
965}