Skip to main content

ignition_core/actions/
deploy.rs

1//! Deploy actions: `ign deploy render` (Phase 21) and the `deploy plan`
2//! engine entry point (Phase 22).
3//!
4//! An OFFLINE verb: it reads a declared repo from disk and returns a model.
5//! There is no `Session`, no profile resolution, no network connection and
6//! no credential read; the envelope's `profile` is null. Render reads the
7//! repo and writes nothing, except into the optional `--out` directory, and
8//! only after the whole render succeeded: a refused render leaves `--out`
9//! untouched. The write goes through [`write_out`]'s clobber ladder, which
10//! never deletes and only replaces its own unmodified prior output.
11//!
12//! The `data` keys, in order: `env, gateway, mode, out, files, bytes,
13//! render_sha256, resources, tag_providers, uuids_minted, opaque_files,
14//! warnings`. Later plans add keys additively (the `workspace.rs`
15//! discipline); `out` is always present and is null without `--out`.
16//!
17//! Errors never echo file contents: findings name repo-relative paths and
18//! key names only.
19//!
20//! ## `deploy plan` (22-01)
21//!
22//! [`load_declared`] is the shared front half of every verb that starts from
23//! a declared repo (render, plan, later pull and verify): it reads
24//! `targets/<env>.yaml`, the gateway manifest and the source tree and
25//! renders them. [`deploy_plan`] then compares that render with a node's
26//! live files, read through a [`TreeReader`]. It opens no gateway session,
27//! sends no REST request, takes no scan-lock and writes nothing: the reader
28//! has no write half to call.
29//!
30//! The [`DeployPlanResult`] `data` keys, in order: `env, gateway, mode,
31//! node, transport, render_sha256, plan_sha256, summary, entries,
32//! delete_set, base, warnings`. Later plans add keys additively.
33//! Entries carry paths and sha256 only, never file bytes.
34//!
35//! ## `deploy pull` (22-08, DPLN-06)
36//!
37//! [`deploy_pull`] bootstraps `gateways/<name>/` from the live master through
38//! the same read-only [`TreeReader`]: it reads, builds a tree that renders
39//! back to the live effective view ([`crate::deploy::pull`]), and only then
40//! writes the local repo. It never writes to the gateway host or the gateway
41//! and resolves no session. Replacing a non-empty `resources/`, `tags/` or
42//! `modes/<env>/` needs `yes` ([`deploy_pull_preview`] names what would be
43//! replaced); `manifest.yaml` and other environments' overlays are never
44//! touched. On unix every removal and write goes through descriptor-relative
45//! calls rooted at the repo (`tree::dirfd`): a directory swapped for a
46//! symlink mid-run is refused, never followed.
47//!
48//! The [`DeployPullResult`] `data` keys, in order: `env, gateway, mode, node,
49//! transport, out, files_written, manifest, render_sha256, skipped,
50//! warnings`.
51//!
52//! ## `deploy verify` (22-10, DPLN-07, DPLN-08, DPLN-09)
53//!
54//! [`deploy_verify`] gathers read-only evidence from the master gateway and
55//! judges it with the pure [`verify::evaluate`] (six checks, fixed order; see
56//! [`crate::deploy::verify`] for the rules). It takes a [`DeployApi`] for the
57//! REST resource, scan and mode reads, a [`GatewayApi`] for the log and the
58//! WebDev tag probes, and a [`TreeReader`] for the live files. It takes no
59//! scan-lock, triggers no scan and writes nothing.
60//!
61//! A red verdict is a normal result: `ok: false`, `slug:
62//! deploy_verify_failed`, and [`DeployVerifyResult::failure_exit_code`]
63//! returns `Some(6)` for the binary to apply after rendering (Decision A).
64//! An unreachable gateway, a refused resource, scan or mode read, and a
65//! transport failure are [`CoreError`]s instead, because there is nothing to
66//! judge. No log text, tag value or file byte reaches the result.
67//!
68//! The [`DeployVerifyResult`] `data` keys, in order: `env, gateway, mode,
69//! node, project, ok, slug, checks, resources_checked, warnings`.
70//!
71//! ## `deploy verify --node` (24-09, DPAIR-01, DPAIR-03, D-12)
72//!
73//! [`deploy_verify_node`] adds the backup of a redundant pair. `master` is
74//! [`deploy_verify`] unchanged; `backup` runs the backup checks only and
75//! `both` runs the master's six and then the backup's. The result then also
76//! carries `backup` (the [`crate::deploy::pair::BackupReport`] shape, with
77//! `budget_s` 0, `waited_s` 0 and `resync` null: verify never waits, never
78//! resyncs and never writes). Both keys are absent for `master`, so its
79//! output is byte-identical to the verb before Phase 24.
80
81use std::collections::{BTreeMap, BTreeSet};
82use std::path::{Path, PathBuf};
83use std::time::Duration;
84
85use serde::Serialize;
86
87use crate::client::logs::{DEFAULT_LOG_LIMIT, LogQuery};
88use crate::client::mode::normalize_deployment_mode;
89use crate::client::query::ListQuery;
90use crate::client::scan::{ScanKind, ScanStatus};
91use crate::client::{DeployApi, GatewayApi};
92use crate::deploy::apply::journal::{journal_on_box, journal_present};
93use crate::deploy::component_problem;
94use crate::deploy::exclude;
95use crate::deploy::layout::{RelPath, collection_path};
96use crate::deploy::ledger::{Ledger, LedgerRoot, ledger_location};
97use crate::deploy::manifest::Manifest;
98use crate::deploy::pair::{
99    BackupCheck, BackupCheckId, BackupNode, BackupReport, BackupSignals, SecretCheckMode,
100    SecretNamesReport, TickOutcome, check_secret_names, collect_referenced_secrets, compare_files,
101    declared_secret_providers, effective_files, exempt_unreadable, files_check,
102    files_skipped_check, mode_backup_check, read_back_backup_check, unreachable_check,
103    unreadable_units, unreadable_warnings, verify_report,
104};
105use crate::deploy::plan::{
106    BaseTree, PlanEntry, PlanSummary, UnreadableUnit, apply_base, plan, render_diffs,
107};
108use crate::deploy::pull::{PullInput, REASON_PATH_TOO_LONG, Skipped, generate_manifest, pull};
109use crate::deploy::render::{RenderInput, RenderOutcome, RenderedResource, render};
110use crate::deploy::target::{NodeTarget, Target};
111use crate::deploy::transport::{CORE_COLLECTION, DeleteSet, READ_MAX_ENTRIES, Scope, TreeReader};
112use crate::deploy::tree::{
113    RenderMarker, SourceTree, read_repo_file, require_repo_dir, walk_capped, write_out,
114};
115use crate::deploy::verify::{self, CheckStatus};
116use crate::error::CoreError;
117
118/// The `ign deploy render` result (the envelope's `data`).
119#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct DeployRenderResult {
121    /// The `--env` value.
122    pub env: String,
123    /// Gateway directory name from the target.
124    pub gateway: String,
125    /// Deployment mode from the target.
126    pub mode: String,
127    /// The `--out` directory as given; null without `--out`.
128    pub out: Option<String>,
129    /// Number of rendered files.
130    pub files: usize,
131    /// Total rendered bytes.
132    pub bytes: usize,
133    /// Lowercase hex SHA-256 of the rendered listing (README documents it).
134    pub render_sha256: String,
135    /// Rendered resources, byte-ordered, singletons first.
136    pub resources: Vec<RenderedResource>,
137    /// Tag providers rendered, byte-ordered.
138    pub tag_providers: Vec<String>,
139    /// Collection paths that received a minted uuid, byte-ordered.
140    pub uuids_minted: Vec<String>,
141    /// Collection paths passed through as opaque bytes, byte-ordered.
142    pub opaque_files: Vec<String>,
143    /// Warnings in pipeline order.
144    pub warnings: Vec<String>,
145}
146
147/// Read and parse `targets/<env>.yaml`: the target file's repo-relative
148/// name and the parsed target.
149pub fn load_target(repo: &Path, env: &str) -> Result<(String, Target), CoreError> {
150    if let Some(problem) = component_problem(env) {
151        return Err(CoreError::InvalidInput {
152            reason: format!("--env {env}: {problem}"),
153        });
154    }
155    let target_rel = format!("targets/{env}.yaml");
156    let target_bytes = read_repo_file(
157        repo,
158        &target_rel,
159        Some(&format!("--env {env} selects {target_rel}")),
160    )?;
161    let target = Target::parse(&target_rel, &target_bytes)?;
162    Ok((target_rel, target))
163}
164
165/// A declared repo, loaded and rendered for one environment.
166#[derive(Debug, Clone)]
167pub struct Declared {
168    /// The `--env` value.
169    pub env: String,
170    /// Repo-relative path of the target file (`targets/<env>.yaml`).
171    pub target_file: String,
172    /// The parsed target.
173    pub target: Target,
174    /// The parsed gateway manifest.
175    pub manifest: Manifest,
176    /// The render of the declared tree.
177    pub outcome: RenderOutcome,
178}
179
180impl Declared {
181    /// The effective tag provider exclusion set of this target and manifest
182    /// ([`exclude::effective`]): byte-sorted, always holding `System`. Every
183    /// host read scope and every plan of this tree uses it.
184    pub fn excluded_tag_providers(&self) -> Vec<String> {
185        exclude::effective(&self.target, Some(&self.manifest))
186    }
187}
188
189/// Load the target, the manifest and the source tree of `env` under `repo`
190/// and render them. Reads the repo and nothing else.
191pub fn load_declared(repo: &Path, env: &str) -> Result<Declared, CoreError> {
192    load_declared_in(repo, repo, env)
193}
194
195/// [`load_declared`] with the target read from `targets` instead of `repo`
196/// (`--targets DIR`, D-14): `targets/<env>.yaml` comes from `targets`, the
197/// `gateways/<gateway>/` tree from `repo`. A caller that builds the node's
198/// host passes the same `targets` root to it, so a relative `known_hosts` or
199/// `local.data_dir` resolves against the targets checkout.
200pub fn load_declared_in(repo: &Path, targets: &Path, env: &str) -> Result<Declared, CoreError> {
201    let (target_file, target) = load_target(targets, env)?;
202
203    let gateway_rel = format!("gateways/{}", target.gateway);
204    let gateway_dir = repo.join("gateways").join(&target.gateway);
205    require_repo_dir(repo, "gateways")?;
206    require_repo_dir(repo, &gateway_rel)?;
207
208    let manifest_rel = format!("{gateway_rel}/manifest.yaml");
209    let manifest_bytes = read_repo_file(repo, &manifest_rel, None)?;
210    let manifest = Manifest::parse(&manifest_rel, &manifest_bytes)?;
211
212    let source = SourceTree::read_gateway(&gateway_dir, env)?;
213    let outcome = render(&RenderInput {
214        gateway: &target.gateway,
215        env,
216        manifest: &manifest,
217        target: &target,
218        source: &source,
219    })?;
220    Ok(Declared {
221        env: env.to_string(),
222        target_file,
223        target,
224        manifest,
225        outcome,
226    })
227}
228
229/// Render the declared tree of `env` under `repo`, optionally writing it
230/// (and `render.json`) to `out`.
231pub fn deploy_render(
232    repo: &Path,
233    env: &str,
234    out: Option<&Path>,
235) -> Result<DeployRenderResult, CoreError> {
236    deploy_render_in(repo, repo, env, out)
237}
238
239/// [`deploy_render`] with the target read from `targets` (`--targets DIR`,
240/// D-14). `out` is written wherever the caller names it, never under
241/// `targets`.
242pub fn deploy_render_in(
243    repo: &Path,
244    targets: &Path,
245    env: &str,
246    out: Option<&Path>,
247) -> Result<DeployRenderResult, CoreError> {
248    let Declared {
249        target, outcome, ..
250    } = load_declared_in(repo, targets, env)?;
251
252    // Only a successful render reaches the filesystem.
253    if let Some(out) = out {
254        let marker = RenderMarker::for_tree(&target.gateway, env, &target.mode, &outcome.tree);
255        write_out(&outcome.tree, out, &marker)?;
256    }
257
258    Ok(DeployRenderResult {
259        env: env.to_string(),
260        gateway: target.gateway.clone(),
261        mode: target.mode.clone(),
262        out: out.map(|p| p.to_string_lossy().into_owned()),
263        files: outcome.tree.files.len(),
264        bytes: outcome.tree.bytes(),
265        render_sha256: outcome.tree.render_sha256(),
266        resources: outcome.resources,
267        tag_providers: outcome.tag_providers,
268        uuids_minted: outcome.uuids_minted,
269        opaque_files: outcome.opaque_files,
270        warnings: outcome.warnings,
271    })
272}
273
274/// The `ign deploy plan` result (the envelope's `data`).
275#[derive(Debug, Clone, PartialEq, Serialize)]
276pub struct DeployPlanResult {
277    /// The `--env` value.
278    pub env: String,
279    /// Gateway directory name from the target.
280    pub gateway: String,
281    /// Deployment mode from the target.
282    pub mode: String,
283    /// The node that was read: always `master` in this phase.
284    pub node: String,
285    /// How the node's host was read: `local` or `ssh`.
286    pub transport: String,
287    /// Lowercase hex SHA-256 of the rendered listing.
288    pub render_sha256: String,
289    /// Lowercase hex SHA-256 of the canonical plan text (D-08).
290    pub plan_sha256: String,
291    /// Counts by status; `has_changes` ignores `unmanaged`.
292    pub summary: PlanSummary,
293    /// One entry per unit, in unit-key byte order.
294    pub entries: Vec<PlanEntry>,
295    /// The files plan proposes to delete: the declared-names ledger's files
296    /// minus the rendered files (22-03). Empty without a ledger.
297    pub delete_set: DeleteSet,
298    /// The `--base` render the drift was computed against; null without it.
299    pub base: Option<BaseSummary>,
300    /// Render warnings, then plan warnings.
301    pub warnings: Vec<String>,
302    /// The effective tag provider exclusion set: byte-sorted, always holds
303    /// `System`. Those providers were not read and are never written or
304    /// deleted. Not part of `plan_sha256`.
305    pub excluded_tag_providers: Vec<String>,
306    /// Units the host holds but could not serve whole (a Windows sftp host
307    /// path of 240 characters or more), in key byte order: never an entry,
308    /// never in the delete set, one `U` line each in `plan_sha256`. Always
309    /// empty on Linux and local hosts.
310    pub unreadable: Vec<UnreadableUnit>,
311}
312
313/// Options of `deploy plan`.
314#[derive(Debug, Clone, Default)]
315pub struct PlanOptions {
316    /// A directory written by `deploy render --out`, re-hashed and used as
317    /// the base of the three-way drift check.
318    pub base: Option<PathBuf>,
319    /// Add redacted unified text diffs to the entries.
320    pub diff: bool,
321}
322
323/// What `--base` was: the directory and its recomputed digest.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
325pub struct BaseSummary {
326    /// The `--base` directory as given.
327    pub dir: String,
328    /// The base's recomputed `render_sha256`.
329    pub render_sha256: String,
330    /// Number of files in the base (`render.json` excluded).
331    pub files: usize,
332}
333
334/// Plan `declared` against the live files `reader` returns for the master
335/// node. Reads once; never writes, never opens a gateway session, never
336/// prints.
337pub async fn deploy_plan(
338    declared: &Declared,
339    reader: &dyn TreeReader,
340    opts: &PlanOptions,
341) -> Result<DeployPlanResult, CoreError> {
342    let node = &declared.target.nodes.master;
343    let transport = match (&node.ssh, &node.local) {
344        (Some(_), _) => "ssh",
345        (None, Some(_)) => "local",
346        (None, None) => "none",
347    };
348    // A base that is not exactly a clean render is refused before any
349    // transport work (22-04).
350    let base = match &opts.base {
351        Some(dir) => {
352            let dir = dir.clone();
353            let (gateway, env, mode) = (
354                declared.target.gateway.clone(),
355                declared.env.clone(),
356                declared.target.mode.clone(),
357            );
358            let read =
359                tokio::task::spawn_blocking(move || BaseTree::read(&dir, &gateway, &env, &mode))
360                    .await
361                    .map_err(|e| CoreError::Internal(format!("--base read task failed: {e}")))?;
362            Some(read?)
363        }
364        None => None,
365    };
366    // An interrupted apply left its journal: the tree may be torn, so nothing
367    // is read or planned until `rollback --run` recovers it (DAPL-09, D-09).
368    // An unreadable journal blocks the same way. Checked after the base (a
369    // bad one costs no transport work) and before the collections are read.
370    if let Some(journal) = journal_on_box(
371        reader,
372        node,
373        &declared.target.gateway,
374        &declared.target.mode,
375    )
376    .await?
377    {
378        return Err(journal_present(
379            &journal,
380            &declared.env,
381            node,
382            &declared.target.gateway,
383            &declared.target.mode,
384        ));
385    }
386    let excluded = declared.excluded_tag_providers();
387    let scope = Scope::from_manifest(&declared.manifest, &declared.target.mode, &excluded);
388    let observed = reader.read(&scope).await?;
389    let gateway = &declared.target.gateway;
390    let mode = &declared.target.mode;
391    // An absent ledger is a first deploy (empty delete set); one that exists
392    // but cannot be trusted is a refusal, never an empty delete set (D-07).
393    let ledger = match reader.read_ledger(gateway, mode).await? {
394        Some(bytes) => Some(Ledger::parse(
395            &ledger_location(LedgerRoot::for_node(node), gateway, mode),
396            &bytes,
397            gateway,
398            mode,
399        )?),
400        None => None,
401    };
402    let mut planned = plan(
403        &declared.outcome.tree,
404        &observed,
405        ledger.as_ref(),
406        &scope,
407        &excluded,
408    );
409    if let Some(base) = &base {
410        apply_base(&mut planned, base);
411    }
412    if opts.diff {
413        render_diffs(&mut planned, &declared.outcome.tree, &observed);
414    }
415
416    let mut warnings = declared.outcome.warnings.clone();
417    warnings.extend(planned.warnings);
418    Ok(DeployPlanResult {
419        env: declared.env.clone(),
420        gateway: declared.target.gateway.clone(),
421        mode: declared.target.mode.clone(),
422        node: "master".to_string(),
423        transport: transport.to_string(),
424        render_sha256: declared.outcome.tree.render_sha256(),
425        plan_sha256: planned.plan_sha256,
426        summary: planned.summary,
427        entries: planned.entries,
428        delete_set: planned.delete_set,
429        base: base.map(|b| BaseSummary {
430            dir: b.dir,
431            render_sha256: b.render_sha256,
432            files: b.files.len(),
433        }),
434        warnings,
435        excluded_tag_providers: excluded,
436        unreadable: planned.unreadable,
437    })
438}
439
440/// The `ign deploy pull` result (the envelope's `data`).
441#[derive(Debug, Clone, PartialEq, Serialize)]
442pub struct DeployPullResult {
443    /// The `--env` value.
444    pub env: String,
445    /// Gateway directory name from the target.
446    pub gateway: String,
447    /// Deployment mode from the target; its collection is the overlay.
448    pub mode: String,
449    /// The node that was read: always `master` in this phase.
450    pub node: String,
451    /// How the node's host was read: `local` or `ssh`.
452    pub transport: String,
453    /// The directory written, repo-relative: `gateways/<name>`.
454    pub out: String,
455    /// Source files written under `resources/`, `tags/` and `modes/<env>/`
456    /// (the manifest is not counted).
457    pub files_written: usize,
458    /// `kept` when `manifest.yaml` already existed, `generated` otherwise.
459    pub manifest: String,
460    /// Lowercase hex SHA-256 of what `deploy render` produces from the
461    /// pulled tree.
462    pub render_sha256: String,
463    /// Every live unit (or type) not written, byte-sorted by path, each with
464    /// a reason. Never a value.
465    pub skipped: Vec<Skipped>,
466    /// Render warnings of the pulled tree, then pull warnings.
467    pub warnings: Vec<String>,
468    /// The effective tag provider exclusion set: byte-sorted, always holds
469    /// `System`. Those providers were not listed or read, and are left out
470    /// of a generated manifest.
471    pub excluded_tag_providers: Vec<String>,
472}
473
474/// What an existing `gateways/<name>/` holds in the three subtrees a pull
475/// replaces. Gateway-relative paths, files in `resources`, `tags`,
476/// `modes/<env>` order and byte order within each.
477struct Existing {
478    files: Vec<String>,
479    /// Every directory below and including each subtree root.
480    dirs: BTreeSet<String>,
481}
482
483/// Whether `repo/rel` exists, without following a symlink.
484fn repo_entry_exists(repo: &Path, rel: &str) -> Result<bool, CoreError> {
485    match std::fs::symlink_metadata(repo.join(rel)) {
486        Ok(_) => Ok(true),
487        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
488        Err(e) => Err(CoreError::deploy_tree_invalid(
489            rel,
490            None,
491            None,
492            format!("cannot inspect ({})", e.kind()),
493        )),
494    }
495}
496
497/// Inspect the subtrees `deploy pull` would replace. A symlink at any level
498/// of them, or anywhere under them, is `deploy_tree_invalid` and nothing is
499/// changed. `None` when `gateways/<gateway>/` does not exist.
500fn scan_existing(repo: &Path, gateway: &str, env: &str) -> Result<Option<Existing>, CoreError> {
501    let gateway_rel = format!("gateways/{gateway}");
502    for rel in ["gateways", gateway_rel.as_str()] {
503        if !repo_entry_exists(repo, rel)? {
504            return Ok(None);
505        }
506        require_repo_dir(repo, rel)?;
507    }
508    let mut existing = Existing {
509        files: Vec::new(),
510        dirs: BTreeSet::new(),
511    };
512    for sub in [
513        "resources".to_string(),
514        "tags".to_string(),
515        format!("modes/{env}"),
516    ] {
517        if sub.starts_with("modes/") {
518            let modes = format!("{gateway_rel}/modes");
519            if !repo_entry_exists(repo, &modes)? {
520                continue;
521            }
522            require_repo_dir(repo, &modes)?;
523        }
524        let rel = format!("{gateway_rel}/{sub}");
525        if !repo_entry_exists(repo, &rel)? {
526            continue;
527        }
528        require_repo_dir(repo, &rel)?;
529        let mut files: BTreeMap<String, PathBuf> = BTreeMap::new();
530        let mut dirs: BTreeSet<String> = BTreeSet::new();
531        walk_capped(
532            &repo.join(&rel),
533            &sub,
534            &mut files,
535            &mut dirs,
536            READ_MAX_ENTRIES,
537        )
538        .map_err(|why| CoreError::deploy_tree_invalid(&rel, None, None, why))?;
539        existing.dirs.insert(sub.clone());
540        existing.dirs.extend(dirs);
541        existing.files.extend(files.into_keys());
542    }
543    Ok(Some(existing))
544}
545
546/// The refusal prose of a pull that would replace `existing`.
547fn pull_prose(gateway: &str, env: &str, existing: &Existing) -> String {
548    let shown: Vec<&str> = existing.files.iter().take(5).map(String::as_str).collect();
549    let more = if existing.files.len() > shown.len() {
550        ", ..."
551    } else {
552        ""
553    };
554    format!(
555        "deploy pull would replace gateways/{gateway}/resources, tags and modes/{env} with the live tree of nodes.master ({} existing files, e.g. {}{more})",
556        existing.files.len(),
557        shown.join(", ")
558    )
559}
560
561/// What `deploy pull` would replace: `Some(prose)` when the gateway
562/// directory holds files in `resources/`, `tags/` or `modes/<env>/` (the
563/// text the `--yes` guard refuses with), `None` when it is absent or holds
564/// nothing there. Reads the local repo only: no gateway is contacted.
565pub fn deploy_pull_preview(repo: &Path, env: &str) -> Result<Option<String>, CoreError> {
566    deploy_pull_preview_in(repo, repo, env)
567}
568
569/// [`deploy_pull_preview`] with the target read from `targets` (`--targets
570/// DIR`, D-14); the existing tree is still scanned under `repo`.
571pub fn deploy_pull_preview_in(
572    repo: &Path,
573    targets: &Path,
574    env: &str,
575) -> Result<Option<String>, CoreError> {
576    let (_, target) = load_target(targets, env)?;
577    Ok(scan_existing(repo, &target.gateway, env)?
578        .filter(|existing| !existing.files.is_empty())
579        .map(|existing| pull_prose(&target.gateway, env, &existing)))
580}
581
582fn transport_label(node: &NodeTarget) -> &'static str {
583    match (&node.ssh, &node.local) {
584        (Some(_), _) => "ssh",
585        (None, Some(_)) => "local",
586        (None, None) => "none",
587    }
588}
589
590/// Bootstrap `gateways/<gateway>/` from the live master. See the module doc.
591///
592/// `yes` confirms replacing existing `resources/`, `tags/` and `modes/<env>/`
593/// files; without it a non-empty tree is refused as `confirmation_required`
594/// before the gateway is read and before any write.
595pub async fn deploy_pull(
596    repo: &Path,
597    env: &str,
598    reader: &dyn TreeReader,
599    yes: bool,
600) -> Result<DeployPullResult, CoreError> {
601    deploy_pull_in(repo, repo, env, reader, yes).await
602}
603
604/// [`deploy_pull`] with the target read from `targets` (`--targets DIR`,
605/// D-14). The pulled tree and the manifest still live under `repo`.
606pub async fn deploy_pull_in(
607    repo: &Path,
608    targets: &Path,
609    env: &str,
610    reader: &dyn TreeReader,
611    yes: bool,
612) -> Result<DeployPullResult, CoreError> {
613    let (_, target) = load_target(targets, env)?;
614    let gateway = target.gateway.clone();
615    let existing = scan_existing(repo, &gateway, env)?;
616    if let Some(existing) = existing.as_ref().filter(|e| !e.files.is_empty())
617        && !yes
618    {
619        return Err(CoreError::ConfirmationRequired {
620            operation: pull_prose(&gateway, env, existing),
621        });
622    }
623
624    // The manifest: an existing one is used and kept (D-10); otherwise one is
625    // generated from the live types and tag providers.
626    let manifest_rel = format!("gateways/{gateway}/manifest.yaml");
627    let mut left_out: Vec<Skipped> = Vec::new();
628    let (manifest, generated, excluded) = if repo_entry_exists(repo, &manifest_rel)? {
629        let bytes = read_repo_file(repo, &manifest_rel, None)?;
630        let manifest = Manifest::parse(&manifest_rel, &bytes)?;
631        // A kept manifest that declares an excluded provider is refused
632        // before any host read: render would refuse the pulled tree anyway.
633        let excluded = exclude::effective(&target, Some(&manifest));
634        let contradictions = exclude::contradictions(&excluded, &manifest, &manifest_rel);
635        if !contradictions.is_empty() {
636            return Err(CoreError::deploy_findings(contradictions));
637        }
638        (manifest, None, excluded)
639    } else {
640        let excluded = exclude::effective(&target, None);
641        let mut live_types = reader.list_types(CORE_COLLECTION).await?;
642        live_types.extend(reader.list_types(&target.mode).await?);
643        // Provider discovery lists directory names only: no provider is read,
644        // so an excluded one is never walked (and an empty provider directory
645        // is named too).
646        let mut names = reader.list_tag_providers(CORE_COLLECTION).await?;
647        names.extend(reader.list_tag_providers(&target.mode).await?);
648        let providers: Vec<String> = names
649            .into_iter()
650            .filter(|p| !exclude::is_excluded(p, &excluded))
651            .collect::<BTreeSet<String>>()
652            .into_iter()
653            .collect();
654        let (yaml, skipped) = generate_manifest(&live_types, &providers);
655        left_out = skipped;
656        let manifest = Manifest::parse(&manifest_rel, yaml.as_bytes())?;
657        (manifest, Some(yaml), excluded)
658    };
659
660    let scope = Scope::from_manifest(&manifest, &target.mode, &excluded);
661    let observed = reader.read(&scope).await?;
662    let outcome = pull(&PullInput {
663        gateway: &gateway,
664        env,
665        target: &target,
666        manifest: &manifest,
667        observed: &observed,
668    })?;
669
670    write_pulled(
671        repo,
672        &gateway,
673        existing.as_ref(),
674        &outcome.source,
675        generated.as_deref(),
676    )?;
677
678    let removed = removed_unreadable_warnings(&gateway, env, existing.as_ref(), &outcome.skipped);
679    left_out.extend(outcome.skipped);
680    left_out.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
681    let mut warnings = outcome.rendered.warnings.clone();
682    warnings.extend(removed);
683    warnings.extend(observed.local_units.iter().map(|unit| {
684        format!(
685            "{unit} also exists in the `local` collection, which the gateway loads over the mode collection; pull did not read the `local` copy"
686        )
687    }));
688    Ok(DeployPullResult {
689        env: env.to_string(),
690        gateway: gateway.clone(),
691        mode: target.mode.clone(),
692        node: "master".to_string(),
693        transport: transport_label(&target.nodes.master).to_string(),
694        out: format!("gateways/{gateway}"),
695        files_written: outcome.source.files.len(),
696        manifest: if generated.is_some() {
697            "generated"
698        } else {
699            "kept"
700        }
701        .to_string(),
702        render_sha256: outcome.rendered.tree.render_sha256(),
703        skipped: left_out,
704        warnings,
705        excluded_tag_providers: excluded,
706    })
707}
708
709/// The most removed repo files one [`removed_unreadable_warnings`] line
710/// names before it counts the rest.
711const REMOVED_FILES_SHOWN: usize = 10;
712
713/// One warning per `path_too_long` unit whose repo copy this pull removed
714/// (quick-261029-ms3, WR-05). `--yes` replaces `resources/`, `tags/` and
715/// `modes/<env>/`, and an unreadable unit is never written in part, so the
716/// files the repo held for it are gone after the pull. They are not kept: a
717/// kept copy is not what the host serves, so the pulled tree would no longer
718/// render to the `render_sha256` pull reports and render then plan would not
719/// be empty. The removal is named instead of silent, with every removed
720/// repo file (gateway-relative under `gateways/<gateway>/`); git still holds
721/// them. Nothing for a unit the repo did not hold.
722fn removed_unreadable_warnings(
723    gateway: &str,
724    env: &str,
725    existing: Option<&Existing>,
726    skipped: &[Skipped],
727) -> Vec<String> {
728    let Some(existing) = existing else {
729        return Vec::new();
730    };
731    let mode_prefix = format!("modes/{env}/");
732    let mut out = Vec::new();
733    for unit in skipped.iter().filter(|s| s.reason == REASON_PATH_TOO_LONG) {
734        let under = format!("{}/", unit.path);
735        let removed: Vec<String> = existing
736            .files
737            .iter()
738            .filter(|file| {
739                let base = file.strip_prefix(&mode_prefix).unwrap_or(file);
740                RelPath::new(base)
741                    .ok()
742                    .and_then(|p| collection_path(&p).ok())
743                    .is_some_and(|c| c.as_str() == unit.path || c.as_str().starts_with(&under))
744            })
745            .map(|file| format!("gateways/{gateway}/{file}"))
746            .collect();
747        if removed.is_empty() {
748            continue;
749        }
750        let take = removed.len().min(REMOVED_FILES_SHOWN);
751        let mut named = removed[..take].join(", ");
752        if removed.len() > take {
753            named.push_str(&format!(" and {} more", removed.len() - take));
754        }
755        out.push(format!(
756            "{} has a live path the host's sftp server cannot read ({REASON_PATH_TOO_LONG}), so pull did not write it and removed its {} repo file(s): {named}; the live unit is unchanged and git still holds the removed files",
757            unit.path,
758            removed.len()
759        ));
760    }
761    out
762}
763
764/// A name that reaches the repo from the gateway is checked once more here:
765/// render already refused unsafe names, so this only fails closed.
766fn safe_component(name: &str) -> bool {
767    !name.is_empty()
768        && name != "."
769        && name != ".."
770        && !name.contains(['/', '\\', '\0'])
771        && !name.chars().any(char::is_control)
772}
773
774/// Refuse a pulled tree that cannot be written whole. This runs before the
775/// first delete, so a pull that would fail part-way fails with the existing
776/// tree untouched (CR-01): an unsafe path component, two paths that collide
777/// when case is folded (a case-insensitive filesystem keeps only one), and a
778/// path that is both a file and a directory.
779fn validate_pulled(gateway: &str, source: &SourceTree) -> Result<(), CoreError> {
780    let refuse = |path: &str, why: String| {
781        CoreError::deploy_tree_invalid(format!("gateways/{gateway}/{path}"), None, None, why)
782    };
783    let mut folded: BTreeMap<String, &str> = BTreeMap::new();
784    let mut dir_folds: BTreeSet<String> = BTreeSet::new();
785    for path in source.files.keys() {
786        let parts: Vec<&str> = path.components().collect();
787        if parts.iter().any(|c| !safe_component(c)) {
788            return Err(refuse(
789                path.as_str(),
790                "has an unsafe path component; refused before anything was changed".to_string(),
791            ));
792        }
793        let fold = path.as_str().to_lowercase();
794        if let Some(other) = folded.insert(fold.clone(), path.as_str()) {
795            return Err(refuse(
796                path.as_str(),
797                format!(
798                    "collides with {other} when case is folded; a case-insensitive filesystem keeps only one. Refused before anything was changed"
799                ),
800            ));
801        }
802        for end in fold.match_indices('/').map(|(i, _)| i) {
803            dir_folds.insert(fold[..end].to_string());
804        }
805    }
806    for (fold, path) in &folded {
807        if dir_folds.contains(fold) {
808            return Err(refuse(
809                path,
810                "is both a file and a directory in the pulled tree; refused before anything was changed"
811                    .to_string(),
812            ));
813        }
814    }
815    Ok(())
816}
817
818/// Validate the pulled tree, then replace what a pull replaces and write it
819/// (see [`write_pulled_fs`]). The validation comes first so no destructive
820/// step runs for a tree that is known to be unwritable.
821fn write_pulled(
822    repo: &Path,
823    gateway: &str,
824    existing: Option<&Existing>,
825    source: &SourceTree,
826    manifest: Option<&str>,
827) -> Result<(), CoreError> {
828    validate_pulled(gateway, source)?;
829    write_pulled_fs(repo, gateway, existing, source, manifest)
830}
831
832/// Remove what a pull replaces, then write the pulled tree and, when one was
833/// generated, the manifest. Unix: every step is descriptor-relative and
834/// rooted at the repo, so no component can be redirected by a symlink.
835#[cfg(unix)]
836fn write_pulled_fs(
837    repo: &Path,
838    gateway: &str,
839    existing: Option<&Existing>,
840    source: &SourceTree,
841    manifest: Option<&str>,
842) -> Result<(), CoreError> {
843    use crate::deploy::tree::dirfd::{self, FdError, Kind};
844    use std::io::Write;
845
846    let fail = |what: &str, e: FdError| match e {
847        FdError::Changed => CoreError::deploy_tree_invalid(
848            what,
849            None,
850            None,
851            "changed while it was written (a symlink or a non-directory appeared); refused, nothing outside the gateway directory was touched",
852        ),
853        FdError::Io(io) => CoreError::Internal(format!("cannot write {what}: {io}")),
854        other => CoreError::Internal(format!("cannot write {what}: {other:?}")),
855    };
856
857    // The repo root is the operator's own and may legitimately be reached
858    // through a symlink (`--repo link`), so it is resolved once here; every
859    // component below it is then opened without following symlinks.
860    let repo_real = std::fs::canonicalize(repo).map_err(|e| {
861        CoreError::Internal(format!("cannot resolve the repo {}: {e}", repo.display()))
862    })?;
863    let root = dirfd::open_root(&repo_real).map_err(|e| fail("the repo", e))?;
864    let gateways = dirfd::ensure_dir(&root, "gateways").map_err(|e| fail("gateways", e))?;
865    let gw_label = format!("gateways/{gateway}");
866    let gw = dirfd::ensure_dir(&gateways, gateway).map_err(|e| fail(&gw_label, e))?;
867
868    if let Some(existing) = existing {
869        for file in &existing.files {
870            let parts: Vec<&str> = file.split('/').collect();
871            let (name, parents) = parts.split_last().ok_or_else(|| {
872                CoreError::Internal("an existing file has an empty path".to_string())
873            })?;
874            let label = format!("{gw_label}/{file}");
875            let dir = dirfd::walk(&gw, parents).map_err(|e| fail(&label, e))?;
876            match dirfd::kind_at(&dir, name).map_err(|e| fail(&label, e))? {
877                Kind::File => dirfd::unlink_at(&dir, name).map_err(|e| fail(&label, e))?,
878                Kind::Missing => {}
879                _ => return Err(fail(&label, FdError::Changed)),
880            }
881        }
882        // children before parents: a child path sorts after its parent
883        for dir_path in existing.dirs.iter().rev() {
884            let parts: Vec<&str> = dir_path.split('/').collect();
885            let Some((name, parents)) = parts.split_last() else {
886                continue;
887            };
888            let label = format!("{gw_label}/{dir_path}");
889            let parent = dirfd::walk(&gw, parents).map_err(|e| fail(&label, e))?;
890            match dirfd::rmdir_at(&parent, name) {
891                Ok(()) | Err(FdError::Missing) => {}
892                Err(FdError::NotEmpty) => {
893                    return Err(CoreError::deploy_tree_invalid(
894                        label,
895                        None,
896                        None,
897                        "is not empty after the pull removed its files (something wrote into it); nothing more was changed",
898                    ));
899                }
900                Err(e) => return Err(fail(&label, e)),
901            }
902        }
903    }
904
905    if let Some(text) = manifest {
906        let label = format!("{gw_label}/manifest.yaml");
907        let mut file = dirfd::create_new(&gw, "manifest.yaml").map_err(|e| fail(&label, e))?;
908        file.write_all(text.as_bytes())
909            .map_err(|e| CoreError::Internal(format!("cannot write {label}: {e}")))?;
910    }
911    for (path, bytes) in &source.files {
912        let label = format!("{gw_label}/{path}");
913        let parts: Vec<&str> = path.components().collect();
914        if parts.iter().any(|c| !safe_component(c)) {
915            return Err(CoreError::deploy_tree_invalid(
916                label,
917                None,
918                None,
919                "has an unsafe path component; refused",
920            ));
921        }
922        let (name, parents) = parts
923            .split_last()
924            .ok_or_else(|| CoreError::Internal("a pulled file has an empty path".to_string()))?;
925        let dir = dirfd::ensure_walk(&gw, parents).map_err(|e| fail(&label, e))?;
926        let mut file = dirfd::create_new(&dir, name).map_err(|e| fail(&label, e))?;
927        file.write_all(bytes)
928            .map_err(|e| CoreError::Internal(format!("cannot write {label}: {e}")))?;
929    }
930    Ok(())
931}
932
933/// Non-unix: std has no descriptor-relative primitives, so the replaced
934/// subtrees were checked for symlinks by [`scan_existing`] just before and
935/// the operations are path-based (the 22-01 residual for Windows).
936#[cfg(not(unix))]
937fn write_pulled_fs(
938    repo: &Path,
939    gateway: &str,
940    existing: Option<&Existing>,
941    source: &SourceTree,
942    manifest: Option<&str>,
943) -> Result<(), CoreError> {
944    use std::io::Write;
945
946    let io = |what: &Path, e: std::io::Error| {
947        CoreError::Internal(format!("cannot write {}: {e}", what.display()))
948    };
949    let gw = repo.join("gateways").join(gateway);
950    std::fs::create_dir_all(&gw).map_err(|e| io(&gw, e))?;
951    if let Some(existing) = existing {
952        for file in &existing.files {
953            let path = file.split('/').fold(gw.clone(), |acc, c| acc.join(c));
954            std::fs::remove_file(&path).map_err(|e| io(&path, e))?;
955        }
956        for dir_path in existing.dirs.iter().rev() {
957            let path = dir_path.split('/').fold(gw.clone(), |acc, c| acc.join(c));
958            std::fs::remove_dir(&path).map_err(|e| io(&path, e))?;
959        }
960    }
961    let create = |path: &Path, bytes: &[u8]| -> Result<(), CoreError> {
962        if let Some(parent) = path.parent() {
963            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
964        }
965        let mut file = std::fs::OpenOptions::new()
966            .write(true)
967            .create_new(true)
968            .open(path)
969            .map_err(|e| io(path, e))?;
970        file.write_all(bytes).map_err(|e| io(path, e))
971    };
972    if let Some(text) = manifest {
973        create(&gw.join("manifest.yaml"), text.as_bytes())?;
974    }
975    for (path, bytes) in &source.files {
976        let parts: Vec<&str> = path.components().collect();
977        if parts.iter().any(|c| !safe_component(c)) {
978            return Err(CoreError::deploy_tree_invalid(
979                format!("gateways/{gateway}/{path}"),
980                None,
981                None,
982                "has an unsafe path component; refused",
983            ));
984        }
985        let dest = parts.iter().fold(gw.clone(), |acc, c| acc.join(c));
986        create(&dest, bytes)?;
987    }
988    Ok(())
989}
990
991/// Options of `deploy verify`.
992#[derive(Debug, Clone)]
993pub struct VerifyOptions {
994    /// How long the probes are polled before the verdict.
995    pub probe_deadline: Duration,
996    /// Time between two probe polls.
997    pub probe_interval: Duration,
998    /// How long after a scan completed the gateway log is trusted to hold its
999    /// lines (CV1). Skipped when the gateway has logged since.
1000    pub log_settle: Duration,
1001}
1002
1003impl Default for VerifyOptions {
1004    fn default() -> Self {
1005        Self {
1006            probe_deadline: verify::PROBE_DEADLINE,
1007            probe_interval: verify::PROBE_INTERVAL,
1008            log_settle: Duration::from_millis(verify::LOG_SETTLE_MS.unsigned_abs()),
1009        }
1010    }
1011}
1012
1013/// The `ign deploy verify` result (the envelope's `data`).
1014#[derive(Debug, Clone, PartialEq, Serialize)]
1015pub struct DeployVerifyResult {
1016    /// The `--env` value.
1017    pub env: String,
1018    /// Gateway directory name from the target.
1019    pub gateway: String,
1020    /// Deployment mode from the target.
1021    pub mode: String,
1022    /// The node that was verified: `master` (the default), `backup` or
1023    /// `both`.
1024    pub node: String,
1025    /// The WebDev project the tag probes were read through.
1026    pub project: String,
1027    /// True when no check failed.
1028    pub ok: bool,
1029    /// `deploy_verify_failed` when `ok` is false, else null.
1030    pub slug: Option<&'static str>,
1031    /// The six checks in fixed order.
1032    pub checks: Vec<verify::Check>,
1033    /// Declared resources `read_back` compared.
1034    pub resources_checked: usize,
1035    /// Non-failing notes.
1036    pub warnings: Vec<String>,
1037    /// The effective tag provider exclusion set: byte-sorted, always holds
1038    /// `System`. Those providers were not read or checked.
1039    pub excluded_tag_providers: Vec<String>,
1040    /// The backup node's checks (`--node backup|both`, V2 shape with
1041    /// `budget_s` 0, `waited_s` 0 and `resync` null); absent for `master`.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub backup: Option<BackupReport>,
1044    /// The secret-name check of each node `--node` ran (V7); absent for
1045    /// `master`.
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    pub secrets: Option<SecretNamesReport>,
1048}
1049
1050impl DeployVerifyResult {
1051    /// The red-verdict exit passthrough (Decision A), mirroring
1052    /// [`crate::actions::testing::TestingRunResult::failure_exit_code`]:
1053    /// `Some(6)` (target state) when a check failed, `None` otherwise. The
1054    /// envelope renders first; the binary decides the exit after.
1055    pub fn failure_exit_code(&self) -> Option<u8> {
1056        (!self.ok).then_some(6)
1057    }
1058}
1059
1060/// Most pages of log lines read before the answer is called truncated.
1061const LOG_PAGES_MAX: usize = 5;
1062
1063/// Verify the master gateway against `declared` (DPLN-07, DPLN-08, DPLN-09).
1064///
1065/// Reads only: `scan_status`, `deployment_mode`, `resource_list` or
1066/// `resource_singleton` per declared non-tag type, one read of the live files
1067/// through `reader`, the gateway log, and the tag probes through `project`'s
1068/// WebDev route. It acquires no scan-lock, triggers no scan and writes no
1069/// file. Evidence is gathered, then judged by [`verify::evaluate`].
1070///
1071/// A gateway that cannot be reached ([`CoreError::Network`]), any refusal of
1072/// the resource, scan or mode reads, and a transport failure return a
1073/// [`CoreError`]: there is nothing to judge. A refusal of the log or tag
1074/// source is evidence, not an error: that check fails with the refusal's slug
1075/// (never its text). A red verdict is a normal `Ok` result; the binary maps
1076/// it to exit 6 through [`DeployVerifyResult::failure_exit_code`].
1077pub async fn deploy_verify(
1078    declared: &Declared,
1079    project: &str,
1080    deploy: &dyn DeployApi,
1081    gateway: &dyn GatewayApi,
1082    reader: &dyn TreeReader,
1083    opts: &VerifyOptions,
1084) -> Result<DeployVerifyResult, CoreError> {
1085    let expected =
1086        verify::Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
1087
1088    let scan = deploy.scan_status(ScanKind::Config).await?;
1089    let scan_read_at = std::time::Instant::now();
1090    let mode = normalize_deployment_mode(deploy.deployment_mode().await?);
1091    let read_back = gather_read_back(deploy, &expected).await?;
1092
1093    // The live files, once, over the read-only transport. Tag units are the
1094    // ones judged; reading is also what proves the host is reachable.
1095    let excluded = declared.excluded_tag_providers();
1096    let scope = Scope::from_manifest(&declared.manifest, &declared.target.mode, &excluded);
1097    let live = reader.read(&scope).await?;
1098    let planned = plan(&declared.outcome.tree, &live, None, &scope, &excluded);
1099    let files = planned
1100        .entries
1101        .iter()
1102        .filter(|entry| expected.tag_units.contains(&entry.key))
1103        .map(|entry| (entry.key.clone(), entry.status))
1104        .collect();
1105
1106    let logs = if scan.scan_active {
1107        // Its lines are not readable yet; the check reports "not observed".
1108        Ok(Vec::new())
1109    } else {
1110        settle_logs(gateway, &scan, scan_read_at, opts.log_settle).await?;
1111        read_logs(gateway, verify::log_window_start(&scan)).await?
1112    };
1113    let probes = if expected.probes.is_empty() {
1114        None
1115    } else {
1116        Some(poll_probes(gateway, project, &expected.probes, opts).await?)
1117    };
1118
1119    let observed = verify::ObservedState {
1120        scan,
1121        mode,
1122        read_back,
1123        files,
1124        logs,
1125        probes,
1126        unreadable: planned
1127            .unreadable
1128            .iter()
1129            .map(|unit| (unit.key.clone(), unit.declared))
1130            .collect(),
1131    };
1132    let report = verify::evaluate(&expected, &observed);
1133    Ok(DeployVerifyResult {
1134        env: declared.env.clone(),
1135        gateway: declared.target.gateway.clone(),
1136        mode: declared.target.mode.clone(),
1137        node: "master".to_string(),
1138        project: project.to_string(),
1139        ok: report.ok,
1140        slug: report.slug,
1141        checks: report.checks,
1142        resources_checked: report.resources_checked,
1143        warnings: report.warnings,
1144        excluded_tag_providers: excluded,
1145        backup: None,
1146        secrets: None,
1147    })
1148}
1149
1150/// Which node(s) `deploy verify --node` looks at (D-12, V6).
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152pub enum VerifyNode {
1153    /// The six master checks; the default and byte-identical to the verb
1154    /// before Phase 24.
1155    Master,
1156    /// Only the backup checks.
1157    Backup,
1158    /// The six master checks, then the backup checks.
1159    Both,
1160}
1161
1162impl VerifyNode {
1163    /// The word the flag and `data.node` use.
1164    pub fn as_str(self) -> &'static str {
1165        match self {
1166            VerifyNode::Master => "master",
1167            VerifyNode::Backup => "backup",
1168            VerifyNode::Both => "both",
1169        }
1170    }
1171}
1172
1173/// The master's surfaces `deploy verify --node master|both` reads through.
1174pub type VerifyMasterSurfaces<'a> = (&'a dyn DeployApi, &'a dyn GatewayApi);
1175
1176/// `deploy verify` over one node or both (D-12, V6).
1177///
1178/// `Master` is [`deploy_verify`] unchanged. `Backup` runs the backup checks
1179/// only: the redundancy check (`peerConnected` and not `syncPending`), the
1180/// mode, the REST read-back of the render, and the backup's files against the
1181/// master's live copy, read through `master_reader` (a `warn` when `backup`
1182/// has no file transport). `Both` runs the master's six checks, then the
1183/// backup's. Like `deploy_verify` it never waits, never resyncs and never
1184/// writes: the backup is reached through [`BackupNode`], which has no write
1185/// to express, and the master only through reads.
1186///
1187/// `master` is required for `Master` and `Both`, `backup` for `Backup` and
1188/// `Both`; a missing one is [`CoreError::InvalidInput`].
1189pub async fn deploy_verify_node(
1190    declared: &Declared,
1191    project: &str,
1192    node: VerifyNode,
1193    master: Option<VerifyMasterSurfaces<'_>>,
1194    master_reader: &dyn TreeReader,
1195    backup: Option<&BackupNode<'_>>,
1196    opts: &VerifyOptions,
1197) -> Result<DeployVerifyResult, CoreError> {
1198    let missing = |what: &str| CoreError::InvalidInput {
1199        reason: format!("deploy verify --node {} needs the {what}", node.as_str()),
1200    };
1201    let master_result = if node == VerifyNode::Backup {
1202        None
1203    } else {
1204        let (deploy, gateway) = master.ok_or_else(|| missing("master surfaces"))?;
1205        Some(deploy_verify(declared, project, deploy, gateway, master_reader, opts).await?)
1206    };
1207    if node == VerifyNode::Master {
1208        return master_result.ok_or_else(|| missing("master surfaces"));
1209    }
1210    let backup = backup.ok_or_else(|| missing("backup node"))?;
1211    let expected =
1212        verify::Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
1213    let mut backup_warnings = Vec::new();
1214    let (tick, compared) = verify_backup_tick(
1215        declared,
1216        &expected,
1217        master_reader,
1218        backup,
1219        &mut backup_warnings,
1220    )
1221    .await;
1222    let report = verify_report(&tick);
1223
1224    let mut result = match master_result {
1225        // The master verify has not read the backup, so the backup's
1226        // read-back warnings are carried here exactly as `--node backup`
1227        // reports them (WR-03).
1228        Some(mut result) => {
1229            result.warnings.extend(backup_warnings);
1230            result
1231        }
1232        None => DeployVerifyResult {
1233            env: declared.env.clone(),
1234            gateway: declared.target.gateway.clone(),
1235            mode: declared.target.mode.clone(),
1236            node: String::new(),
1237            project: project.to_string(),
1238            ok: true,
1239            slug: None,
1240            checks: Vec::new(),
1241            resources_checked: compared,
1242            warnings: backup_warnings,
1243            excluded_tag_providers: declared.excluded_tag_providers(),
1244            backup: None,
1245            secrets: None,
1246        },
1247    };
1248    // The secret names each checked node holds, master first (V7). Names
1249    // only: `secret_names` never reads a value.
1250    let secrets = verify_secret_names(
1251        declared,
1252        master
1253            .map(|(deploy, _)| deploy)
1254            .filter(|_| node != VerifyNode::Backup),
1255        Some(backup),
1256    )
1257    .await?;
1258    let secrets_ok = secrets.nodes.iter().all(|n| n.status != CheckStatus::Fail);
1259    result.node = node.as_str().to_string();
1260    result.ok = result.ok && report.converged && secrets_ok;
1261    result.slug = (!result.ok).then_some(verify::VERIFY_FAILED_SLUG);
1262    result.backup = Some(report);
1263    result.secrets = Some(secrets);
1264    Ok(result)
1265}
1266
1267/// The secret-name check of `deploy verify --node` (D-13, V7): for each node
1268/// handed in (master first), one `secret_names` listing per provider the
1269/// render references, judged in [`SecretCheckMode::Verify`] so a provider the
1270/// render declares but a node lacks counts as `missing` (K8, verify side).
1271/// A listing that is refused for any reason other than "no provider" or
1272/// "cannot list" is an error: there is nothing to judge.
1273async fn verify_secret_names(
1274    declared: &Declared,
1275    master: Option<&dyn DeployApi>,
1276    backup: Option<&BackupNode<'_>>,
1277) -> Result<SecretNamesReport, CoreError> {
1278    let referenced = collect_referenced_secrets(&declared.outcome.tree);
1279    let declared_providers = declared_secret_providers(&declared.outcome.tree);
1280    let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
1281    let mut nodes = Vec::with_capacity(2);
1282    if let Some(master) = master {
1283        let mut listings = BTreeMap::new();
1284        for provider in &providers {
1285            listings.insert(
1286                (*provider).to_string(),
1287                master.secret_names(provider).await?,
1288            );
1289        }
1290        nodes.push(check_secret_names(
1291            "master",
1292            &referenced,
1293            &listings,
1294            &declared_providers,
1295            SecretCheckMode::Verify,
1296        ));
1297    }
1298    if let Some(backup) = backup {
1299        let mut listings = BTreeMap::new();
1300        for provider in &providers {
1301            listings.insert(
1302                (*provider).to_string(),
1303                backup.secret_names(provider).await?,
1304            );
1305        }
1306        nodes.push(check_secret_names(
1307            "backup",
1308            &referenced,
1309            &listings,
1310            &declared_providers,
1311            SecretCheckMode::Verify,
1312        ));
1313    }
1314    Ok(SecretNamesReport::ordered(nodes))
1315}
1316
1317/// One look at the backup for `deploy verify`: its redundancy status, mode,
1318/// REST read-back and files. Every read that cannot be answered becomes an
1319/// `unreachable` check carrying the error code (never the gateway's words);
1320/// verify has no pre-apply sample, so the sync timestamp is recorded, not
1321/// compared. Returns the tick and the number of resources the read-back
1322/// compared.
1323async fn verify_backup_tick(
1324    declared: &Declared,
1325    expected: &verify::Expected,
1326    master_reader: &dyn TreeReader,
1327    backup: &BackupNode<'_>,
1328    warnings: &mut Vec<String>,
1329) -> (TickOutcome, usize) {
1330    let signals = match backup.status().await {
1331        Ok(status) => Ok(BackupSignals::from_status(None, &status)),
1332        Err(error) => Err(error.code().to_string()),
1333    };
1334    let mode = match backup.deployment_mode().await {
1335        Ok(mode) => mode_backup_check(expected, mode.as_deref()),
1336        Err(error) => unreachable_check(BackupCheckId::Mode, error.code()),
1337    };
1338    let mut compared = 0;
1339    let read_back = match backup.read_back(expected).await {
1340        Ok(read_back) => {
1341            let (check, count) = read_back_backup_check(expected, &read_back, warnings);
1342            compared = count;
1343            check
1344        }
1345        Err(error) => unreachable_check(BackupCheckId::ReadBack, error.code()),
1346    };
1347    let (files, files_checked) =
1348        verify_backup_files(declared, master_reader, backup, warnings).await;
1349    (
1350        TickOutcome {
1351            signals,
1352            exhausted: false,
1353            content: Some(vec![mode, read_back, files]),
1354            files_checked,
1355            stable_ticks: 0,
1356        },
1357        compared,
1358    )
1359}
1360
1361/// The backup's files against the master's live copy of every rendered path.
1362/// Nothing was deleted by verify, so no path is expected absent. A rendered
1363/// unit either node cannot read fails the check as `unreadable`; any other
1364/// unreadable unit is a warning (quick-261029-ms3).
1365async fn verify_backup_files(
1366    declared: &Declared,
1367    master_reader: &dyn TreeReader,
1368    backup: &BackupNode<'_>,
1369    warnings: &mut Vec<String>,
1370) -> (BackupCheck, usize) {
1371    let scope = Scope::from_manifest(
1372        &declared.manifest,
1373        &declared.target.mode,
1374        &declared.excluded_tag_providers(),
1375    );
1376    let backup_files = match backup.read_files(&scope).await {
1377        // A backup with no file transport cannot be asked.
1378        None => return (files_skipped_check(), 0),
1379        Some(Ok(observed)) => observed,
1380        Some(Err(error)) => {
1381            return (
1382                unreachable_check(BackupCheckId::Files, &format!("backup {}", error.code())),
1383                0,
1384            );
1385        }
1386    };
1387    let backup_unreadable = unreadable_units(&backup_files);
1388    let backup_files = effective_files(backup_files);
1389    let master_files = match master_reader.read(&scope).await {
1390        Ok(observed) => observed,
1391        Err(error) => {
1392            return (
1393                unreachable_check(BackupCheckId::Files, &format!("master {}", error.code())),
1394                0,
1395            );
1396        }
1397    };
1398    let master_unreadable = unreadable_units(&master_files);
1399    let master_files = effective_files(master_files);
1400    let present: BTreeSet<_> = declared.outcome.tree.files.keys().cloned().collect();
1401    let absent = BTreeSet::new();
1402    let mut comparison = compare_files(&master_files, &backup_files, &present, &absent);
1403    exempt_unreadable(
1404        &mut comparison,
1405        &present,
1406        &absent,
1407        &[
1408            ("master", &master_unreadable),
1409            ("backup", &backup_unreadable),
1410        ],
1411    );
1412    warnings.extend(unreadable_warnings(&comparison));
1413    (files_check(&comparison), comparison.checked)
1414}
1415
1416/// Read each declared, comparable resource from the REST effective view:
1417/// one list per named type, one singleton read per singleton. An absent
1418/// resource (or a type whose route answers 404) is `Missing`; any other
1419/// refusal is an error.
1420pub(crate) async fn gather_read_back(
1421    deploy: &dyn DeployApi,
1422    expected: &verify::Expected,
1423) -> Result<BTreeMap<String, verify::Remote>, CoreError> {
1424    let classify = |record: crate::client::config_resources::ResourceRecord| {
1425        if record.collection.as_deref() == Some(verify::SYSTEM_COLLECTION) {
1426            verify::Remote::System
1427        } else {
1428            verify::Remote::Config(record.config)
1429        }
1430    };
1431    let mut lists: BTreeMap<(String, String), Option<Vec<_>>> = BTreeMap::new();
1432    let mut out = BTreeMap::new();
1433    for res in &expected.resources {
1434        let remote = match &res.name {
1435            None => match deploy.resource_singleton(&res.module, &res.ty, None).await {
1436                Ok(record) => classify(record),
1437                Err(CoreError::NotFound { .. }) => verify::Remote::Missing,
1438                Err(e) => return Err(e),
1439            },
1440            Some(name) => {
1441                let type_key = (res.module.clone(), res.ty.clone());
1442                if !lists.contains_key(&type_key) {
1443                    let items = match deploy
1444                        .resource_list(&res.module, &res.ty, &ListQuery::default())
1445                        .await
1446                    {
1447                        Ok(envelope) => Some(envelope.items),
1448                        Err(CoreError::NotFound { .. }) => None,
1449                        Err(e) => return Err(e),
1450                    };
1451                    lists.insert(type_key.clone(), items);
1452                }
1453                lists
1454                    .get(&type_key)
1455                    .and_then(Option::as_ref)
1456                    .and_then(|items| items.iter().find(|r| r.name.as_deref() == Some(name)))
1457                    .map_or(verify::Remote::Missing, |r| classify(r.clone()))
1458            }
1459        };
1460        out.insert(res.key.clone(), remote);
1461    }
1462    Ok(out)
1463}
1464
1465/// Wait until an empty answer from the log means something (CV1: a line is
1466/// readable up to about 1.5 s after the scan completed). The wait is skipped
1467/// when the gateway has logged anything stamped after the settle point.
1468async fn settle_logs(
1469    gateway: &dyn GatewayApi,
1470    scan: &ScanStatus,
1471    scan_read_at: std::time::Instant,
1472    settle: Duration,
1473) -> Result<(), CoreError> {
1474    if settle.is_zero() {
1475        return Ok(());
1476    }
1477    let settle_ms = i64::try_from(settle.as_millis()).unwrap_or(i64::MAX);
1478    let probe = LogQuery {
1479        start_time: Some(scan.last_scan_timestamp.saturating_add(settle_ms)),
1480        limit: 1,
1481        ..LogQuery::default()
1482    };
1483    match gateway.logs(&probe).await {
1484        Ok(envelope) if !envelope.items.is_empty() => return Ok(()),
1485        Err(e @ CoreError::Network { .. }) => return Err(e),
1486        // An unreadable log is reported by the real read that follows.
1487        Ok(_) | Err(_) => {}
1488    }
1489    tokio::time::sleep(settle.saturating_sub(scan_read_at.elapsed())).await;
1490    Ok(())
1491}
1492
1493/// The WARN-or-higher log lines since `start_time` (CV1: no `logger` filter,
1494/// no `endTime`; matched client-side). The inner `Err` is the slug of why
1495/// they could not be read.
1496async fn read_logs(
1497    gateway: &dyn GatewayApi,
1498    start_time: i64,
1499) -> Result<Result<Vec<verify::LogLine>, String>, CoreError> {
1500    let mut lines = Vec::new();
1501    let mut offset = 0i64;
1502    for _ in 0..LOG_PAGES_MAX {
1503        let query = LogQuery {
1504            start_time: Some(start_time),
1505            min_level: Some("WARN".to_string()),
1506            limit: DEFAULT_LOG_LIMIT,
1507            offset,
1508            ..LogQuery::default()
1509        };
1510        match gateway.logs(&query).await {
1511            Ok(envelope) => {
1512                let count = i64::try_from(envelope.items.len()).unwrap_or(i64::MAX);
1513                lines.extend(envelope.items.into_iter().map(|e| verify::LogLine {
1514                    timestamp: e.timestamp,
1515                    logger: e.logger_name,
1516                    message: e.message,
1517                }));
1518                if count < DEFAULT_LOG_LIMIT {
1519                    return Ok(Ok(lines));
1520                }
1521                offset += count;
1522            }
1523            Err(e @ CoreError::Network { .. }) => return Err(e),
1524            Err(e) => return Ok(Err(e.code().to_string())),
1525        }
1526    }
1527    Ok(Err("logs_truncated".to_string()))
1528}
1529
1530/// Read `paths` through the WebDev `tags` route every `probe_interval` until
1531/// every one is Good or `probe_deadline` passed; the last reading is judged.
1532/// Values are dropped at once. The inner `Err` is the slug of why the route
1533/// could not be read.
1534async fn poll_probes(
1535    gateway: &dyn GatewayApi,
1536    project: &str,
1537    paths: &[String],
1538    opts: &VerifyOptions,
1539) -> Result<Result<Vec<verify::ProbeReading>, String>, CoreError> {
1540    let interval = opts.probe_interval.max(Duration::from_millis(1));
1541    let started = tokio::time::Instant::now();
1542    loop {
1543        let readings: Vec<verify::ProbeReading> =
1544            match crate::actions::tags::tags_read(gateway, project, paths).await {
1545                Ok(result) => result
1546                    .results
1547                    .into_iter()
1548                    .enumerate()
1549                    .map(|(i, row)| verify::ProbeReading {
1550                        // A row that does not echo its path is the one asked for.
1551                        path: if row.path.is_empty() {
1552                            paths.get(i).cloned().unwrap_or_default()
1553                        } else {
1554                            row.path
1555                        },
1556                        quality: row.quality,
1557                    })
1558                    .collect(),
1559                Err(e @ CoreError::Network { .. }) => return Err(e),
1560                Err(e) => return Ok(Err(e.code().to_string())),
1561            };
1562        let all_good = paths.iter().all(|path| {
1563            readings
1564                .iter()
1565                .any(|r| &r.path == path && r.quality.starts_with("Good"))
1566        });
1567        if all_good || started.elapsed() + interval > opts.probe_deadline {
1568            return Ok(Ok(readings));
1569        }
1570        tokio::time::sleep(interval).await;
1571    }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577
1578    /// A repo whose `gateways/g/resources/` holds one file the pull replaces.
1579    fn repo_with_existing() -> (tempfile::TempDir, Existing) {
1580        let repo = tempfile::TempDir::new().expect("repo");
1581        let dir = repo.path().join("gateways/g/resources/ignition/x");
1582        std::fs::create_dir_all(&dir).expect("dirs");
1583        std::fs::write(dir.join("keep.json"), b"{\"keep\": true}").expect("file");
1584        let existing = Existing {
1585            files: vec!["resources/ignition/x/keep.json".to_string()],
1586            dirs: ["resources", "resources/ignition", "resources/ignition/x"]
1587                .iter()
1588                .map(|d| d.to_string())
1589                .collect(),
1590        };
1591        (repo, existing)
1592    }
1593
1594    fn snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
1595        fn walk(dir: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
1596            for entry in std::fs::read_dir(dir).expect("read_dir") {
1597                let path = entry.expect("entry").path();
1598                if path.is_dir() {
1599                    out.insert(path.clone(), Vec::new());
1600                    walk(&path, out);
1601                } else {
1602                    out.insert(path.clone(), std::fs::read(&path).expect("read"));
1603                }
1604            }
1605        }
1606        let mut out = BTreeMap::new();
1607        walk(root, &mut out);
1608        out
1609    }
1610
1611    /// CR-01: a pulled tree that cannot be written is refused before the first
1612    /// delete, so the existing tree is left exactly as it was.
1613    fn assert_refused_untouched(files: &[(&str, &str)], needle: &str) {
1614        let (repo, existing) = repo_with_existing();
1615        let before = snapshot(repo.path());
1616        let source = SourceTree::from_files(files.iter().copied()).expect("source");
1617        let err =
1618            write_pulled(repo.path(), "g", Some(&existing), &source, None).expect_err("refused");
1619        assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
1620        assert!(err.to_string().contains(needle), "{needle}: {err}");
1621        assert_eq!(
1622            snapshot(repo.path()),
1623            before,
1624            "a refused pull must not delete the existing tree"
1625        );
1626    }
1627
1628    #[test]
1629    fn unsafe_component_is_refused_before_any_delete() {
1630        assert_refused_untouched(
1631            &[
1632                ("resources/ignition/x/fine.json", "{}"),
1633                ("resources/ignition/x/bad\nname.json", "{}"),
1634            ],
1635            "unsafe path component",
1636        );
1637    }
1638
1639    #[test]
1640    fn case_fold_collision_is_refused_before_any_delete() {
1641        assert_refused_untouched(
1642            &[
1643                ("resources/ignition/x/Foo.json", "{}"),
1644                ("resources/ignition/x/foo.json", "{}"),
1645            ],
1646            "case is folded",
1647        );
1648    }
1649
1650    #[test]
1651    fn file_and_directory_clash_is_refused_before_any_delete() {
1652        assert_refused_untouched(
1653            &[
1654                ("resources/ignition/x", "{}"),
1655                ("resources/ignition/x/a.json", "{}"),
1656            ],
1657            "both a file and a directory",
1658        );
1659    }
1660
1661    #[test]
1662    fn a_valid_tree_replaces_the_existing_one() {
1663        let (repo, existing) = repo_with_existing();
1664        let source =
1665            SourceTree::from_files([("resources/ignition/x/new.json", "{}")]).expect("source");
1666        write_pulled(repo.path(), "g", Some(&existing), &source, None).expect("written");
1667        let dir = repo.path().join("gateways/g/resources/ignition/x");
1668        assert!(!dir.join("keep.json").exists());
1669        assert!(dir.join("new.json").is_file());
1670    }
1671}