Skip to main content

ignition_core/actions/
deploy_projects.rs

1//! `ign deploy projects` (Phase 25): deploy one Ignition project from its own
2//! repo to a gateway. `--check` is the read-only half (DPRJ-05); the run
3//! ([`deploy_projects_run`], 25-11) is the guarded write path.
4//!
5//! [`load_project`] is the offline front half: it reads `deploy/project.yaml`,
6//! refuses an environment the file does not list, reads
7//! `targets/<ENV>.yaml` below the targets root (D-07, D-14) and builds the
8//! artifact from the checkout (D-01, D-09). [`deploy_projects_prepare`] then
9//! reads the live project through a [`ProjectGateway`], compares canonical
10//! digests and reports what a deploy would do: `create` for a project absent
11//! from the gateway (D-13), `skip` when the live project equals the artifact,
12//! `import` otherwise.
13//!
14//! ## Release order (25-09)
15//!
16//! `--release` is a semver tag (D-02, V4), parsed before any host or gateway
17//! contact. The host's release record (V5) names the release the gateway last
18//! received from this tool; an older `--release` refuses
19//! `destructive_change_refused` gate `downgrade` unless `--allow-downgrade`
20//! (D-03). A record that cannot be trusted is `deploy_run_invalid`, never
21//! absent.
22//!
23//! ## The first deploy and the record (25-09)
24//!
25//! A project the git module (or anyone) left on the gateway has no release
26//! record. The first `ign` deploy of it must prove the release equals what the
27//! gateway runs: a live project that differs from the artifact refuses
28//! `destructive_change_refused` gate `first_deploy` unless
29//! `--allow-first-change <REASON>` (D-12). A live project that equals the
30//! artifact plans `record` (only the record is written, K14), or `skip` when
31//! the record already names this release. A live project that no longer
32//! matches the artifact digest the record holds is a warning (K13).
33//!
34//! ## The write path (25-11)
35//!
36//! [`deploy_projects_prepare`] is shared by `--check` and the apply, so the
37//! `.git` probe, the release gates and the parent check run on both, before
38//! any write. The artifact hash (`--artifact-sha256`, D-01) is compared in
39//! [`load_project`], after the local build and before any host or gateway
40//! contact. A confirmed apply then runs [`deploy_projects_run`]:
41//!
42//! 1. the release-record writer for the master node is built first, so a node
43//!    that cannot take a record refuses before anything is imported;
44//! 2. a fresh run directory (`.ign/deploy/<gateway>/<run-id>/`, git-ignore
45//!    guard, 0700/0600) gets `run.json`, `plan.json` and `events.ndjson`;
46//! 3. for an `import` the live project is exported into the run directory
47//!    (`export/<project>.zip`, 0600) and its canonical digest must still be
48//!    the planned one, else `plan_stale` and nothing is imported (K15);
49//! 4. the project is imported (`overwrite=true`; a project absent from the
50//!    gateway is created with `overwrite=false` and no pre-image, D-13);
51//! 5. the project is exported again and compared by canonical digest with the
52//!    artifact; equal writes the release record, different is the fail-safe
53//!    outcome `imported_unverified` with no record, no second import and no
54//!    further gateway call (K11);
55//! 6. a `record` plan (the live project already is the artifact, K14) only
56//!    writes the record.
57//!
58//! The import goes to the master node's gateway; the backup node is never
59//! contacted (the redundancy sync carries the project to it). Every return
60//! after the run directory exists writes `result.json` first.
61//!
62//! ## Children of a parent project (25-15)
63//!
64//! After an `imported` or `imported_unverified` outcome the run looks at the
65//! projects whose `parent` is the deployed project (D-06, K7). It reads each
66//! child's export every [`ProjectsRunOptions::children_poll`] until two
67//! consecutive reads of every child agree or
68//! [`ProjectsRunOptions::children_settle`] ([`CHILDREN_SETTLE_S`]) has passed,
69//! then compares each child's canonical digest with the `artifact_sha256` of
70//! that child's own release record: [`ChildStatus::Ok`] when equal,
71//! [`ChildStatus::Mismatch`] (a warning) when not, [`ChildStatus::Unverified`]
72//! when the child has no record or cannot be read. The sweep only reads
73//! (`projects` and the children's `export_bytes` on the gateway,
74//! `read_release_record` on the host), never changes the outcome or the exit
75//! code and never rolls back. It reports hash drift only: what an open
76//! Designer or Perspective session of a child sees after a parent overwrite
77//! is not claimed (the S5 residual stays open).
78//!
79//! ## One write
80//!
81//! [`ProjectGateway`] has four reads and one write, [`ProjectGateway::project_import`],
82//! and [`ProjectHost`](crate::deploy::transport::projects::ProjectHost) is
83//! read-only (`git_entry`, `read_release_record`). No method deletes, renames,
84//! copies or modifies a project, and the import only ever names the project
85//! the repo declares, so nothing in this module can touch another project
86//! (DPRJ-04, D-05). Projects other than the named one are listed by name in
87//! `unmanaged` and nothing else.
88//!
89//! The [`DeployProjectsResult`] `data` keys, in order: `env, gateway,
90//! project, release, check, action, would_change, artifact_sha256,
91//! live_sha256, members, removed, removed_count, excluded, excluded_count,
92//! unmanaged, unmanaged_count, parent, previous_release, recorded_sha256,
93//! live_matches_last_deploy, allow_downgrade, allow_first_change, warnings,
94//! run_id, run_dir, record_written, restore_command, children, outcome, ok,
95//! slug`. Later plans add keys additively. Lists are
96//! byte-sorted and capped at [`LIST_CAP`] entries with the full count beside
97//! them. Output carries names, hashes and counts only.
98
99use std::io::Write as _;
100use std::path::PathBuf;
101use std::time::{Duration, Instant};
102
103use async_trait::async_trait;
104use serde::Serialize;
105
106use super::deploy::load_target;
107use super::projects::validate_import;
108use super::resources::export_zip_bytes;
109use super::workspace::rfc3339_now_utc;
110use crate::client::GatewayApi;
111use crate::client::projects::{ImportOutcome, ProjectRecord};
112use crate::client::query::ListQuery;
113use crate::deploy::apply::hold::SystemClock;
114use crate::deploy::apply::preflight::{
115    DesignerSeen, DesignersPolicy, DesignersVerdict, designers_detail, designers_gate,
116    read_designers, wrong_target_failure, wrong_target_gate,
117};
118use crate::deploy::apply::run_store::{EventLog, RunDir, RunId, run_dir_rel};
119use crate::deploy::ledger::{LedgerRoot, release_record_location};
120use crate::deploy::project_artifact::{
121    Artifact, CanonicalDigest, build_artifact, canonical_digest,
122};
123use crate::deploy::projects::{
124    MemberDelta, PROJECT_FILE_REL, ParentLink, ProjectAction, ProjectFile, ProjectMeta, Release,
125    ReleaseOrder, ReleaseRecord, check_parent, load_project_file, member_delta, parent_chain,
126    read_project_meta,
127};
128use crate::deploy::target::{NodeTarget, Target};
129use crate::deploy::transport::for_node;
130use crate::deploy::transport::projects::{
131    ProjectHost, ReleaseRecordWriter, git_entry_location, release_writer_for_node,
132};
133use crate::deploy::transport::remote::safe_remote_path;
134use crate::error::{CoreError, PreflightFailure};
135
136/// Most entries a result list carries; the full count rides beside it.
137pub const LIST_CAP: usize = 200;
138
139/// The longest the children sweep waits for the children's exports to settle
140/// after a parent import, in seconds (K7, D-06). Capture C9 measured the
141/// settle at 5 s on the 8.3.6 rig; 30 s is six times that.
142pub const CHILDREN_SETTLE_S: u64 = 30;
143
144/// How often the sweep reads the children while it waits, in seconds (K7).
145pub const CHILDREN_POLL_S: u64 = 5;
146
147/// The only entry of a run directory that may hold project content (DPRJ-03,
148/// SC-5): the `export/` subtree, which holds the pre-image
149/// `export/<project>.zip`. A project export is the one secret-bearing file a
150/// run writes, because scripts and resources can embed credentials. It is
151/// written 0600 inside a 0700 directory below the git-ignored `.ign/`, and
152/// nothing else a run writes (`run.json`, `plan.json`, `events.ndjson`,
153/// `result.json`), prints or records may carry content. The canary suites name
154/// this constant as the sole exemption and assert the file exists and is
155/// private, so the exemption cannot hide a missing file.
156pub const PROJECTS_CANARY_EXEMPT: &[&str] = &["export"];
157
158/// The gateway calls a project deploy needs, and nothing else.
159///
160/// Four reads and one write. There is deliberately no method that deletes,
161/// renames, copies or modifies a project: the only write is the import of the
162/// project the repo declares, and the read-only check never reaches it
163/// (`ForbidProjectWrites` proves that in the engine tests).
164#[async_trait]
165pub trait ProjectGateway: Send + Sync {
166    /// Every project the gateway lists (`limit=-1`).
167    async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError>;
168    /// One project by name; `None` when the gateway has no such project.
169    async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError>;
170    /// The project's export zip, in memory.
171    async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError>;
172    /// The open Designer sessions as `(user, project, host)` (a read). The
173    /// pre-check before an import (25-14, D-14, D-15).
174    async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError>;
175    /// The gateway's own name over REST (`gateway-info`), or `None` when it
176    /// reports none or a blank one. Read before any write to prove the
177    /// gateway is the one the target names (25-14, T-25-44).
178    async fn gateway_name(&self) -> Result<Option<String>, CoreError>;
179    /// Import `zip` as project `name`: `overwrite=true` replaces the whole
180    /// project, `overwrite=false` creates it. The only write of the verb. A
181    /// 200 answer carrying `success: false` is `import_denied`.
182    async fn project_import(
183        &self,
184        name: &str,
185        zip: Vec<u8>,
186        overwrite: bool,
187    ) -> Result<ImportOutcome, CoreError>;
188}
189
190/// [`ProjectGateway`] over the real gateway client.
191pub struct GatewayProjects<'a>(pub &'a dyn GatewayApi);
192
193#[async_trait]
194impl ProjectGateway for GatewayProjects<'_> {
195    async fn projects(&self) -> Result<Vec<ProjectRecord>, CoreError> {
196        Ok(self.0.projects(&ListQuery::default()).await?.items)
197    }
198
199    async fn project_find(&self, name: &str) -> Result<Option<ProjectRecord>, CoreError> {
200        match self.0.project_find(name).await {
201            Ok(record) => Ok(Some(record)),
202            Err(CoreError::NotFound { .. }) => Ok(None),
203            Err(err) => Err(err),
204        }
205    }
206
207    async fn export_bytes(&self, name: &str) -> Result<Vec<u8>, CoreError> {
208        export_zip_bytes(self.0, name).await
209    }
210
211    async fn designers(&self) -> Result<Vec<DesignerSeen>, CoreError> {
212        read_designers(self.0).await
213    }
214
215    async fn gateway_name(&self) -> Result<Option<String>, CoreError> {
216        Ok(self
217            .0
218            .gateway_info()
219            .await?
220            .name
221            .filter(|name| !name.trim().is_empty()))
222    }
223
224    async fn project_import(
225        &self,
226        name: &str,
227        zip: Vec<u8>,
228        overwrite: bool,
229    ) -> Result<ImportOutcome, CoreError> {
230        self.0.project_import(name, zip, overwrite).await
231    }
232}
233
234/// The two surfaces a project deploy talks to: the gateway REST API and the
235/// gateway host's filesystem (the `.git` probe, the release record read). The
236/// host surface is read-only; the record is written through the writer
237/// [`deploy_projects_run`] builds for the master node.
238pub struct ProjectsSurfaces<'a> {
239    /// The gateway project calls.
240    pub gateway: &'a dyn ProjectGateway,
241    /// The host reads.
242    pub host: &'a dyn ProjectHost,
243}
244
245/// What the caller supplies.
246#[derive(Debug, Clone)]
247pub struct ProjectsInput {
248    /// The project repo root.
249    pub repo: PathBuf,
250    /// The root holding `targets/<ENV>.yaml` (default: `repo`).
251    pub targets: PathBuf,
252    /// The environment name.
253    pub env: String,
254    /// The release tag being deployed, as given.
255    pub release: String,
256    /// `--allow-downgrade`: let an older release than the recorded one
257    /// replace it (D-03).
258    pub allow_downgrade: bool,
259    /// `--allow-first-change <REASON>`: let the first `ign` deploy of a
260    /// project change what the gateway runs (D-12). 1 to 500 characters.
261    pub allow_first_change: Option<String>,
262    /// `--artifact-sha256`: the canonical digest of the artifact the reviewer
263    /// saw (64 lowercase hex). Compared with the artifact the checkout builds
264    /// before any host or gateway contact (D-01, V1); `None` is allowed here
265    /// because the CLI (Decision C) decides when it is required.
266    pub artifact_sha256: Option<String>,
267}
268
269/// The longest `--allow-first-change` reason accepted.
270pub const FIRST_CHANGE_REASON_MAX: usize = 500;
271
272/// Everything read offline.
273#[derive(Debug)]
274pub struct LoadedProject {
275    /// `deploy/project.yaml`.
276    pub file: ProjectFile,
277    /// What the root `project.json` says (the parent).
278    pub meta: ProjectMeta,
279    /// The target file's name below the targets root.
280    pub target_file: String,
281    /// The parsed target.
282    pub target: Target,
283    /// The artifact built from the checkout.
284    pub artifact: Artifact,
285    /// `--release`, parsed.
286    pub release: Release,
287}
288
289/// Load and build everything that needs no network (D-01, D-07, D-08, D-09).
290pub fn load_project(input: &ProjectsInput) -> Result<LoadedProject, CoreError> {
291    // The flags are checked first: nothing is read, let alone contacted, for
292    // an input that cannot be valid (V4, V3).
293    let release = Release::parse(&input.release)?;
294    if let Some(reason) = &input.allow_first_change {
295        let chars = reason.chars().count();
296        if chars == 0 || chars > FIRST_CHANGE_REASON_MAX {
297            return Err(CoreError::InvalidInput {
298                reason: format!(
299                    "--allow-first-change <reason>: the reason is 1 to {FIRST_CHANGE_REASON_MAX} characters"
300                ),
301            });
302        }
303    }
304    if let Some(hash) = &input.artifact_sha256
305        && !is_lower_hex64(hash)
306    {
307        return Err(CoreError::InvalidInput {
308            reason: "--artifact-sha256 must be 64 lowercase hex characters (the \
309                     `artifact_sha256` of `ign deploy projects --check --json`)"
310                .to_string(),
311        });
312    }
313    let file = load_project_file(&input.repo)?;
314    file.require_env(&input.env)?;
315    let meta = read_project_meta(&input.repo)?;
316    let (target_file, target) = load_target(&input.targets, &input.env)?;
317    // The release record is written after the import. A name the writer will
318    // refuse then is refused now, before any gateway or host is contacted.
319    record_names_fit_the_host(
320        &file.project,
321        &target.gateway,
322        &target_file,
323        &target.nodes.master,
324    )?;
325    let artifact = build_artifact(&input.repo)?;
326    check_artifact_zip(&artifact.zip)?;
327    // The reviewed hash against the artifact this checkout builds: any
328    // difference, including letter case (refused above), never passes, and
329    // nothing has been uploaded or even contacted (SC-1, D-01).
330    if let Some(expected) = &input.artifact_sha256
331        && *expected != artifact.digest.sha256
332    {
333        return Err(CoreError::ProjectHashMismatch {
334            project: file.project.clone(),
335            expected: expected.clone(),
336            actual: artifact.digest.sha256.clone(),
337        });
338    }
339    Ok(LoadedProject {
340        file,
341        meta,
342        target_file,
343        target,
344        artifact,
345        release,
346    })
347}
348
349/// Refuse, offline, a project or gateway name the ssh release-record writer
350/// cannot carry (WR-03). The record path `ign-deploy/<gateway>/projects/
351/// <project>/release.json` reaches sftp on a batch line, and sftp globs: a
352/// name with `[ ] { } * ?` (or a quote, backslash or control character) is
353/// refused by the writer, which would otherwise happen only after the import,
354/// leaving a deployed project that no record can ever be written for. A
355/// `local` node writes with plain file calls and takes any name that passed
356/// the component checks.
357fn record_names_fit_the_host(
358    project: &str,
359    gateway: &str,
360    target_file: &str,
361    master: &NodeTarget,
362) -> Result<(), CoreError> {
363    if master.ssh.is_none() {
364        return Ok(());
365    }
366    if let Err(why) = safe_remote_path(project) {
367        return Err(CoreError::deploy_tree_invalid(
368            PROJECT_FILE_REL,
369            None,
370            None,
371            format!(
372                "project: the name cannot be used for the release record on an ssh node ({why})"
373            ),
374        ));
375    }
376    if let Err(why) = safe_remote_path(gateway) {
377        return Err(CoreError::deploy_tree_invalid(
378            target_file,
379            None,
380            None,
381            format!(
382                "gateway: the name cannot be used for the release record on an ssh node ({why})"
383            ),
384        ));
385    }
386    Ok(())
387}
388
389/// 64 lowercase hex characters.
390fn is_lower_hex64(text: &str) -> bool {
391    text.len() == 64
392        && text
393            .bytes()
394            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
395}
396
397/// The built artifact must pass the same zip checks as `projects import`
398/// before it is reported or uploaded: ZIP magic, the size limit, and every
399/// entry decompressed to its end (a truncated zip imports "successfully" and,
400/// on overwrite, empties the project; C8b). `invalid_import_file`, exit 2.
401pub(crate) fn check_artifact_zip(zip: &[u8]) -> Result<(), CoreError> {
402    validate_import(zip)
403}
404
405/// How a project deploy ended (V6).
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
407#[serde(rename_all = "snake_case")]
408pub enum ProjectOutcome {
409    /// `--check`: nothing was written.
410    Checked,
411    /// The live project already was this release; nothing was written and no
412    /// run directory was created.
413    Skipped,
414    /// The project was absent from the gateway and was created (D-13).
415    Created,
416    /// The live project was replaced and its canonical digest equals the
417    /// artifact's.
418    Imported,
419    /// The live project already equalled the artifact; only the release record
420    /// was written (K14).
421    Recorded,
422    /// The project was imported but the canonical digest read back does not
423    /// equal the artifact's (K11). Fail-safe: no record, no second import.
424    ImportedUnverified,
425}
426
427impl ProjectOutcome {
428    /// The word the result and the events carry.
429    pub fn as_str(self) -> &'static str {
430        match self {
431            ProjectOutcome::Checked => "checked",
432            ProjectOutcome::Skipped => "skipped",
433            ProjectOutcome::Created => "created",
434            ProjectOutcome::Imported => "imported",
435            ProjectOutcome::Recorded => "recorded",
436            ProjectOutcome::ImportedUnverified => "imported_unverified",
437        }
438    }
439}
440
441/// The verdict slug of an unverified import (V6). It rides the success
442/// payload as `data.slug` beside `data.ok: false` and the process then exits 6
443/// (the SIXTH sanctioned success-path exit exception, Decision A). It is not a
444/// [`CoreError`], is not in [`CoreError::DEPLOY_SLUGS`] and is not an
445/// exit-table row.
446pub const PROJECT_UNVERIFIED_SLUG: &str = "deploy_project_unverified";
447
448/// `ign deploy projects` output model.
449#[derive(Debug, Clone, Serialize)]
450pub struct DeployProjectsResult {
451    /// The `--env` value.
452    pub env: String,
453    /// The target's gateway name.
454    pub gateway: String,
455    /// The gateway project name from `deploy/project.yaml`.
456    pub project: String,
457    /// The release tag, as given.
458    pub release: String,
459    /// True for `--check`: no write was made.
460    pub check: bool,
461    /// What a deploy would do.
462    pub action: ProjectAction,
463    /// Whether the gateway would change.
464    pub would_change: bool,
465    /// Canonical sha256 of the artifact built from the checkout.
466    pub artifact_sha256: String,
467    /// Canonical sha256 of the live project's export; null when absent.
468    pub live_sha256: Option<String>,
469    /// How the artifact's members differ from the live project's.
470    pub members: MemberDelta,
471    /// Live members an overwrite would delete, byte-sorted, capped.
472    pub removed: Vec<String>,
473    /// Full count of removed members.
474    pub removed_count: usize,
475    /// Repo-relative paths left out of the artifact (root entries outside
476    /// the allowlist, the built-in denylist, `.ignexclude`, files no
477    /// `resource.json` lists), byte-sorted, capped. A directory carries a
478    /// trailing `/`. Paths only, never file content.
479    pub excluded: Vec<String>,
480    /// Full count of excluded paths.
481    pub excluded_count: usize,
482    /// Other projects on the gateway, by name, byte-sorted, capped. They are
483    /// never read, changed or deleted.
484    pub unmanaged: Vec<String>,
485    /// Full count of other projects.
486    pub unmanaged_count: usize,
487    /// The parent named in the repo's `project.json`; null for a root
488    /// project. Checked with reads only (K6 a, D-05).
489    pub parent: Option<String>,
490    /// The release the gateway last received from this tool, as recorded;
491    /// null when there is no release record (K13, D-03).
492    pub previous_release: Option<String>,
493    /// The `artifact_sha256` the release record holds: the canonical digest
494    /// of what this tool last deployed; null when there is no record.
495    pub recorded_sha256: Option<String>,
496    /// Whether the live project still equals `recorded_sha256`; null without
497    /// a record or without a live project. False is a warning (K13).
498    pub live_matches_last_deploy: Option<bool>,
499    /// Whether `--allow-downgrade` was given.
500    pub allow_downgrade: bool,
501    /// The `--allow-first-change` reason, as given; null without the flag.
502    pub allow_first_change: Option<String>,
503    /// Warnings in pipeline order.
504    pub warnings: Vec<String>,
505    /// The run's id; null for a check and for a project left alone.
506    pub run_id: Option<String>,
507    /// The run directory, relative to the repo (`.ign/deploy/<gateway>/<id>`);
508    /// null when no run directory was created.
509    pub run_dir: Option<String>,
510    /// Whether the release record was written on the gateway host.
511    pub record_written: bool,
512    /// The command that puts the saved pre-image back: set only on the
513    /// unverified verdict of an overwrite import, where a pre-image exists
514    /// (K11). Nothing runs it; the operator does.
515    pub restore_command: Option<String>,
516    /// The projects that inherit from this one, checked against the release
517    /// record of their own last deploy (D-06, K7); byte-sorted by name. Empty
518    /// when no sweep ran: a check, a project with no children, and the
519    /// `created`, `recorded` and `skipped` outcomes. Reported, never acted on.
520    pub children: Vec<ChildReport>,
521    /// How the run ended.
522    pub outcome: ProjectOutcome,
523    /// False only for a verdict exit (never in a check).
524    pub ok: bool,
525    /// The verdict slug when `ok` is false; otherwise null.
526    pub slug: Option<String>,
527}
528
529/// How one child compares with the release its own last deploy recorded.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
531#[serde(rename_all = "snake_case")]
532pub enum ChildStatus {
533    /// The child's canonical digest equals its record's `artifact_sha256`.
534    Ok,
535    /// They differ. Also a warning naming the child.
536    Mismatch,
537    /// There is nothing to compare: the child has no release record, or its
538    /// export or record could not be read.
539    Unverified,
540}
541
542impl ChildStatus {
543    /// The word the result and the events carry.
544    pub fn as_str(self) -> &'static str {
545        match self {
546            ChildStatus::Ok => "ok",
547            ChildStatus::Mismatch => "mismatch",
548            ChildStatus::Unverified => "unverified",
549        }
550    }
551}
552
553/// One inheriting project in `data.children` (D-06). Names, a release tag and
554/// hashes only.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
556pub struct ChildReport {
557    /// The child's project name, as the gateway lists it.
558    pub name: String,
559    /// How it compares with its record.
560    pub status: ChildStatus,
561    /// The release its record names; null without a record.
562    pub release: Option<String>,
563    /// The `artifact_sha256` its record holds; null without a record.
564    pub recorded_sha256: Option<String>,
565    /// The canonical sha256 of the child's export after the settle wait; null
566    /// when it could not be read.
567    pub live_sha256: Option<String>,
568}
569
570impl DeployProjectsResult {
571    /// The exit code the process ends with after the envelope: `Some(6)` for
572    /// an unverified import (V6), `None` for every other outcome. The
573    /// envelope is a success envelope; the code is the verdict.
574    pub fn failure_exit_code(&self) -> Option<u8> {
575        (self.outcome == ProjectOutcome::ImportedUnverified).then_some(6)
576    }
577}
578
579/// `text` as one shell word: unchanged when it holds only characters no shell
580/// treats specially, else single-quoted.
581fn shell_word(text: &str) -> String {
582    let plain = !text.is_empty()
583        && text.chars().all(|c| {
584            c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '@' | '+' | ',')
585        });
586    if plain {
587        text.to_string()
588    } else {
589        format!("'{}'", text.replace('\'', "'\\''"))
590    }
591}
592
593/// The command that puts the saved pre-image back over the project (K11): the
594/// plain `ign project import` of the run's `export/<project>.zip`. The path is
595/// relative to the repo root, where the run directory lives. It names the
596/// project and the path only, never file content.
597fn restore_command(project: &str, run_dir_rel: &str) -> String {
598    format!(
599        "ign project import {} --file {} --collision-policy overwrite --yes",
600        shell_word(project),
601        shell_word(&format!("{run_dir_rel}/export/{project}.zip")),
602    )
603}
604
605fn capped(mut items: Vec<String>) -> (Vec<String>, usize) {
606    let count = items.len();
607    items.truncate(LIST_CAP);
608    (items, count)
609}
610
611/// The parent-relevant facts of one listed project (K6 a).
612fn parent_link(record: &ProjectRecord) -> ParentLink {
613    ParentLink {
614        name: record.name.clone(),
615        parent: record.parent.clone().filter(|p| !p.is_empty()),
616        inheritable: record.inheritable,
617        invalid_parent: record.invalid_parent,
618    }
619}
620
621/// The live export must hold members. A gateway answering an export with
622/// none breaks its own contract, which is not a defect in the repo.
623fn live_digest(project: &str, zip: &[u8]) -> Result<CanonicalDigest, CoreError> {
624    canonical_digest(zip).map_err(|err| match err {
625        CoreError::DeployTreeInvalid(_) => CoreError::Internal(format!(
626            "the gateway's export of project {project} holds no members"
627        )),
628        other => other,
629    })
630}
631
632/// What [`deploy_projects_prepare`] decided.
633#[derive(Debug, Clone)]
634pub enum ProjectsPrepared {
635    /// The gateway would change (`create`, `import` or `record`): the plan
636    /// and the prose the confirmation refusal carries.
637    Ready {
638        /// The confirmation prose: the action, project, gateway, environment,
639        /// release, member counts, the removed paths and `artifact_sha256`.
640        prose: String,
641        /// The plan; its `outcome` is the one the action leads to when it
642        /// verifies.
643        plan: DeployProjectsResult,
644    },
645    /// Nothing to do (`skip`): outcome `skipped`. Returned before any session
646    /// write, and no `.ign/` directory is created.
647    Unchanged(DeployProjectsResult),
648}
649
650impl ProjectsPrepared {
651    /// The `--check` result: the plan with `check` true and outcome
652    /// `checked`. Nothing was written.
653    pub fn into_check_result(self) -> DeployProjectsResult {
654        let (ProjectsPrepared::Ready {
655            plan: mut result, ..
656        }
657        | ProjectsPrepared::Unchanged(mut result)) = self;
658        result.check = true;
659        result.outcome = ProjectOutcome::Checked;
660        result
661    }
662}
663
664/// The plan as the confirmation refusal shows it (K5): what will happen, to
665/// which project on which gateway, which release, how many members change,
666/// the paths an overwrite deletes and the hash that was reviewed. Names,
667/// counts and hashes only; the CLI escapes it for a terminal.
668fn plan_prose(plan: &DeployProjectsResult) -> String {
669    let action = match plan.action {
670        ProjectAction::Create => "create project",
671        ProjectAction::Import => "overwrite-import project",
672        ProjectAction::Record => "record the release only for project",
673        ProjectAction::Skip => "leave project",
674    };
675    let mut lines = vec![format!(
676        "deploy projects will {action} {} on gateway {} (env {}, release {})",
677        plan.project, plan.gateway, plan.env, plan.release
678    )];
679    if plan.action == ProjectAction::Record {
680        lines.push("the project itself is not changed".to_string());
681    } else {
682        lines.push(format!(
683            "members: {} added, {} changed, {} removed, {} same",
684            plan.members.added, plan.members.changed, plan.members.removed, plan.members.same
685        ));
686    }
687    if plan.removed_count > 0 {
688        lines.push(format!(
689            "the import deletes {} member(s) the repo does not have:",
690            plan.removed_count
691        ));
692        for path in plan.removed.iter().take(PROSE_REMOVED_SHOWN) {
693            lines.push(format!("  {path}"));
694        }
695        if plan.removed_count > PROSE_REMOVED_SHOWN {
696            lines.push(format!(
697                "  ... and {} more",
698                plan.removed_count - PROSE_REMOVED_SHOWN
699            ));
700        }
701    }
702    if plan.action == ProjectAction::Import {
703        lines.push("the live project is saved into a run directory before the import".to_string());
704    }
705    lines.push(format!("artifact_sha256 {}", plan.artifact_sha256));
706    lines.join("\n")
707}
708
709/// Removed paths the confirmation prose lists before it counts the rest.
710const PROSE_REMOVED_SHOWN: usize = 20;
711
712/// The comparison both `--check` and the apply start from: probe the host for
713/// a `.git`, then list, find, export and digest the live project and decide
714/// the action. Sends GET requests only and writes nothing; the apply's writes
715/// are [`deploy_projects_run`]'s.
716///
717/// The `.git` probe comes first (DPRJ-04, D-11): a project the git module
718/// still manages is refused as `project_git_managed` before any gateway read.
719/// An unreadable probe is `transport_failed`, never "no git".
720pub async fn deploy_projects_prepare(
721    loaded: &LoadedProject,
722    input: &ProjectsInput,
723    surfaces: &ProjectsSurfaces<'_>,
724) -> Result<ProjectsPrepared, CoreError> {
725    let project = &loaded.file.project;
726    let gateway = surfaces.gateway;
727    let gateway_name = &loaded.target.gateway;
728
729    let git = surfaces.host.git_entry(project).await?;
730    if git.is_present() {
731        return Err(CoreError::ProjectGitManaged {
732            project: project.clone(),
733            location: git_entry_location(project),
734            kind: git.as_str().to_string(),
735        });
736    }
737
738    // The release record (V5): absent means this tool has not deployed the
739    // project here; a record that cannot be trusted refuses and is never read
740    // as absent (T-25-28).
741    let record = match surfaces
742        .host
743        .read_release_record(gateway_name, project)
744        .await?
745    {
746        None => None,
747        Some(bytes) => {
748            let location = release_record_location(
749                LedgerRoot::for_node(&loaded.target.nodes.master),
750                gateway_name,
751                project,
752            );
753            Some(ReleaseRecord::parse(
754                &bytes,
755                &location,
756                gateway_name,
757                project,
758            )?)
759        }
760    };
761    if let Some(record) = &record
762        && loaded.release.order_against(&record.release()?) == ReleaseOrder::Older
763        && !input.allow_downgrade
764    {
765        return Err(CoreError::DestructiveChangeRefused {
766            gate: "downgrade".to_string(),
767            keys: vec![project.clone()],
768            count: 1,
769        });
770    }
771
772    let records = gateway.projects().await?;
773    // The parent check reads the one list and nothing else (K6 a, D-05): a
774    // missing parent, a cycle or a parent that is not inheritable refuses
775    // before the project itself is exported, and the parent is never touched.
776    let mut warnings = Vec::new();
777    if let Some(parent) = &loaded.meta.parent {
778        let all: Vec<ParentLink> = records.iter().map(parent_link).collect();
779        let (chain, bound_hit) = parent_chain(parent, &all);
780        check_parent(project, parent, &chain, bound_hit).map_err(|reason| {
781            CoreError::ProjectParentInvalid {
782                project: project.clone(),
783                parent: parent.clone(),
784                reason: reason.as_str().to_string(),
785            }
786        })?;
787        // The gateway's own flag on the parent: the three reasons above found
788        // nothing, so the cause is the parent's own inheritance. Reported, not
789        // refused (K6 a).
790        if chain
791            .first()
792            .is_some_and(|p| p.invalid_parent == Some(true))
793        {
794            warnings.push(format!(
795                "project {project}: the gateway reports that parent {parent} has an invalid parent of its own"
796            ));
797        }
798    }
799    // An import writes the repo's `parent` onto the live project, so a live
800    // parent that differs is a reparent. Warned about, never refused: the
801    // repo is the source of truth (D-05). Read from the list already held.
802    let repo_parent = loaded.meta.parent.as_deref().filter(|p| !p.is_empty());
803    if let Some(live_record) = records.iter().find(|r| r.name == *project) {
804        let live_parent = live_record.parent.as_deref().filter(|p| !p.is_empty());
805        if live_parent != repo_parent {
806            warnings.push(format!(
807                "project {project}: importing this project changes its parent from {} to {}",
808                live_parent.unwrap_or("none"),
809                repo_parent.unwrap_or("none")
810            ));
811        }
812    }
813    let mut others: Vec<String> = records
814        .into_iter()
815        .map(|r| r.name)
816        .filter(|name| name != project)
817        .collect();
818    others.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
819    others.dedup();
820
821    let live = match gateway.project_find(project).await? {
822        None => None,
823        Some(_) => {
824            let zip = gateway.export_bytes(project).await?;
825            Some(live_digest(project, &zip)?)
826        }
827    };
828
829    let artifact = &loaded.artifact.digest;
830    let equal = live.as_ref().is_some_and(|live| {
831        !live.members.is_empty() && !artifact.members.is_empty() && live.sha256 == artifact.sha256
832    });
833    let action = match &live {
834        None => ProjectAction::Create,
835        // The live project already is the artifact: the project is left alone
836        // and only the record may be written (K14, D-12).
837        Some(_) if equal => match &record {
838            Some(record) if record.release()?.version == loaded.release.version => {
839                ProjectAction::Skip
840            }
841            _ => ProjectAction::Record,
842        },
843        Some(_) => ProjectAction::Import,
844    };
845    // The first `ign` deploy of a project must not change what the gateway
846    // runs without a stated reason (D-12, V3).
847    if action == ProjectAction::Import && record.is_none() && input.allow_first_change.is_none() {
848        return Err(CoreError::DestructiveChangeRefused {
849            gate: "first_deploy".to_string(),
850            keys: vec![project.clone()],
851            count: 1,
852        });
853    }
854    let live_matches_last_deploy = match (&record, &live) {
855        (Some(record), Some(live)) => Some(record.artifact_sha256 == live.sha256),
856        _ => None,
857    };
858    if let (Some(record), Some(false)) = (&record, live_matches_last_deploy) {
859        warnings.push(format!(
860            "project {project}: the live project no longer matches the last release this tool deployed ({})",
861            record.release
862        ));
863    }
864    let (members, removed) = member_delta(artifact, live.as_ref());
865    let (removed, removed_count) = capped(removed);
866    let (excluded, excluded_count) = capped(loaded.artifact.excluded.clone());
867    let (unmanaged, unmanaged_count) = capped(others);
868
869    let plan = DeployProjectsResult {
870        env: input.env.clone(),
871        gateway: loaded.target.gateway.clone(),
872        project: project.clone(),
873        release: input.release.clone(),
874        check: false,
875        action,
876        would_change: action.would_change(),
877        artifact_sha256: artifact.sha256.clone(),
878        live_sha256: live.map(|l| l.sha256),
879        members,
880        removed,
881        removed_count,
882        excluded,
883        excluded_count,
884        unmanaged,
885        unmanaged_count,
886        parent: loaded.meta.parent.clone(),
887        previous_release: record.as_ref().map(|r| r.release.clone()),
888        recorded_sha256: record.map(|r| r.artifact_sha256),
889        live_matches_last_deploy,
890        allow_downgrade: input.allow_downgrade,
891        allow_first_change: input.allow_first_change.clone(),
892        warnings,
893        run_id: None,
894        run_dir: None,
895        record_written: false,
896        restore_command: None,
897        children: Vec::new(),
898        outcome: match action {
899            ProjectAction::Create => ProjectOutcome::Created,
900            ProjectAction::Skip => ProjectOutcome::Skipped,
901            ProjectAction::Import => ProjectOutcome::Imported,
902            ProjectAction::Record => ProjectOutcome::Recorded,
903        },
904        ok: true,
905        slug: None,
906    };
907    Ok(if action == ProjectAction::Skip {
908        ProjectsPrepared::Unchanged(plan)
909    } else {
910        ProjectsPrepared::Ready {
911            prose: plan_prose(&plan),
912            plan,
913        }
914    })
915}
916
917/// A step of a run, for the events log and the observer.
918///
919/// The fields are names, ids, counts and hashes only: never a project file,
920/// a secret or a gateway response body.
921#[derive(Debug, Clone, PartialEq, Eq)]
922pub enum ProjectsEvent {
923    /// The Designer list was read under `--designers wait` (25-14). Heard
924    /// as it happens and logged once the run directory exists.
925    DesignersPoll {
926        /// How many sessions were open at that read.
927        open: usize,
928    },
929    /// `--designers proceed` went ahead over these open sessions (D-15).
930    DesignersProceed {
931        /// `(user, project, host)` of each open session.
932        sessions: Vec<DesignerSeen>,
933    },
934    /// The Designer check did not run: a `record` run does not touch the
935    /// project.
936    DesignersSkipped,
937    /// The run directory exists and the run is about to start.
938    RunStarted {
939        /// The run's id.
940        run_id: String,
941        /// What the run will do.
942        action: ProjectAction,
943    },
944    /// The live project was saved into the run directory.
945    PreImageExported {
946        /// Size of the saved export.
947        bytes: usize,
948    },
949    /// The import request is about to be sent.
950    ImportSent {
951        /// True when the live project is replaced, false when it is created.
952        overwrite: bool,
953    },
954    /// The gateway answered the import.
955    ImportAnswered,
956    /// The project was read back and compared with the artifact.
957    Verified {
958        /// Whether the canonical digests are equal.
959        equal: bool,
960    },
961    /// The release record was written.
962    RecordWritten,
963    /// The release record could not be written.
964    RecordFailed {
965        /// The error's slug.
966        code: String,
967    },
968    /// The sweep found projects that inherit from the deployed one and is
969    /// reading their exports until they settle (D-06, K7).
970    ChildrenSettling {
971        /// The children, byte-sorted.
972        children: Vec<String>,
973    },
974    /// One child was compared with its release record.
975    ChildChecked {
976        /// The child's name.
977        name: String,
978        /// How it compared.
979        status: ChildStatus,
980    },
981    /// The run ended.
982    Finished {
983        /// How it ended.
984        outcome: ProjectOutcome,
985    },
986}
987
988impl ProjectsEvent {
989    /// The event name `events.ndjson` carries.
990    pub fn name(&self) -> &'static str {
991        match self {
992            ProjectsEvent::DesignersPoll { .. } => "designers_poll",
993            ProjectsEvent::DesignersProceed { .. } => "designers_proceed",
994            ProjectsEvent::DesignersSkipped => "designers_skipped",
995            ProjectsEvent::RunStarted { .. } => "run_started",
996            ProjectsEvent::PreImageExported { .. } => "pre_image_exported",
997            ProjectsEvent::ImportSent { .. } => "import_sent",
998            ProjectsEvent::ImportAnswered => "import_answered",
999            ProjectsEvent::Verified { .. } => "verified",
1000            ProjectsEvent::RecordWritten => "record_written",
1001            ProjectsEvent::RecordFailed { .. } => "record_failed",
1002            ProjectsEvent::ChildrenSettling { .. } => "children_settling",
1003            ProjectsEvent::ChildChecked { .. } => "child_checked",
1004            ProjectsEvent::Finished { .. } => "finished",
1005        }
1006    }
1007
1008    fn fields(&self) -> serde_json::Value {
1009        match self {
1010            ProjectsEvent::DesignersPoll { open } => serde_json::json!({ "open": open }),
1011            ProjectsEvent::DesignersProceed { sessions } => {
1012                serde_json::json!({ "sessions": sessions_json(sessions) })
1013            }
1014            ProjectsEvent::DesignersSkipped => serde_json::json!({ "reason": "record only" }),
1015            ProjectsEvent::RunStarted { run_id, action } => {
1016                serde_json::json!({ "run_id": run_id, "action": action })
1017            }
1018            ProjectsEvent::PreImageExported { bytes } => serde_json::json!({ "bytes": bytes }),
1019            ProjectsEvent::ImportSent { overwrite } => {
1020                serde_json::json!({ "overwrite": overwrite })
1021            }
1022            ProjectsEvent::Verified { equal } => serde_json::json!({ "equal": equal }),
1023            ProjectsEvent::RecordFailed { code } => serde_json::json!({ "code": code }),
1024            ProjectsEvent::ChildrenSettling { children } => {
1025                serde_json::json!({ "children": children })
1026            }
1027            ProjectsEvent::ChildChecked { name, status } => {
1028                serde_json::json!({ "name": name, "status": status.as_str() })
1029            }
1030            ProjectsEvent::Finished { outcome } => {
1031                serde_json::json!({ "outcome": outcome.as_str() })
1032            }
1033            ProjectsEvent::ImportAnswered | ProjectsEvent::RecordWritten => serde_json::Value::Null,
1034        }
1035    }
1036}
1037
1038/// Receives each [`ProjectsEvent`] as the run goes (the CLI prints them to
1039/// stderr in human mode, 25-12). Actions never print.
1040pub trait ProjectsObserver: Send + Sync {
1041    /// One step happened.
1042    fn event(&self, event: &ProjectsEvent);
1043}
1044
1045/// An observer that ignores every event.
1046pub struct NoProjectsObserver;
1047
1048impl ProjectsObserver for NoProjectsObserver {
1049    fn event(&self, _event: &ProjectsEvent) {}
1050}
1051
1052/// What [`deploy_projects_run`] needs besides the plan.
1053pub struct ProjectsRunOptions<'a> {
1054    /// The project repo root: the run directory lives below it.
1055    pub repo: PathBuf,
1056    /// The root `target_file` and a relative `known_hosts` or `data_dir`
1057    /// resolve against (`--targets`, default the repo).
1058    pub targets_root: PathBuf,
1059    /// The target file's name below the targets root.
1060    pub target_file: String,
1061    /// The master node. The import and the record go here and nowhere else;
1062    /// `nodes.backup` is never contacted (the redundancy sync carries the
1063    /// project to it).
1064    pub master: NodeTarget,
1065    /// A record writer standing in for the one built from `master`. For the
1066    /// engine tests only: the CLI always passes `None`.
1067    #[doc(hidden)]
1068    pub record_writer: Option<&'a dyn ReleaseRecordWriter>,
1069    /// `--designers`, `--designers-timeout` and the poll interval (25-14).
1070    pub designers: DesignersPolicy,
1071    /// The longest the children sweep waits for the children's exports to
1072    /// settle (K7); the CLI passes [`CHILDREN_SETTLE_S`] seconds.
1073    pub children_settle: Duration,
1074    /// How often the sweep reads the children while it waits; the CLI passes
1075    /// [`CHILDREN_POLL_S`] seconds. The engine tests pass short values, never
1076    /// an environment variable.
1077    pub children_poll: Duration,
1078}
1079
1080/// The events log of one run. Before the gateway changes a log failure stops
1081/// the run; after it, the work that is left (verify, record) matters more than
1082/// the log, so a failure is remembered and reported as a warning.
1083struct RunEvents<'a> {
1084    log: EventLog,
1085    observer: &'a dyn ProjectsObserver,
1086    late_failure: bool,
1087}
1088
1089impl RunEvents<'_> {
1090    fn emit(&mut self, event: ProjectsEvent) -> Result<(), CoreError> {
1091        self.observer.event(&event);
1092        self.log.append(event.name(), event.fields())
1093    }
1094
1095    /// Log `event` without telling the observer, which heard it live.
1096    fn record(&mut self, event: &ProjectsEvent) -> Result<(), CoreError> {
1097        self.log.append(event.name(), event.fields())
1098    }
1099
1100    fn emit_late(&mut self, event: ProjectsEvent) {
1101        if self.emit(event).is_err() {
1102            self.late_failure = true;
1103        }
1104    }
1105}
1106
1107/// `(user, project, host)` triples as `run.json` and the events carry them:
1108/// names only (D-15).
1109fn sessions_json(sessions: &[DesignerSeen]) -> Vec<serde_json::Value> {
1110    sessions
1111        .iter()
1112        .map(|(user, project, host)| {
1113            serde_json::json!({ "user": user, "project": project, "host": host })
1114        })
1115        .collect()
1116}
1117
1118/// What the Designer pre-check found, for `run.json` and the events.
1119struct DesignersFound {
1120    /// `fail`, `wait`, `proceed`, or `skipped` for a run that does not touch
1121    /// the project.
1122    mode: &'static str,
1123    /// The timeout in seconds; `None` when the check did not run.
1124    timeout_s: Option<u64>,
1125    /// The reads `wait` made, in order.
1126    polls: Vec<usize>,
1127    /// The sessions open at the last read.
1128    seen: Vec<DesignerSeen>,
1129    /// Whether the run goes ahead over those sessions.
1130    proceeded: bool,
1131    /// Whether the check was skipped (a `record` plan).
1132    skipped: bool,
1133}
1134
1135/// The Designer pre-check (25-14, K12): any open session refuses an import
1136/// unless `--designers proceed` was given. Runs before the run directory
1137/// exists, so a refusal leaves nothing behind. A `record` plan does not touch
1138/// the project and skips it.
1139async fn designers_check(
1140    plan: &DeployProjectsResult,
1141    gateway: &dyn ProjectGateway,
1142    policy: &DesignersPolicy,
1143    observer: &dyn ProjectsObserver,
1144) -> Result<DesignersFound, CoreError> {
1145    if plan.action == ProjectAction::Record {
1146        return Ok(DesignersFound {
1147            mode: "skipped",
1148            timeout_s: None,
1149            polls: Vec::new(),
1150            seen: Vec::new(),
1151            proceeded: false,
1152            skipped: true,
1153        });
1154    }
1155    let mut polls = Vec::new();
1156    let (verdict, seen) =
1157        designers_gate(|| gateway.designers(), policy, &SystemClock, &mut |open| {
1158            polls.push(open);
1159            observer.event(&ProjectsEvent::DesignersPoll { open });
1160            Ok(())
1161        })
1162        .await?;
1163    if verdict == DesignersVerdict::Open {
1164        return Err(CoreError::DeployPreflightFailed {
1165            failures: Box::new([PreflightFailure {
1166                check: "designers".to_string(),
1167                detail: designers_detail(&seen),
1168            }]),
1169        });
1170    }
1171    Ok(DesignersFound {
1172        mode: policy.mode.as_str(),
1173        timeout_s: Some(policy.timeout.as_secs()),
1174        polls,
1175        seen,
1176        proceeded: verdict == DesignersVerdict::Proceeded,
1177        skipped: false,
1178    })
1179}
1180
1181/// The wrong-target pre-check (25-14, T-25-44): the gateway's REST name must
1182/// be the host's `systemName` and `nodes.master.gateway_name` when the target
1183/// declares it. Runs for every confirmed plan, `record` included, because the
1184/// record is a write to that host too. An unanswerable read is its own error.
1185async fn wrong_target_check(
1186    loaded: &LoadedProject,
1187    gateway: &dyn ProjectGateway,
1188    opts: &ProjectsRunOptions<'_>,
1189) -> Result<(), CoreError> {
1190    let rest = gateway.gateway_name().await?;
1191    let reader = for_node(
1192        &opts.targets_root,
1193        &opts.target_file,
1194        "nodes.master",
1195        &opts.master,
1196    )?;
1197    let problems = wrong_target_gate(
1198        rest.as_deref(),
1199        reader.as_ref(),
1200        &loaded.target.mode,
1201        opts.master.gateway_name.as_deref(),
1202    )
1203    .await?;
1204    if problems.is_empty() {
1205        return Ok(());
1206    }
1207    Err(CoreError::DeployPreflightFailed {
1208        failures: Box::new([wrong_target_failure(&problems)]),
1209    })
1210}
1211
1212/// The release record this run leaves (V5).
1213fn release_record(plan: &DeployProjectsResult, release: &str, run_id: &RunId) -> ReleaseRecord {
1214    ReleaseRecord {
1215        schema_version: 1,
1216        gateway: plan.gateway.clone(),
1217        project: plan.project.clone(),
1218        release: release.to_string(),
1219        artifact_sha256: plan.artifact_sha256.clone(),
1220        run_id: run_id.as_str().to_string(),
1221        deployed_at: rfc3339_now_utc(),
1222    }
1223}
1224
1225fn stale_live(expected: String, actual: String) -> CoreError {
1226    CoreError::PlanStale {
1227        expected,
1228        actual,
1229        detail: "live project changed since the plan".to_string(),
1230    }
1231}
1232
1233/// The confirmed write path: see the module documentation for the order.
1234///
1235/// `prepared` is what [`deploy_projects_prepare`] returned for the same
1236/// `loaded` and `input`; an `Unchanged` plan returns as it is, before any run
1237/// directory exists. Every return after the run directory exists writes
1238/// `result.json` first (an error is recorded by its slug only).
1239pub async fn deploy_projects_run(
1240    prepared: ProjectsPrepared,
1241    loaded: &LoadedProject,
1242    input: &ProjectsInput,
1243    surfaces: &ProjectsSurfaces<'_>,
1244    opts: &ProjectsRunOptions<'_>,
1245    observer: &dyn ProjectsObserver,
1246) -> Result<DeployProjectsResult, CoreError> {
1247    let plan = match prepared {
1248        ProjectsPrepared::Unchanged(result) => return Ok(result),
1249        ProjectsPrepared::Ready { plan, .. } => plan,
1250    };
1251    // The record writer is built before anything is written, so a node that
1252    // cannot take a record refuses now and not after the import.
1253    let built;
1254    let writer: &dyn ReleaseRecordWriter = match opts.record_writer {
1255        Some(writer) => writer,
1256        None => {
1257            built = release_writer_for_node(
1258                &opts.targets_root,
1259                &opts.target_file,
1260                "nodes.master",
1261                &opts.master,
1262            )?;
1263            built.as_ref()
1264        }
1265    };
1266    // The pre-checks read the gateway and write nothing: a refusal leaves no
1267    // run directory, no pre-image, no import and no record.
1268    wrong_target_check(loaded, surfaces.gateway, opts).await?;
1269    let designers = designers_check(&plan, surfaces.gateway, &opts.designers, observer).await?;
1270    let (run_id, run_dir) = RunDir::create_fresh(&opts.repo, &plan.gateway)?;
1271    let rel = run_dir_rel(&plan.gateway, &run_id);
1272    let outcome = execute(
1273        &plan, loaded, input, surfaces, opts, writer, &run_dir, &run_id, &rel, &designers, observer,
1274    )
1275    .await;
1276    match outcome {
1277        Ok(mut result) => {
1278            if let Err(err) = run_dir.write_json("result.json", &result) {
1279                result.warnings.push(format!(
1280                    "the run's result.json could not be written ({})",
1281                    err.code()
1282                ));
1283            }
1284            Ok(result)
1285        }
1286        Err(err) => {
1287            // The slug only: no message text, no hash, no file content.
1288            let _ = run_dir.write_json(
1289                "result.json",
1290                &serde_json::json!({
1291                    "ok": false,
1292                    "slug": err.code(),
1293                    "run_id": run_id.as_str(),
1294                }),
1295            );
1296            Err(err)
1297        }
1298    }
1299}
1300
1301/// What the children sweep found.
1302struct ChildSweep {
1303    children: Vec<ChildReport>,
1304    warnings: Vec<String>,
1305}
1306
1307/// One child's export as the sweep reads it: the canonical digest, or the
1308/// error code of the read.
1309type ChildRead = Result<String, String>;
1310
1311async fn read_children(gateway: &dyn ProjectGateway, names: &[String]) -> Vec<ChildRead> {
1312    let mut reads = Vec::with_capacity(names.len());
1313    for name in names {
1314        reads.push(match gateway.export_bytes(name).await {
1315            Ok(zip) => live_digest(name, &zip)
1316                .map(|digest| digest.sha256)
1317                .map_err(|err| err.code().to_string()),
1318            Err(err) => Err(err.code().to_string()),
1319        });
1320    }
1321    reads
1322}
1323
1324/// The children sweep (D-06, K7): for every project whose `parent` is the
1325/// deployed one, wait until two consecutive reads of every child's export
1326/// agree or `children_settle` has passed, then compare each child's canonical
1327/// digest with the record its own last deploy left.
1328///
1329/// It never fails: a listing, export or record that cannot be read becomes a
1330/// warning carrying the error code only and, for a child, the status
1331/// `unverified`. Its gateway calls are `projects` and the children's
1332/// `export_bytes`; its host call is `read_release_record`. Only direct
1333/// children are swept (a flagged assumption of 25-15).
1334async fn sweep_children(
1335    result: &DeployProjectsResult,
1336    loaded: &LoadedProject,
1337    surfaces: &ProjectsSurfaces<'_>,
1338    opts: &ProjectsRunOptions<'_>,
1339    events: &mut RunEvents<'_>,
1340) -> ChildSweep {
1341    let project = result.project.as_str();
1342    let gateway = surfaces.gateway;
1343    let mut sweep = ChildSweep {
1344        children: Vec::new(),
1345        warnings: Vec::new(),
1346    };
1347
1348    let records = match gateway.projects().await {
1349        Ok(records) => records,
1350        Err(err) => {
1351            sweep.warnings.push(format!(
1352                "could not list the projects to check the children of {project} ({})",
1353                err.code()
1354            ));
1355            return sweep;
1356        }
1357    };
1358    let mut names: Vec<String> = records
1359        .into_iter()
1360        .filter(|record| record.parent.as_deref() == Some(project))
1361        .map(|record| record.name)
1362        .collect();
1363    names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1364    names.dedup();
1365    if names.is_empty() {
1366        return sweep;
1367    }
1368    events.emit_late(ProjectsEvent::ChildrenSettling {
1369        children: names.clone(),
1370    });
1371
1372    // Settle: read every child, wait, read again; stop when the last two
1373    // reads of every child agree or the bound has passed.
1374    let started = Instant::now();
1375    let mut current = read_children(gateway, &names).await;
1376    let mut unsettled = vec![true; names.len()];
1377    loop {
1378        let elapsed = started.elapsed();
1379        if elapsed >= opts.children_settle {
1380            break;
1381        }
1382        tokio::time::sleep(opts.children_poll.min(opts.children_settle - elapsed)).await;
1383        let next = read_children(gateway, &names).await;
1384        unsettled = current.iter().zip(&next).map(|(a, b)| a != b).collect();
1385        current = next;
1386        if !unsettled.iter().any(|changing| *changing) {
1387            break;
1388        }
1389    }
1390
1391    let gateway_name = result.gateway.as_str();
1392    let root = LedgerRoot::for_node(&loaded.target.nodes.master);
1393    for ((name, read), changing) in names.iter().zip(current).zip(unsettled) {
1394        let live = match read {
1395            Ok(sha256) => Some(sha256),
1396            Err(code) => {
1397                sweep.warnings.push(format!(
1398                    "child project {name}: its export could not be read ({code})"
1399                ));
1400                None
1401            }
1402        };
1403        if changing {
1404            sweep.warnings.push(format!(
1405                "child project {name} did not settle within {} s",
1406                opts.children_settle.as_secs()
1407            ));
1408        }
1409        let record = match surfaces.host.read_release_record(gateway_name, name).await {
1410            Ok(None) => None,
1411            Ok(Some(bytes)) => {
1412                let location = release_record_location(root, gateway_name, name);
1413                match ReleaseRecord::parse(&bytes, &location, gateway_name, name) {
1414                    Ok(record) => Some(record),
1415                    Err(err) => {
1416                        sweep.warnings.push(format!(
1417                            "child project {name}: its release record could not be read ({})",
1418                            err.code()
1419                        ));
1420                        None
1421                    }
1422                }
1423            }
1424            Err(err) => {
1425                sweep.warnings.push(format!(
1426                    "child project {name}: its release record could not be read ({})",
1427                    err.code()
1428                ));
1429                None
1430            }
1431        };
1432        let status = match (&record, &live) {
1433            (Some(record), Some(live)) if record.artifact_sha256 == *live => ChildStatus::Ok,
1434            (Some(record), Some(_)) => {
1435                sweep.warnings.push(format!(
1436                    "child project {name} no longer matches the release this tool last deployed to it ({})",
1437                    record.release
1438                ));
1439                ChildStatus::Mismatch
1440            }
1441            _ => ChildStatus::Unverified,
1442        };
1443        events.emit_late(ProjectsEvent::ChildChecked {
1444            name: name.clone(),
1445            status,
1446        });
1447        sweep.children.push(ChildReport {
1448            name: name.clone(),
1449            status,
1450            release: record.as_ref().map(|r| r.release.clone()),
1451            recorded_sha256: record.map(|r| r.artifact_sha256),
1452            live_sha256: live,
1453        });
1454    }
1455    sweep
1456}
1457
1458#[allow(clippy::too_many_arguments)]
1459async fn execute(
1460    plan: &DeployProjectsResult,
1461    loaded: &LoadedProject,
1462    input: &ProjectsInput,
1463    surfaces: &ProjectsSurfaces<'_>,
1464    opts: &ProjectsRunOptions<'_>,
1465    writer: &dyn ReleaseRecordWriter,
1466    run_dir: &RunDir,
1467    run_id: &RunId,
1468    rel: &str,
1469    designers: &DesignersFound,
1470    observer: &dyn ProjectsObserver,
1471) -> Result<DeployProjectsResult, CoreError> {
1472    let project = plan.project.as_str();
1473    let gateway = surfaces.gateway;
1474    let artifact = &loaded.artifact.digest;
1475
1476    run_dir.write_json(
1477        "run.json",
1478        &serde_json::json!({
1479            "run_id": run_id.as_str(),
1480            "started_at": rfc3339_now_utc(),
1481            "env": plan.env,
1482            "gateway": plan.gateway,
1483            "project": plan.project,
1484            "release": plan.release,
1485            "action": plan.action,
1486            "artifact_sha256": plan.artifact_sha256,
1487            "live_sha256": plan.live_sha256,
1488            "allow_downgrade": input.allow_downgrade,
1489            "allow_first_change": input.allow_first_change,
1490            "designers": designers.mode,
1491            "designers_timeout_s": designers.timeout_s,
1492            "designers_seen": sessions_json(&designers.seen),
1493            "run_dir_git": run_dir.git_status().as_record(),
1494        }),
1495    )?;
1496    run_dir.write_json("plan.json", plan)?;
1497    let mut events = RunEvents {
1498        log: EventLog::open(run_dir)?,
1499        observer,
1500        late_failure: false,
1501    };
1502    events.emit(ProjectsEvent::RunStarted {
1503        run_id: run_id.as_str().to_string(),
1504        action: plan.action,
1505    })?;
1506    // The Designer pre-check ran before this directory existed: its reads,
1507    // and the choice to go ahead, are put on record now (D-15).
1508    for open in &designers.polls {
1509        events.record(&ProjectsEvent::DesignersPoll { open: *open })?;
1510    }
1511    if designers.proceeded {
1512        events.emit(ProjectsEvent::DesignersProceed {
1513            sessions: designers.seen.clone(),
1514        })?;
1515    }
1516    if designers.skipped {
1517        events.emit(ProjectsEvent::DesignersSkipped)?;
1518    }
1519
1520    let mut result = plan.clone();
1521    result.run_id = Some(run_id.as_str().to_string());
1522    result.run_dir = Some(rel.to_string());
1523    let record = release_record(plan, &loaded.release.tag, run_id);
1524
1525    if plan.action == ProjectAction::Record {
1526        // Only the record moves, and only for a project that still is the
1527        // artifact: the record claims exactly that.
1528        let zip = gateway.export_bytes(project).await?;
1529        let live = live_digest(project, &zip)?;
1530        if live.sha256 != artifact.sha256 {
1531            return Err(stale_live(artifact.sha256.clone(), live.sha256));
1532        }
1533        events.emit(ProjectsEvent::Verified { equal: true })?;
1534        if let Err(err) = writer
1535            .write_release_record(&plan.gateway, project, &record.to_bytes())
1536            .await
1537        {
1538            events.emit_late(ProjectsEvent::RecordFailed {
1539                code: err.code().to_string(),
1540            });
1541            return Err(err);
1542        }
1543        // The record is on the host: from here a log failure is remembered
1544        // and reported, never turned into an error for a run that changed
1545        // state.
1546        finish_recorded(&mut result, &mut events);
1547        return Ok(result);
1548    }
1549
1550    let overwrite = plan.action == ProjectAction::Import;
1551    if overwrite {
1552        // The pre-image (DPRJ-03): the live project as it is now, saved
1553        // before it is replaced. It is secret-bearing and goes to the 0600
1554        // file in the run directory and nowhere else.
1555        let pre = gateway.export_bytes(project).await?;
1556        let live = live_digest(project, &pre)?;
1557        let mut file = run_dir.create_private_file_in("export", &format!("{project}.zip"))?;
1558        file.write_all(&pre)
1559            .and_then(|()| file.sync_all())
1560            .map_err(|e| CoreError::DeployRunInvalid {
1561                run_id: run_id.as_str().to_string(),
1562                problem: format!("could not write the pre-image export ({:?})", e.kind()),
1563            })?;
1564        events.emit(ProjectsEvent::PreImageExported { bytes: pre.len() })?;
1565        // K15: the project the plan was drawn against is the one that is
1566        // about to be replaced.
1567        if plan.live_sha256.as_deref() != Some(live.sha256.as_str()) {
1568            return Err(stale_live(
1569                plan.live_sha256
1570                    .clone()
1571                    .unwrap_or_else(|| "none".to_string()),
1572                live.sha256,
1573            ));
1574        }
1575    }
1576
1577    events.emit(ProjectsEvent::ImportSent { overwrite })?;
1578    let answer = match gateway
1579        .project_import(project, loaded.artifact.zip.clone(), overwrite)
1580        .await
1581    {
1582        Ok(answer) => Some(answer),
1583        // The request may have reached the gateway and been applied (a
1584        // timeout or a reset on a 300 s synchronous import). Do not call that
1585        // "nothing happened": read the project back and let the verdict and
1586        // the restore command speak. No second import is ever sent.
1587        Err(err) if import_outcome_unknown(&err) => {
1588            result.warnings.push(format!(
1589                "the import request failed after it may have reached the gateway ({}); the project was read back to find out what it holds",
1590                err.code()
1591            ));
1592            None
1593        }
1594        // The gateway answered with a refusal, or the request never went out.
1595        Err(err) => return Err(err),
1596    };
1597    // From here the gateway may have changed. Nothing below returns an error:
1598    // what is left is verifying, recording and reporting.
1599    if let Some(answer) = &answer {
1600        events.emit_late(ProjectsEvent::ImportAnswered);
1601        if answer
1602            .response
1603            .get("changes")
1604            .and_then(serde_json::Value::as_array)
1605            .is_some_and(Vec::is_empty)
1606        {
1607            result
1608                .warnings
1609                .push("the gateway reported no changes for this import".to_string());
1610        }
1611    }
1612
1613    let equal = match gateway.export_bytes(project).await {
1614        Ok(zip) => match live_digest(project, &zip) {
1615            Ok(live) => Some(live.sha256 == artifact.sha256),
1616            Err(err) => {
1617                result.warnings.push(format!(
1618                    "could not verify the import: the project read back is unusable ({})",
1619                    err.code()
1620                ));
1621                None
1622            }
1623        },
1624        Err(err) => {
1625            result.warnings.push(format!(
1626                "could not verify the import: the project could not be read back ({})",
1627                err.code()
1628            ));
1629            None
1630        }
1631    };
1632    events.emit_late(ProjectsEvent::Verified {
1633        equal: equal == Some(true),
1634    });
1635
1636    if equal == Some(true) {
1637        match writer
1638            .write_release_record(&plan.gateway, project, &record.to_bytes())
1639            .await
1640        {
1641            Ok(()) => {
1642                events.emit_late(ProjectsEvent::RecordWritten);
1643                result.record_written = true;
1644            }
1645            Err(err) => {
1646                events.emit_late(ProjectsEvent::RecordFailed {
1647                    code: err.code().to_string(),
1648                });
1649                result.warnings.push(format!(
1650                    "the import is verified but the release record could not be written ({})",
1651                    err.code()
1652                ));
1653            }
1654        }
1655        // K6 a: the gateway's own verdict on the parent link, read after the
1656        // import. A true value is a warning, never a failure.
1657        if let Some(parent) = &loaded.meta.parent {
1658            match gateway.projects().await {
1659                Ok(records) => {
1660                    if records
1661                        .iter()
1662                        .find(|r| r.name == project)
1663                        .is_some_and(|r| r.invalid_parent == Some(true))
1664                    {
1665                        result.warnings.push(format!(
1666                            "project {project}: after the import the gateway reports that its parent {parent} is invalid"
1667                        ));
1668                    }
1669                }
1670                Err(err) => result.warnings.push(format!(
1671                    "project {project}: the parent could not be read back after the import ({})",
1672                    err.code()
1673                )),
1674            }
1675        }
1676        result.outcome = if overwrite {
1677            ProjectOutcome::Imported
1678        } else {
1679            ProjectOutcome::Created
1680        };
1681    } else {
1682        // K11: the project is not what was reviewed, and nothing here makes
1683        // it worse: no record, no second import, no further gateway call.
1684        result.outcome = ProjectOutcome::ImportedUnverified;
1685        result.ok = false;
1686        result.slug = Some(PROJECT_UNVERIFIED_SLUG.to_string());
1687        // K11: no automatic restore. The operator gets the command that
1688        // would, when a pre-image exists (an overwrite; a created project has
1689        // nothing to go back to).
1690        if overwrite {
1691            result.restore_command = Some(restore_command(project, rel));
1692        }
1693    }
1694    // D-06, K7: a parent that was replaced is followed by a look at the
1695    // projects that inherit from it. Reads only, and nothing in it can change
1696    // the outcome, the exit code or the gateway.
1697    if matches!(
1698        result.outcome,
1699        ProjectOutcome::Imported | ProjectOutcome::ImportedUnverified
1700    ) {
1701        let sweep = sweep_children(&result, loaded, surfaces, opts, &mut events).await;
1702        result.children = sweep.children;
1703        result.warnings.extend(sweep.warnings);
1704    }
1705    events.emit_late(ProjectsEvent::Finished {
1706        outcome: result.outcome,
1707    });
1708    note_late_log_failure(&mut result, &events);
1709    Ok(result)
1710}
1711
1712/// Whether a failed import request may nonetheless have been applied by the
1713/// gateway: a transport failure after the request went out (a timeout, a
1714/// reset) and an unclassified answer (a 5xx can follow a partial apply). A
1715/// refusal the gateway gave (`auth_failed`, `import_denied`, `not_found`, an
1716/// uncommissioned or restarting gateway) and a connection that was never made
1717/// are not: nothing was applied, and the error stands as it is.
1718fn import_outcome_unknown(err: &CoreError) -> bool {
1719    match err {
1720        CoreError::Network {
1721            source: Some(source),
1722            ..
1723        } => !(source.is_connect() || source.is_builder()),
1724        CoreError::Network { source: None, .. } | CoreError::Internal(_) => true,
1725        _ => false,
1726    }
1727}
1728
1729/// The tail of a `record` run, after the release record is on the host: the
1730/// result says so first, then the events are logged late, so a log that fails
1731/// here is a warning and never an error (the import branch does the same).
1732fn finish_recorded(result: &mut DeployProjectsResult, events: &mut RunEvents<'_>) {
1733    result.record_written = true;
1734    result.outcome = ProjectOutcome::Recorded;
1735    events.emit_late(ProjectsEvent::RecordWritten);
1736    events.emit_late(ProjectsEvent::Finished {
1737        outcome: result.outcome,
1738    });
1739    note_late_log_failure(result, events);
1740}
1741
1742/// Warn when an event could not be logged after the gateway or host changed.
1743fn note_late_log_failure(result: &mut DeployProjectsResult, events: &RunEvents<'_>) {
1744    if events.late_failure {
1745        result
1746            .warnings
1747            .push("the run's events log could not be written to the end".to_string());
1748    }
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754
1755    fn sample_zip() -> Vec<u8> {
1756        use std::io::Write as _;
1757        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1758        let options = zip::write::SimpleFileOptions::default()
1759            .compression_method(zip::CompressionMethod::Deflated);
1760        for (name, bytes) in [
1761            ("project.json", br#"{"title":"T"}"#.as_slice()),
1762            ("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
1763            ("ignition/s/a.py", &[b'x'; 4096]),
1764        ] {
1765            writer.start_file(name, options).expect("start");
1766            writer.write_all(bytes).expect("write");
1767        }
1768        writer.finish().expect("finish").into_inner()
1769    }
1770
1771    #[test]
1772    fn check_artifact_zip_refuses_truncated() {
1773        let whole = sample_zip();
1774        check_artifact_zip(&whole).expect("the whole zip passes");
1775        let half = &whole[..whole.len() / 2];
1776        let err = check_artifact_zip(half).expect_err("half a zip");
1777        assert_eq!(err.code(), "invalid_import_file");
1778        // Cut inside the last member's data, central directory gone too.
1779        let err = check_artifact_zip(&whole[..whole.len() - 40]).expect_err("tail cut");
1780        assert_eq!(err.code(), "invalid_import_file");
1781        let err = check_artifact_zip(b"not a zip").expect_err("no magic");
1782        assert_eq!(err.code(), "invalid_import_file");
1783    }
1784
1785    fn sample_result() -> DeployProjectsResult {
1786        DeployProjectsResult {
1787            env: "prod".to_string(),
1788            gateway: "gw".to_string(),
1789            project: "CapApp".to_string(),
1790            release: "v1.0.0".to_string(),
1791            check: false,
1792            action: ProjectAction::Record,
1793            would_change: true,
1794            artifact_sha256: "a".repeat(64),
1795            live_sha256: Some("a".repeat(64)),
1796            members: MemberDelta::default(),
1797            removed: Vec::new(),
1798            removed_count: 0,
1799            excluded: Vec::new(),
1800            excluded_count: 0,
1801            unmanaged: Vec::new(),
1802            unmanaged_count: 0,
1803            parent: None,
1804            previous_release: None,
1805            recorded_sha256: None,
1806            live_matches_last_deploy: None,
1807            allow_downgrade: false,
1808            allow_first_change: None,
1809            warnings: Vec::new(),
1810            run_id: None,
1811            run_dir: None,
1812            record_written: false,
1813            restore_command: None,
1814            children: Vec::new(),
1815            outcome: ProjectOutcome::Checked,
1816            ok: true,
1817            slug: None,
1818        }
1819    }
1820
1821    /// WR-05: once the release record is on the host, a log that cannot be
1822    /// written is a warning on a successful `record` run, not an error, and
1823    /// the result still says the record was written.
1824    #[test]
1825    fn a_log_failure_after_the_record_is_written_is_a_warning() {
1826        let dir = tempfile::tempdir().expect("tempdir");
1827        let path = dir.path().join("events.ndjson");
1828        std::fs::write(&path, b"").expect("seed");
1829        // Opened read-only: every append fails.
1830        let read_only = std::fs::File::open(&path).expect("open read-only");
1831        let mut events = RunEvents {
1832            log: EventLog::from_file(read_only),
1833            observer: &NoProjectsObserver,
1834            late_failure: false,
1835        };
1836        let mut result = sample_result();
1837
1838        finish_recorded(&mut result, &mut events);
1839
1840        assert!(result.record_written, "the record is on the host");
1841        assert_eq!(result.outcome, ProjectOutcome::Recorded);
1842        assert!(result.ok, "a log failure does not fail the run");
1843        assert!(events.late_failure);
1844        assert!(
1845            result
1846                .warnings
1847                .iter()
1848                .any(|w| w.contains("events log could not be written")),
1849            "{:?}",
1850            result.warnings
1851        );
1852    }
1853
1854    #[test]
1855    fn a_built_artifact_passes_the_check() {
1856        let dir = tempfile::tempdir().expect("tempdir");
1857        for (rel, bytes) in [
1858            ("project.json", br#"{"title":"T"}"#.as_slice()),
1859            ("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
1860            ("ignition/s/a.py", b"pass"),
1861        ] {
1862            let path = dir.path().join(rel);
1863            std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1864            std::fs::write(path, bytes).expect("write");
1865        }
1866        let artifact = build_artifact(dir.path()).expect("artifact");
1867        check_artifact_zip(&artifact.zip).expect("a built artifact is a valid import");
1868    }
1869}