Skip to main content

bynk/deploy/
plan.rs

1use super::*;
2
3#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4pub enum DeployFormat {
5    #[default]
6    Short,
7    Json,
8}
9
10#[derive(Debug, Clone, Default)]
11pub struct DeployOptions {
12    pub dry_run: bool,
13    pub format: DeployFormat,
14    pub yes: bool,
15    /// `--context NAME` — deploy this context alone, assuming the contexts it
16    /// consumes are already live (slice 2, D4). Absent deploys the whole
17    /// project in dependency order.
18    pub context: Option<String>,
19    /// `--env NAME` — slice 4. Selects the `bynk.deploy.lock` section; for any
20    /// value other than `"default"` also drives synthesis of an environment-
21    /// scoped Wrangler config section, since Cloudflare does not inherit
22    /// bindings into a named environment (confirmed against Cloudflare's own
23    /// docs — see #835). `DeployOptions::default()`'s empty string is a
24    /// test-only artefact of `#[derive(Default)]`; every real invocation goes
25    /// through the CLI, whose `default_value = "default"` guarantees this is
26    /// never empty.
27    pub environment: String,
28    /// `--secrets-file` — a dotenv-style source of `NAME=value` pairs. Supplies
29    /// **names and values** (slice 3, ADR 0195 D3).
30    pub secrets_file: Option<std::path::PathBuf>,
31    /// `--secret NAME` — a name whose *value* comes from the environment or a
32    /// prompt. The environment is never scanned for names, so this is how a
33    /// `bynk.Secrets` name reaches `deploy` without a file.
34    pub secrets: Vec<String>,
35    /// `--force` — overwrite a secret already set, rather than skipping it.
36    pub force: bool,
37    /// `--prune` (slice 5) — delete every reported KV/queue orphan, behind
38    /// its own confirmation. Never deletes a Worker (DECISION C). Defaults
39    /// to report-only, matching the track's "report or prevent, never
40    /// silently share/destroy" posture (§6).
41    pub prune: bool,
42    pub wrangler_args: Vec<String>,
43}
44
45/// The whole-project plan (slice 2). `order` is the upload order, dependencies
46/// first; `contexts` carries it with each context's own actions. Slice 0's
47/// single-worker `Plan` is the one-element case of this.
48#[derive(Debug, Serialize)]
49pub(crate) struct Plan<'a> {
50    environment: &'a str,
51    /// Slice 5: every ledger entry this environment no longer declares —
52    /// printed before the per-context breakdown, so an orphan is seen before
53    /// what's actually being pushed.
54    orphans: Orphans,
55    /// The resolved upload order — the plan's headline, since Cloudflare
56    /// rejects a Worker uploaded before its binding target.
57    order: Vec<&'a str>,
58    contexts: Vec<ContextPlan<'a>>,
59}
60
61#[derive(Debug, Serialize)]
62struct ContextPlan<'a> {
63    worker: &'a str,
64    kv: Option<PlanKv<'a>>,
65    /// One line per queue this context consumes, in name order.
66    queues: Vec<PlanQueue<'a>>,
67    /// The migration the push will apply, if the context has an agent.
68    migration: Option<PlanMigration<'a>>,
69    /// One line per secret this run will set on this context, in name order.
70    secrets: Vec<PlanSecret>,
71    /// False when this context names at least one secret with a computed
72    /// expression, so `secrets` is **not** everything it reads (ADR 0196 D2).
73    ///
74    /// Carried in the machine surface as well as the human one, because this is
75    /// the field that stops a CI job trusting a short list — the failure the
76    /// whole increment exists to prevent is a reader taking silence for absence.
77    secrets_complete: bool,
78    /// `deploy` first time, `redeploy` when the ledger has pushed it before —
79    /// the honest word, since a re-run re-pushes rather than skipping.
80    action: &'static str,
81    /// The workers this one binds to, in the emitted config.
82    binds_to: Vec<&'a str>,
83}
84
85#[derive(Debug, Serialize)]
86struct PlanKv<'a> {
87    action: &'static str,
88    namespace: &'a str,
89}
90
91#[derive(Debug, Serialize)]
92struct PlanQueue<'a> {
93    /// `create` when this project has never made the queue, `reuse` when the
94    /// ledger has it. Either way the provision step attempts the create and
95    /// treats an existing queue as success, so `reuse` is a forecast — "expect
96    /// nothing new" — not a promise to stay silent (ADR 0194 D2).
97    action: &'static str,
98    queue: &'a str,
99}
100
101/// One secret the run intends to set on one context.
102///
103/// There is deliberately **no presence field**. Presence is a live question
104/// (`wrangler secret list`), and the plan is derived before `deploy`
105/// authenticates — which is what keeps `--dry-run` working offline. So the plan
106/// says what it will *try*, and the run reports the skip when a secret turns out
107/// to be there. The ledger cannot help: it records no secret at all, because a
108/// recorded presence could only ever be a stale one (ADR 0195 D1/D4).
109#[derive(Debug, Serialize)]
110struct PlanSecret {
111    /// Owned: the name set is derived (declared ∪ supplied) rather than
112    /// borrowed from any one source.
113    name: String,
114    /// `declared` — the compiler proved a handler reads it. `supplied` — the
115    /// user named it. The mark is the floor-not-census contract made legible:
116    /// no `declared` line for a `bynk.Secrets` name does **not** mean the
117    /// context needs none (ADR 0195 D2).
118    origin: Origin,
119    /// `set`, or `overwrite` under `--force`. A `set` line may still report a
120    /// skip at wire time — see the type's note.
121    action: &'static str,
122}
123
124#[derive(Debug, Serialize)]
125struct PlanMigration<'a> {
126    tag: &'a str,
127    /// Always `wrangler deploy`, and that is the point: the field names an
128    /// owner other than `bynk`, which is the whole content of the advisory
129    /// (ADR 0194 D1). A consumer reading the plan learns that this line is not
130    /// a claim about the account's state, without having to know the ADR.
131    applied_by: &'static str,
132}
133
134/// The `--` passthrough argument that conflicts with the driver's own
135/// `--env`, if any (slice 4, DECISION E) — bare or `=`-joined, mirroring
136/// `dev.rs`'s `passthrough_has` matching rule for the same class of clash
137/// (`--port`/`--inspector-port` there). Returns the matched literal, not just
138/// a bool, so the error can name what it conflicts with. Pure, so the rule is
139/// tested without touching `DeployOptions`.
140///
141/// `pub(crate)`: `dev.rs` reuses this for the identical clash between its own
142/// `--env` (which environment's ledger section `--remote` reads) and a
143/// `-- --env`/`-- --environment` passthrough to `wrangler dev` (which
144/// environment Wrangler actually connects to) — the same "two explicit,
145/// conflicting environment selections, one of them silent" shape, just
146/// without a value `dev` forwards to wrangler itself.
147pub(crate) fn conflicting_env_passthrough(wrangler_args: &[String]) -> Option<&str> {
148    wrangler_args
149        .iter()
150        .find(|arg| {
151            ["--env", "--environment"]
152                .iter()
153                .any(|flag| arg.as_str() == *flag || arg.starts_with(&format!("{flag}=")))
154        })
155        .map(String::as_str)
156}
157
158/// Run the slice-0 single-context deployment pipeline.
159pub fn run(
160    tb: &dyn Toolbox,
161    compiler: &Compiler,
162    project_root: &Path,
163    node_floor: u32,
164    opts: &DeployOptions,
165) -> ExitCode {
166    // Slice 4 (DECISION E), and the first check of all: once `--env` is a
167    // real, driver-curated concept, a conflicting `-- --env`/`-- --environment`
168    // would otherwise reach `wrangler deploy` as a second, contradictory flag —
169    // Wrangler's own last-wins parsing deciding silently which one actually
170    // deploys, while the ledger records the driver's choice regardless. Reject
171    // before any other work, rather than pick a winner between two explicit,
172    // conflicting inputs.
173    if let Some(conflict) = conflicting_env_passthrough(&opts.wrangler_args) {
174        eprintln!(
175            "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
176            opts.environment
177        );
178        return ExitCode::FAILURE;
179    }
180
181    let preflight_opts = DoctorOptions {
182        only: Some(Capability::Deploy),
183        strict: false,
184    };
185    let report = doctor::diagnose(
186        tb,
187        compiler,
188        &Context {
189            project_root: Some(project_root.to_path_buf()),
190            in_repo: false,
191            node_floor,
192        },
193        &preflight_opts,
194    );
195    if report.exit_nonzero(&preflight_opts) {
196        eprint!("{}", preflight_failure_message(&report));
197        return ExitCode::FAILURE;
198    }
199
200    let build_dir = project_root.join(".bynk").join("deploy");
201    if let Err(e) = workers::prepare_build_dir(project_root, &build_dir) {
202        eprintln!("bynk: could not prepare build directory: {e}");
203        return ExitCode::FAILURE;
204    }
205    if !workers::compile_once(compiler, project_root, &build_dir, true) {
206        return ExitCode::FAILURE;
207    }
208    // Slice 2: every context, ordered — not the one context slice 0 demanded.
209    let workers_dir = build_dir.join("workers");
210    let available = workers::discover_workers(&workers_dir);
211    let selected = match workers::select_contexts(&available, opts.context.as_slice()) {
212        Ok(selected) => selected,
213        Err(e) => {
214            eprintln!("bynk: {e}");
215            return ExitCode::FAILURE;
216        }
217    };
218    // Read spans the *whole* project even under `--context`: D4 needs the
219    // selected context's binding targets to check they are live, and they are
220    // by definition outside the selection.
221    let resources = match project_resources(&workers_dir, &available) {
222        Ok(resources) => resources,
223        Err(e) => {
224            eprintln!("bynk: {e}");
225            return ExitCode::FAILURE;
226        }
227    };
228    let graph = service_graph(&resources);
229    // Read before the plan: a malformed `--secrets-file` is the user's typo, and
230    // it should surface as one now rather than as a missing-secret failure
231    // partway through a run that has already pushed a Worker.
232    let secret_source = match SecretSource::read(opts) {
233        Ok(source) => source,
234        Err(e) => {
235            eprintln!("bynk: {e}");
236            return ExitCode::FAILURE;
237        }
238    };
239    let lock_path = project_root.join(LOCK_FILE);
240    let mut lock = match read_lock(&lock_path) {
241        Ok(lock) => lock,
242        Err(e) => {
243            eprintln!("bynk: could not read {}: {e}", lock_path.display());
244            return ExitCode::FAILURE;
245        }
246    };
247
248    // (D4) `--context` does not deploy a dependency closure. A binding to a
249    // Worker that has never been pushed fails at upload, so say which one
250    // rather than letting Cloudflare's own error carry it.
251    if opts.context.is_some()
252        && let [worker] = selected.as_slice()
253    {
254        let absent = absent_dependencies(worker, &graph, &lock, &opts.environment);
255        if !absent.is_empty() {
256            eprintln!(
257                "bynk: `{worker}` binds to {}, which {} never been deployed — a Service Binding to a Worker that does not exist fails at upload.",
258                absent
259                    .iter()
260                    .map(|a| format!("`{a}`"))
261                    .collect::<Vec<_>>()
262                    .join(", "),
263                if absent.len() == 1 { "has" } else { "have" }
264            );
265            eprintln!("  Deploy the whole project once (`bynk deploy`) to bring the topology up.");
266            return ExitCode::FAILURE;
267        }
268
269        // v0.177 (#643): the other half of D4. The dependency exists — but does
270        // it still provide the contract this worker was compiled against?
271        // Without this, the push succeeds and production discovers the skew by
272        // 409ing. `--context` is precisely the flag that makes this reachable.
273        let expects = match read_contracts_manifest(&workers_dir.join(worker)) {
274            Ok(m) => m.expects,
275            Err(e) => {
276                eprintln!(
277                    "bynk: could not read `{worker}`'s {}: {e}",
278                    bynk_emit::emitter::contracts::CONTRACTS_MANIFEST
279                );
280                return ExitCode::FAILURE;
281            }
282        };
283        let skews = contract_skews(
284            &expects,
285            &lock,
286            bynk_emit::project::worker_dir_name,
287            &opts.environment,
288        );
289        if !skews.is_empty() {
290            eprintln!(
291                "bynk: `{worker}` was compiled against a contract its live dependencies no longer provide (bynk.deploy.contract_skew):"
292            );
293            for s in &skews {
294                eprintln!(
295                    "  {}.{} — compiled against {}, live is {}",
296                    s.dependency, s.service, s.expected, s.live
297                );
298            }
299            eprintln!(
300                "  Deploying this would ship a caller its callee rejects (409 ContractMismatch) on every call."
301            );
302            eprintln!("  Deploy the whole project (`bynk deploy`) so both sides move together.");
303            return ExitCode::FAILURE;
304        }
305    }
306
307    let order = match deploy_order(&selected, &graph) {
308        Ok(order) => order
309            .into_iter()
310            // A whole-project run orders every worker; `--context` orders only
311            // the selection, but the DFS reaches its (already-live) targets —
312            // drop them, D4 having already checked them.
313            .filter(|worker| selected.contains(worker))
314            .collect::<Vec<_>>(),
315        Err(e) => {
316            eprintln!("bynk: {e}");
317            return ExitCode::FAILURE;
318        }
319    };
320
321    let plan = derive_plan(
322        &order,
323        &resources,
324        &lock,
325        &secret_source,
326        opts.force,
327        &opts.environment,
328    );
329    print_plan(&plan, opts.format);
330    if opts.dry_run {
331        return ExitCode::SUCCESS;
332    }
333
334    // The CI gate is KV's alone, deliberately. A namespace id is *minted* by
335    // Cloudflare, so a CI job that creates one and cannot commit the result
336    // leaves an orphan nobody can find again. A queue's name comes from the
337    // source, so CI creating one loses nothing: the next run derives the same
338    // name and finds the same queue (ADR 0194 D2).
339    for worker in &order {
340        let recorded = recorded_kv(&lock, worker, &opts.environment);
341        if should_refuse_unrecorded_ci(resources[worker].needs_kv, recorded, is_ci()) {
342            eprintln!(
343                "bynk: KV namespace for `{worker}` is unrecorded; provision locally first and commit {LOCK_FILE}"
344            );
345            return ExitCode::FAILURE;
346        }
347    }
348    let probe = probe::detect(
349        tb,
350        "wrangler",
351        DetectOpts {
352            project_root: Some(project_root),
353            allow_npx: true,
354        },
355    );
356    if !whoami(&probe.provenance) {
357        eprintln!(
358            "bynk: Cloudflare authentication is unavailable; run `wrangler login` or set CLOUDFLARE_API_TOKEN"
359        );
360        return ExitCode::FAILURE;
361    }
362    if !confirm(opts.yes) {
363        return ExitCode::FAILURE;
364    }
365
366    // Slice 5, DECISION B: once per run, not once per context — an
367    // account-wide list, fetched only when it could actually change an
368    // outcome (a context needs KV and already has a recorded id worth
369    // checking; a first deploy has nothing to check drift against). `None`
370    // (no wrangler, or the call failed) falls back to trusting the ledger
371    // unconditionally, exactly as every deploy before this slice did.
372    let live_kv_ids = if order
373        .iter()
374        .any(|w| resources[w].needs_kv && recorded_kv(&lock, w, &opts.environment).is_some())
375    {
376        live_kv_namespace_ids(&probe.provenance, project_root)
377    } else {
378        None
379    };
380
381    // Provision → wire → push, per context, in dependency order. Each context's
382    // state is written to the ledger as it lands (ADR 0180's incremental
383    // posture), so an interrupted multi-context run is resumable rather than
384    // restartable — and never rolled back (D2): a half-deployed project is a
385    // real state the next plan will show, not an error to unwind.
386    // Shared across the loop: a queue two contexts consume is one queue, so it
387    // wants one create attempt per run, not one per consumer (ADR 0194 D2).
388    let mut attempted_queues = BTreeSet::new();
389    // Shared across the loop so two contexts wanting the same secret prompt
390    // once. Dropped with the run — nothing here is ever written (ADR 0195 D1).
391    let mut resolved_secrets = BTreeMap::new();
392    for (i, worker) in order.iter().enumerate() {
393        if order.len() > 1 {
394            eprintln!("bynk: deploying `{worker}` ({}/{})…", i + 1, order.len());
395        }
396        match deploy_one(
397            &probe.provenance,
398            project_root,
399            &workers_dir,
400            worker,
401            &resources[worker],
402            &mut lock,
403            &lock_path,
404            &mut attempted_queues,
405            &mut Secrets {
406                source: &secret_source,
407                force: opts.force,
408                resolved: &mut resolved_secrets,
409            },
410            &opts.wrangler_args,
411            &opts.environment,
412            live_kv_ids.as_ref(),
413        ) {
414            Ok(Pushed::Ok) => {
415                // v0.177 (#643): record what this Worker now *provides*, so a
416                // later `--context` push of one of its callers can be refused
417                // before it ships a caller that would 409.
418                // `Some` even when empty: this build *knows* what the Worker
419                // provides, and "knows it provides nothing" must not read as
420                // "no record" at the next gate.
421                let provided = read_contracts_manifest(&workers_dir.join(worker))
422                    .map(|m| Some(m.provides))
423                    .unwrap_or(None);
424                lock.record_deployed(&opts.environment, worker, provided);
425                if let Err(e) = write_lock(&lock_path, &lock) {
426                    eprintln!(
427                        "bynk: deployed `{worker}` but could not record it in {}: {e}",
428                        lock_path.display()
429                    );
430                    return ExitCode::FAILURE;
431                }
432            }
433            // A shared Ctrl-C. Stop, but report nothing and exit cleanly: the
434            // user asked for this, and the terminal signalled us too. `worker`
435            // is deliberately *not* recorded as deployed — the push was cut
436            // short, so whether it landed is unknown, and the ledger only ever
437            // claims what it watched succeed.
438            Ok(Pushed::Interrupted) => return ExitCode::SUCCESS,
439            Err(f) => {
440                eprintln!("bynk: {}", f.message);
441                // Stop rather than push on: everything left in the order either
442                // binds to what just failed or would be uploaded into a
443                // topology that is not what the plan described. `worker` itself
444                // is excluded — the line above already named it as the failure,
445                // and listing it here again as "not deployed" would double-count
446                // it against the number.
447                eprint!("{}", stopped_report(&order[i + 1..]));
448                // Wrangler's own code, not a flat 1 (slice 0's contract).
449                return ExitCode::from(f.code);
450            }
451        }
452    }
453
454    // Slice 5: pruning is project-wide, independent of `--context` — the
455    // orphan report already is (DECISION A), so pruning follows it. Runs
456    // only after every selected context has pushed cleanly: a failed deploy
457    // above already returned, so a mid-flight ledger never reaches this.
458    if opts.prune && plan.orphans.has_prunable() {
459        if !confirm_prune(opts.yes, &plan.orphans) {
460            return ExitCode::FAILURE;
461        }
462        if let Err(e) = prune_orphans(
463            &probe.provenance,
464            project_root,
465            &mut lock,
466            &lock_path,
467            &opts.environment,
468            &plan.orphans,
469        ) {
470            eprintln!("bynk: {}", e.message);
471            return ExitCode::from(e.code);
472        }
473    }
474    ExitCode::SUCCESS
475}
476
477pub fn preflight_failure_message(report: &Report) -> String {
478    format!(
479        "bynk: environment not ready for `deploy` — see below.\n\n{}",
480        report::render(report, Format::Human)
481    )
482}
483
484/// What the run did **not** get to, once a context failed. `rest` is the order
485/// *beyond* the failure, so the last context failing reports nothing — there was
486/// nothing left to withhold, and the failure itself has already been named.
487///
488/// Pure, so the wording — and the count's agreement with the list — is goldened
489/// rather than described.
490pub(crate) fn stopped_report(rest: &[String]) -> String {
491    if rest.is_empty() {
492        return String::new();
493    }
494    format!(
495        "bynk: stopping — {} not deployed: {}. Re-run `bynk deploy` to resume; what already landed is kept.\n",
496        if rest.len() == 1 {
497            "1 more context was".to_string()
498        } else {
499            format!("{} further contexts were", rest.len())
500        },
501        rest.join(", ")
502    )
503}
504
505/// Render the plan exactly as the user sees it. Pure, so the output surface the
506/// deploy guide documents is goldened rather than described — `print_plan` is
507/// the transport.
508pub(crate) fn plan_report(plan: &Plan<'_>, format: DeployFormat) -> String {
509    match format {
510        DeployFormat::Short => {
511            let mut out = String::new();
512            // Before the per-context section, deliberately: an orphan is a
513            // fact about the account regardless of what this run is about to
514            // do, and a reader should see it before the noise of what's being
515            // pushed (slice 5).
516            for kv in &plan.orphans.kv {
517                out.push_str(&format!("orphan kv {kv}\n"));
518            }
519            for worker in &plan.orphans.workers {
520                out.push_str(&format!("orphan worker {worker}\n"));
521            }
522            for queue in &plan.orphans.queues {
523                out.push_str(&format!("orphan queue {queue}\n"));
524            }
525            for context in &plan.contexts {
526                if let Some(kv) = &context.kv {
527                    out.push_str(&format!("kv {} {}\n", kv.action, kv.namespace));
528                }
529                for queue in &context.queues {
530                    out.push_str(&format!("queue {} {}\n", queue.action, queue.queue));
531                }
532                // Between the provisioning lines and the push, because that is
533                // where it happens: the migration rides the config `wrangler
534                // deploy` reads rather than being a step of its own. Flagged
535                // advisory in place — a reader must not take it for a claim
536                // that the tag is not yet applied (ADR 0194 D1).
537                if let Some(migration) = &context.migration {
538                    out.push_str(&format!(
539                        "migration {} (advisory — {} applies it)\n",
540                        migration.tag, migration.applied_by
541                    ));
542                }
543                // Before the lines it qualifies, not after: a reader who takes
544                // the list for the whole story is the failure this increment
545                // exists to prevent (ADR 0196 D2).
546                if !context.secrets_complete {
547                    out.push_str(&format!(
548                        "secrets incomplete {} (computes at least one name)\n",
549                        context.worker
550                    ));
551                }
552                // Names only, never values (ADR 0195 D1). The origin rides each
553                // line because the three are not equally known: `declared` is
554                // required, `read` is advisory, `supplied` is the user's word.
555                for secret in &context.secrets {
556                    out.push_str(&format!(
557                        "secret {} {} ({})\n",
558                        secret.action,
559                        secret.name,
560                        secret.origin.label()
561                    ));
562                }
563                out.push_str(&format!("{} {}\n", context.action, context.worker));
564            }
565            // The order is the plan's load-bearing claim once there is more
566            // than one context, so state it rather than leaving it implied by
567            // the line order above.
568            if plan.order.len() > 1 {
569                out.push_str(&format!("order {}\n", plan.order.join(" → ")));
570            }
571            out
572        }
573        DeployFormat::Json => {
574            format!(
575                "{}\n",
576                serde_json::to_string_pretty(plan).expect("plan serialises")
577            )
578        }
579    }
580}
581
582fn print_plan(plan: &Plan<'_>, format: DeployFormat) {
583    print!("{}", plan_report(plan, format));
584}
585
586/// Derive the plan over the resolved order. Pure, so the per-context breakdown
587/// and the ordering claim are unit-tested without a Cloudflare account.
588///
589/// Indexes `resources` rather than defending a miss: `order` ⊆ the workers it
590/// was read for, and the deploy loop indexes the same map anyway — so a
591/// tolerated miss here would only understate the plan a moment before the run
592/// panicked on it regardless.
593pub(crate) fn derive_plan<'a>(
594    order: &'a [String],
595    resources: &'a BTreeMap<String, Resources>,
596    lock: &DeployLock,
597    // Not borrowed into the plan: a `PlanSecret` owns its name, because the set
598    // is derived (declared ∪ supplied) rather than taken from either source.
599    secrets: &SecretSource,
600    force: bool,
601    environment: &'a str,
602) -> Plan<'a> {
603    Plan {
604        environment,
605        orphans: find_orphans(lock, environment, resources),
606        order: order.iter().map(String::as_str).collect(),
607        contexts: order
608            .iter()
609            .map(|worker| {
610                let declared = &resources[worker];
611                ContextPlan {
612                    worker,
613                    kv: declared.needs_kv.then(|| PlanKv {
614                        action: if recorded_kv(lock, worker, environment).is_some() {
615                            "reuse"
616                        } else {
617                            "create"
618                        },
619                        namespace: worker,
620                    }),
621                    queues: declared
622                        .queues
623                        .iter()
624                        .map(|queue| PlanQueue {
625                            action: if lock.has_queue(environment, queue) {
626                                "reuse"
627                            } else {
628                                "create"
629                            },
630                            queue,
631                        })
632                        .collect(),
633                    migration: declared.migration.as_deref().map(|tag| PlanMigration {
634                        tag,
635                        applied_by: "wrangler deploy",
636                    }),
637                    secrets: wanted_secrets(
638                        &declared.declared_secrets,
639                        &declared.read_secrets,
640                        secrets,
641                    )
642                    .into_iter()
643                    .map(|want| PlanSecret {
644                        name: want.name,
645                        origin: want.origin,
646                        // Presence is not knowable here — the plan runs
647                        // before auth so `--dry-run` stays offline — so the
648                        // action is what the run will attempt.
649                        action: if force { "overwrite" } else { "set" },
650                    })
651                    .collect(),
652                    secrets_complete: declared.reads_complete,
653                    action: if lock.is_deployed(environment, worker) {
654                        "redeploy"
655                    } else {
656                        "deploy"
657                    },
658                    binds_to: declared.binds_to.iter().map(String::as_str).collect(),
659                }
660            })
661            .collect(),
662    }
663}
664
665fn should_refuse_unrecorded_ci(needs_kv: bool, recorded: Option<&str>, ci: bool) -> bool {
666    needs_kv && recorded.is_none() && ci
667}
668
669pub(crate) fn requires_interactive_confirmation(yes: bool, stdin_is_terminal: bool) -> bool {
670    !yes && stdin_is_terminal
671}
672
673fn is_ci() -> bool {
674    std::env::var_os("CI").is_some_and(|value| value != "false")
675}
676
677fn confirm(yes: bool) -> bool {
678    if yes {
679        return true;
680    }
681    if !requires_interactive_confirmation(yes, io::stdin().is_terminal()) {
682        eprintln!("bynk: refusing to mutate in a non-interactive session without --yes");
683        return false;
684    }
685    eprint!("Deploy to Cloudflare? [y/N] ");
686    let _ = io::stderr().flush();
687    let mut answer = String::new();
688    io::stdin().read_line(&mut answer).is_ok()
689        && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
690}
691
692#[cfg(test)]
693pub(crate) mod tests {
694    use super::*;
695    use crate::deploy::config::tests::project;
696    use crate::deploy::graph::tests::graph;
697    use crate::deploy::ledger::tests::{lock_with_deployed, with_kv, with_queue};
698
699    fn names(v: &[&str]) -> Vec<String> {
700        v.iter().map(|s| s.to_string()).collect()
701    }
702
703    /// `derive_plan` with no secret input and no `--force` — the shape every
704    /// test that predates slice 3 wants, so those tests keep saying what they
705    /// are about rather than restating two arguments they do not exercise.
706    pub(crate) fn plan_of<'a>(
707        order: &'a [String],
708        resources: &'a BTreeMap<String, Resources>,
709        lock: &DeployLock,
710    ) -> Plan<'a> {
711        derive_plan(
712            order,
713            resources,
714            lock,
715            &SecretSource::default(),
716            false,
717            "default",
718        )
719    }
720
721    // ---- v0.177 (#643): the deploy-time contract-skew gate ----
722
723    fn lock_with(worker: &str, contracts: &[(&str, &str)]) -> DeployLock {
724        let mut lock = DeployLock::default();
725        lock.record_deployed(
726            "default",
727            worker,
728            Some(
729                contracts
730                    .iter()
731                    .map(|(s, h)| (s.to_string(), h.to_string()))
732                    .collect(),
733            ),
734        );
735        lock
736    }
737
738    fn expects(dep: &str, svc: &str, hash: &str) -> BTreeMap<String, BTreeMap<String, String>> {
739        BTreeMap::from([(
740            dep.to_string(),
741            BTreeMap::from([(svc.to_string(), hash.to_string())]),
742        )])
743    }
744
745    #[test]
746    fn conflicting_env_passthrough_finds_bare_and_equals_forms() {
747        assert_eq!(
748            conflicting_env_passthrough(&names(&["--env", "production"])),
749            Some("--env")
750        );
751        assert_eq!(
752            conflicting_env_passthrough(&names(&["--env=production"])),
753            Some("--env=production")
754        );
755        assert_eq!(
756            conflicting_env_passthrough(&names(&["--environment", "production"])),
757            Some("--environment")
758        );
759        assert_eq!(
760            conflicting_env_passthrough(&names(&["--minify"])),
761            None,
762            "an unrelated flag is not a conflict"
763        );
764        assert_eq!(conflicting_env_passthrough(&[]), None);
765    }
766
767    #[test]
768    fn plan_creates_or_reuses_kv_from_the_ledger() {
769        let order = names(&["api"]);
770        let declared = project(vec![("api", Resources::default().needs_kv())]);
771        let fresh = DeployLock::default();
772        assert_eq!(
773            plan_of(&order, &declared, &fresh).contexts[0]
774                .kv
775                .as_ref()
776                .unwrap()
777                .action,
778            "create"
779        );
780        assert_eq!(
781            plan_of(&order, &declared, &with_kv(DeployLock::default(), "api")).contexts[0]
782                .kv
783                .as_ref()
784                .unwrap()
785                .action,
786            "reuse"
787        );
788        assert!(
789            plan_of(
790                &order,
791                &project(vec![("api", Resources::default())]),
792                &fresh
793            )
794            .contexts[0]
795                .kv
796                .is_none(),
797            "a context declaring no KV gets no KV line"
798        );
799    }
800
801    // ---- #600 slice 1: queues and DO migrations ------------------------
802
803    #[test]
804    fn plan_creates_or_reuses_a_queue_by_its_name() {
805        // Queues reconcile on the name `from queue("n")` gave them — there is
806        // no id — so the ledger's whole answer is "have we made this before?"
807        let order = names(&["jobs"]);
808        let declared = project(vec![("jobs", Resources::default().consumes(&["intake"]))]);
809        let line =
810            |lock: &DeployLock| plan_of(&order, &declared, lock).contexts[0].queues[0].action;
811        assert_eq!(line(&DeployLock::default()), "create");
812        assert_eq!(line(&with_queue(DeployLock::default(), "intake")), "reuse");
813        // The name is keyed environment-wide, not per worker: a different
814        // context consuming `intake` means the same queue.
815        assert!(with_queue(DeployLock::default(), "intake").has_queue("default", "intake"));
816        assert!(!with_queue(DeployLock::default(), "intake").has_queue("default", "other"));
817    }
818
819    #[test]
820    fn a_context_with_no_queues_gets_no_queue_lines() {
821        assert!(
822            plan_of(
823                &names(&["api"]),
824                &project(vec![("api", Resources::default())]),
825                &DeployLock::default(),
826            )
827            .contexts[0]
828                .queues
829                .is_empty()
830        );
831    }
832
833    #[test]
834    fn the_migration_line_is_advisory_in_every_ledger_state() {
835        // D1: Cloudflare owns the applied-migration record, so the plan says
836        // what the push will *ask for* and never what is already true. A ledger
837        // that has deployed this context before must not change the line —
838        // there is no state here for the ledger to have an opinion about.
839        let order = names(&["jobs"]);
840        let declared = project(vec![("jobs", Resources::default().migrates("v1"))]);
841        for lock in [DeployLock::default(), lock_with_deployed(&["jobs"])] {
842            let plan = plan_of(&order, &declared, &lock);
843            let migration = plan.contexts[0]
844                .migration
845                .as_ref()
846                .expect("a context with an agent has a migration line");
847            assert_eq!(migration.tag, "v1");
848            assert_eq!(
849                migration.applied_by, "wrangler deploy",
850                "the plan names an owner other than bynk — that is the advisory"
851            );
852        }
853        // No agent, no migration line.
854        assert!(
855            plan_of(
856                &names(&["api"]),
857                &project(vec![("api", Resources::default())]),
858                &DeployLock::default(),
859            )
860            .contexts[0]
861                .migration
862                .is_none()
863        );
864    }
865
866    // ---- #601 slice 2: `--context` dependency liveness (D4) ------------
867
868    #[test]
869    fn context_flag_names_a_dependency_that_was_never_deployed() {
870        let g = graph(&[("orders", &["payment"]), ("payment", &[])]);
871        assert_eq!(
872            absent_dependencies("orders", &g, &DeployLock::default(), "default"),
873            names(&["payment"]),
874            "deploying orders alone would fail at upload — say which target is missing"
875        );
876        // Once payment is in the ledger, orders alone is fine.
877        assert!(
878            absent_dependencies("orders", &g, &lock_with_deployed(&["payment"]), "default")
879                .is_empty()
880        );
881        // A worker with no bindings never has an absent dependency.
882        assert!(absent_dependencies("payment", &g, &DeployLock::default(), "default").is_empty());
883    }
884
885    #[test]
886    fn the_plan_distinguishes_a_first_deploy_from_a_redeploy() {
887        let order = names(&["api"]);
888        let declared = project(vec![("api", Resources::default())]);
889        assert_eq!(
890            plan_of(&order, &declared, &DeployLock::default()).contexts[0].action,
891            "deploy"
892        );
893        assert_eq!(
894            plan_of(&order, &declared, &lock_with_deployed(&["api"])).contexts[0].action,
895            "redeploy",
896            "a re-run re-pushes rather than skipping, so the plan must not say `deploy`"
897        );
898    }
899
900    #[test]
901    fn the_plan_carries_the_order_and_each_context_s_bindings() {
902        let order = names(&["payment", "orders"]);
903        let declared = project(vec![
904            ("orders", Resources::default().binds(&["payment"])),
905            ("payment", Resources::default()),
906        ]);
907        let plan = plan_of(&order, &declared, &DeployLock::default());
908        assert_eq!(plan.order, vec!["payment", "orders"]);
909        assert_eq!(plan.contexts[1].worker, "orders");
910        assert_eq!(plan.contexts[1].binds_to, vec!["payment"]);
911        assert!(plan.contexts[0].binds_to.is_empty());
912    }
913
914    #[test]
915    fn dry_run_and_ci_gates_do_not_reach_mutation() {
916        assert!(
917            DeployOptions {
918                dry_run: true,
919                ..Default::default()
920            }
921            .dry_run
922        );
923        assert!(should_refuse_unrecorded_ci(true, None, true));
924        assert!(!should_refuse_unrecorded_ci(true, Some("id"), true));
925        assert!(!should_refuse_unrecorded_ci(true, None, false));
926    }
927
928    #[test]
929    fn non_interactive_deploy_requires_yes() {
930        assert!(!requires_interactive_confirmation(false, false));
931        assert!(requires_interactive_confirmation(false, true));
932        assert!(!requires_interactive_confirmation(true, false));
933    }
934
935    #[test]
936    fn matching_contracts_are_not_a_skew() {
937        let lock = lock_with("app-b", &[("whoami", "317bdd3de84d2176")]);
938        let found = contract_skews(
939            &expects("app.b", "whoami", "317bdd3de84d2176"),
940            &lock,
941            |c| c.replace('.', "-"),
942            "default",
943        );
944        assert!(found.is_empty(), "{found:?}");
945    }
946
947    #[test]
948    fn a_changed_contract_is_a_skew() {
949        // The scenario the increment exists for: B was redeployed with a new
950        // contract, and A still stamps the old one.
951        let lock = lock_with("app-b", &[("whoami", "ffffffffffffffff")]);
952        let found = contract_skews(
953            &expects("app.b", "whoami", "317bdd3de84d2176"),
954            &lock,
955            |c| c.replace('.', "-"),
956            "default",
957        );
958        assert_eq!(found.len(), 1);
959        assert_eq!(found[0].dependency, "app.b");
960        assert_eq!(found[0].service, "whoami");
961        assert_eq!(found[0].expected, "317bdd3de84d2176");
962        assert_eq!(found[0].live, "ffffffffffffffff");
963    }
964
965    #[test]
966    fn a_service_the_live_callee_no_longer_provides_is_a_skew() {
967        let lock = lock_with("app-b", &[("somethingElse", "317bdd3de84d2176")]);
968        let found = contract_skews(
969            &expects("app.b", "whoami", "317bdd3de84d2176"),
970            &lock,
971            |c| c.replace('.', "-"),
972            "default",
973        );
974        assert_eq!(found.len(), 1);
975        assert_eq!(found[0].live, "<absent>");
976    }
977
978    #[test]
979    fn a_ledger_with_no_contract_record_yields_no_finding() {
980        // Silence is not a match. A dependency deployed by a pre-v0.177 driver
981        // has no contract record, and the gate must report only what it *knows*
982        // is skewed — never what it merely cannot rule out. The runtime check is
983        // the backstop for exactly this case, so a false accusation here would
984        // block a legitimate deploy for no gain.
985        let mut lock = DeployLock::default();
986        lock.record_deployed("default", "app-b", None);
987        let found = contract_skews(
988            &expects("app.b", "whoami", "317bdd3de84d2176"),
989            &lock,
990            |c| c.replace('.', "-"),
991            "default",
992        );
993        assert!(found.is_empty(), "{found:?}");
994    }
995
996    #[test]
997    fn a_callee_that_now_provides_nothing_is_a_total_skew() {
998        // The counterpart to the rule above, and why the sentinel is `Option`
999        // rather than an empty map: a callee that removed *all* its `on call`
1000        // services emits no manifest, so a bare-map field would record `{}` —
1001        // indistinguishable from "old ledger" — and the gate would wave through
1002        // the most complete skew there is. `Some({})` says "known to provide
1003        // nothing", which is a finding.
1004        let lock = lock_with("app-b", &[]);
1005        let found = contract_skews(
1006            &expects("app.b", "whoami", "317bdd3de84d2176"),
1007            &lock,
1008            |c| c.replace('.', "-"),
1009            "default",
1010        );
1011        assert_eq!(found.len(), 1);
1012        assert_eq!(found[0].live, "<absent>");
1013    }
1014
1015    #[test]
1016    fn a_never_deployed_dependency_yields_no_finding_here() {
1017        // That is D4's existing job (`absent_dependencies`), and it runs first.
1018        // Reporting it twice, in two vocabularies, would only confuse.
1019        let found = contract_skews(
1020            &expects("app.b", "whoami", "317bdd3de84d2176"),
1021            &DeployLock::default(),
1022            |c| c.replace('.', "-"),
1023            "default",
1024        );
1025        assert!(found.is_empty(), "{found:?}");
1026    }
1027}