Skip to main content

release_kit/plan/
gather.rs

1//! The observation: everything the planner reads off the target, the
2//! host, and — where asked — the forge, gathered once and stamped in the
3//! evidence ledger.
4//!
5//! This is the I/O half of planning. It reads and never writes, and it
6//! answers with data the pure planner consumes: the record as bytes and
7//! as a parsed document, the configuration, every destination's bytes,
8//! the repository's facts, the pin, and the forge's trunk tip where the
9//! read was opted into.
10
11use std::collections::BTreeMap;
12use std::process::Command;
13
14use camino::Utf8Path;
15
16use crate::config::Config;
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::digest::Digest;
19use crate::error::RkError;
20use crate::landing::manifest::{self, Manifest, Style, Workflow};
21use crate::landing::{self, Params};
22use crate::release::ReleaseSource;
23
24use super::PinState;
25use super::classify::RepositoryFacts;
26use super::evidence::{EvidenceKind, Ledger};
27
28/// The landing record, as read.
29#[derive(Debug)]
30pub enum RecordRead {
31    /// No record at the target.
32    Absent,
33    /// A record this engine read.
34    Present {
35        /// The parsed record.
36        manifest: Box<Manifest>,
37        /// Its bytes, as found.
38        bytes: Vec<u8>,
39    },
40    /// A record this engine could not read.
41    Invalid {
42        /// Why.
43        reason: String,
44    },
45}
46
47/// The committed configuration, as read.
48#[derive(Debug)]
49pub enum ConfigRead {
50    /// No configuration at the target.
51    Absent,
52    /// A configuration this engine read.
53    Present {
54        /// The parsed configuration.
55        config: Box<Config>,
56        /// Its bytes, as found.
57        bytes: Vec<u8>,
58    },
59    /// A configuration this engine could not read.
60    Invalid {
61        /// Why.
62        reason: String,
63        /// Its bytes, as found.
64        bytes: Vec<u8>,
65    },
66}
67
68/// What the forge said.
69#[derive(Debug)]
70pub enum ForgeRead {
71    /// The forge was not asked.
72    NotObserved {
73        /// Why.
74        reason: String,
75    },
76    /// The forge was asked about the trunk.
77    Observed {
78        /// The trunk asked about.
79        trunk: String,
80        /// The trunk's tip at the remote, where it has one.
81        remote_tip: Option<String>,
82    },
83}
84
85/// The evidence ids each section of the observation cites.
86#[derive(Debug, Default)]
87pub struct Refs {
88    /// The repository facts.
89    pub repository: Vec<String>,
90    /// The record.
91    pub record: Option<String>,
92    /// The configuration.
93    pub configuration: Option<String>,
94    /// Each destination read, by path.
95    pub destinations: BTreeMap<String, String>,
96    /// The host.
97    pub host: Vec<String>,
98    /// The pin.
99    pub pin: Option<String>,
100    /// The forge.
101    pub forge: Vec<String>,
102}
103
104/// Everything observed, as data.
105#[derive(Debug)]
106pub struct Observation {
107    /// The target, as given.
108    pub target: String,
109    /// Whether the target is a git repository.
110    pub git: bool,
111    /// The technology the version file names.
112    pub tech: Option<String>,
113    /// The forge the origin remote maps to.
114    pub forge_name: Option<String>,
115    /// The project path from the origin remote.
116    pub repo: Option<String>,
117    /// The facts the verdict reads.
118    pub facts: RepositoryFacts,
119    /// The record.
120    pub record: RecordRead,
121    /// The configuration.
122    pub config: ConfigRead,
123    /// Every destination present, by path, as bytes: the whole file, or
124    /// the marked block for the two block destinations.
125    pub files: BTreeMap<String, Vec<u8>>,
126    /// The hook file's defect, where it has one.
127    pub hooks_defect: Option<String>,
128    /// The pin the wired manager records.
129    pub pin: Option<PinState>,
130    /// The forge.
131    pub forge: ForgeRead,
132    /// The ledger, stamped as each fact was read.
133    pub ledger: Ledger,
134    /// The ids the sections cite.
135    pub refs: Refs,
136}
137
138/// The landing parameters, resolved or not, with what withheld the Nix
139/// destinations where the target cannot take them.
140#[derive(Debug)]
141pub struct Resolution {
142    /// The parameters, where they resolved.
143    pub params: Option<Params>,
144    /// Which layer answered each parameter.
145    pub sources: BTreeMap<String, String>,
146    /// Why they did not resolve, where they did not.
147    pub unresolved: Option<String>,
148    /// The Nix destinations withheld, with the one reason.
149    pub nix_withheld: Option<(Vec<String>, String)>,
150}
151
152/// The explicit answers a request carries.
153#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
154pub struct Flags {
155    /// Binding override.
156    pub tech: Option<String>,
157    /// Forge override.
158    pub forge: Option<String>,
159    /// Repository override.
160    pub repo: Option<String>,
161    /// Workflow override.
162    pub workflow: Option<String>,
163    /// Release style override.
164    pub style: Option<String>,
165    /// Nix capability override.
166    pub nix: Option<bool>,
167}
168
169/// One request to observe a target.
170pub struct Request<'a> {
171    /// The target.
172    pub target: &'a Utf8Path,
173    /// The explicit answers.
174    pub flags: &'a Flags,
175    /// The decisions selected, by id.
176    pub decisions: &'a BTreeMap<String, String>,
177    /// Whether to read the forge.
178    pub observe_forge: bool,
179    /// The instant every evidence item is stamped with.
180    pub clock: &'a str,
181    /// The candidate source, for the reads that validate against it.
182    pub source: &'a dyn ReleaseSource,
183}
184
185/// Read the target.
186///
187/// # Errors
188///
189/// Returns [`RkError::Missing`] for a target that is not a directory,
190/// the assessment's own failures where git cannot answer for a
191/// repository, and [`RkError::Io`] for a read that fails for a reason
192/// other than absence. A record or a configuration that does not read
193/// is an observation, not a failure.
194pub fn observe(request: &Request<'_>) -> Result<Observation, RkError> {
195    let target = request.target;
196    if !target.is_dir() {
197        return Err(RkError::missing(
198            Diagnostic::new(
199                Reason::TargetNotFound,
200                format!("target {target} is not a directory"),
201            )
202            .expected("an existing repository to plan for"),
203        ));
204    }
205    let clock = request.clock;
206    let mut ledger = Ledger::new();
207    let mut refs = Refs::default();
208    let record = read_record(target, clock, &mut ledger, &mut refs)?;
209    let config = read_config(target, clock, &mut ledger, &mut refs)?;
210    let facts = crate::assess::gather_facts(target)?;
211    refs.repository.push(ledger.observe(
212        "repository",
213        EvidenceKind::Repository,
214        "rk",
215        clock,
216        None,
217        "marker scan, payload destinations, git tag --list, git for-each-ref, version file",
218    ));
219    let files = read_destinations(target, &record, clock, &mut ledger, &mut refs)?;
220    let hooks_defect = landing::hooks_file_defect(request.source, target)?;
221    refs.host.push(ledger.observe(
222        "host",
223        EvidenceKind::Host,
224        "rk",
225        clock,
226        None,
227        "the engine's own version",
228    ));
229    let pin = read_pin(target, clock, &mut ledger, &mut refs);
230    let forge = if request.observe_forge {
231        let trunk = crate::config::trunk_of(target.as_std_path())?;
232        let remote_tip = remote_tip(target, &trunk)?;
233        refs.forge.push(ledger.observe(
234            "forge:trunk",
235            EvidenceKind::Forge,
236            "git",
237            clock,
238            None,
239            format!("git ls-remote --heads origin {trunk}"),
240        ));
241        ForgeRead::Observed { trunk, remote_tip }
242    } else {
243        ForgeRead::NotObserved {
244            reason: "the forge read was not requested; --observe forge opts in".into(),
245        }
246    };
247    Ok(Observation {
248        target: target.to_string(),
249        git: facts.git,
250        tech: facts.tech.map(str::to_owned),
251        forge_name: facts.forge.map(str::to_owned),
252        repo: facts.repo.clone(),
253        facts: RepositoryFacts {
254            release_markers: facts.release_markers,
255            collisions: facts.collisions,
256            tags: facts.tags,
257            long_lived_branches: facts.long_lived_branches,
258        },
259        record,
260        config,
261        files,
262        hooks_defect,
263        pin,
264        forge,
265        ledger,
266        refs,
267    })
268}
269
270/// The record, as bytes and as a document, stamped.
271fn read_record(
272    target: &Utf8Path,
273    clock: &str,
274    ledger: &mut Ledger,
275    refs: &mut Refs,
276) -> Result<RecordRead, RkError> {
277    let bytes = read_optional(&target.join(manifest::MANIFEST_PATH))?;
278    let record = match (&bytes, manifest::load(target)) {
279        (None, _) => RecordRead::Absent,
280        (Some(bytes), Ok(Some(manifest))) => RecordRead::Present {
281            manifest: Box::new(manifest),
282            bytes: bytes.clone(),
283        },
284        (Some(_), Ok(None)) => RecordRead::Invalid {
285            reason: "the record vanished between two reads".into(),
286        },
287        (Some(_), Err(error)) => RecordRead::Invalid {
288            reason: error.to_string(),
289        },
290    };
291    refs.record = Some(ledger.observe(
292        "record",
293        EvidenceKind::Record,
294        "rk",
295        clock,
296        bytes.as_deref().map(Digest::of),
297        format!("read {}", manifest::MANIFEST_PATH),
298    ));
299    Ok(record)
300}
301
302/// The configuration, as bytes and as a document, stamped.
303fn read_config(
304    target: &Utf8Path,
305    clock: &str,
306    ledger: &mut Ledger,
307    refs: &mut Refs,
308) -> Result<ConfigRead, RkError> {
309    let bytes = read_optional(&target.join(crate::config::CONFIG_PATH))?;
310    let config = match (&bytes, crate::config::load(target.as_std_path())) {
311        (None, _) => ConfigRead::Absent,
312        (Some(bytes), Ok(Some(config))) => ConfigRead::Present {
313            config: Box::new(config),
314            bytes: bytes.clone(),
315        },
316        (Some(bytes), Ok(None)) => ConfigRead::Invalid {
317            reason: "the configuration vanished between two reads".into(),
318            bytes: bytes.clone(),
319        },
320        (Some(bytes), Err(error)) => ConfigRead::Invalid {
321            reason: error.to_string(),
322            bytes: bytes.clone(),
323        },
324    };
325    refs.configuration = Some(ledger.observe(
326        "configuration",
327        EvidenceKind::Configuration,
328        "rk",
329        clock,
330        bytes.as_deref().map(Digest::of),
331        format!("read {}", crate::config::CONFIG_PATH),
332    ));
333    Ok(config)
334}
335
336/// Every destination the payload can land, and every one the record
337/// names, as bytes, each stamped.
338fn read_destinations(
339    target: &Utf8Path,
340    record: &RecordRead,
341    clock: &str,
342    ledger: &mut Ledger,
343    refs: &mut Refs,
344) -> Result<BTreeMap<String, Vec<u8>>, RkError> {
345    let mut paths: Vec<String> = landing::destinations().map(str::to_owned).collect();
346    if let RecordRead::Present { manifest, .. } = record {
347        paths.extend(manifest.files.iter().map(|file| file.destination.clone()));
348    }
349    paths.sort();
350    paths.dedup();
351    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
352    for path in paths {
353        let bytes = landing::read_recorded(target, &path)?;
354        let id = ledger.observe(
355            format!("destination:{path}"),
356            EvidenceKind::Destination,
357            "rk",
358            clock,
359            bytes.as_deref().map(Digest::of),
360            if landing::block_markers(&path).is_some() {
361                "read the marked block"
362            } else {
363                "read the file"
364            },
365        );
366        refs.destinations.insert(path.clone(), id);
367        if let Some(bytes) = bytes {
368            files.insert(path, bytes);
369        }
370    }
371    Ok(files)
372}
373
374/// The pin the wired manager records, where exactly one names
375/// release-kit, stamped.
376fn read_pin(
377    target: &Utf8Path,
378    clock: &str,
379    ledger: &mut Ledger,
380    refs: &mut Refs,
381) -> Option<PinState> {
382    let pin = crate::self_depend::observe(target)
383        .ok()
384        .and_then(|observed| {
385            let manager = observed.wired?;
386            let entry = observed.entry(manager)?;
387            Some(PinState {
388                manager: manager.as_str().to_owned(),
389                file: entry.file.clone()?,
390                version: entry.version.clone()?,
391            })
392        })?;
393    refs.pin = Some(ledger.observe(
394        "pin",
395        EvidenceKind::Pin,
396        "rk self-depend",
397        clock,
398        files_digest(target, &pin.file),
399        format!("read {}", pin.file),
400    ));
401    Some(pin)
402}
403
404/// Resolve the landing parameters over the flags, the decisions, the
405/// configuration, and the record, and say which layer answered each.
406///
407/// A decision the operator selected for the workflow mode or the release
408/// style answers as a flag would. The resolution never refuses: an
409/// unresolved identity is a reason the planner turns into a blocked
410/// precondition.
411///
412/// # Errors
413///
414/// Returns [`RkError::Usage`] for a flag value outside its grammar, and
415/// the Nix shape read's failures other than absence.
416pub fn resolve(request: &Request<'_>, observation: &Observation) -> Result<Resolution, RkError> {
417    let flags = request.flags;
418    let workflow_flag = flags
419        .workflow
420        .clone()
421        .or_else(|| request.decisions.get("workflow-mode").cloned());
422    let style_flag = flags
423        .style
424        .clone()
425        .or_else(|| request.decisions.get("release-style").cloned());
426    let inputs = landing::Inputs {
427        tech: flags.tech.as_deref(),
428        forge: flags.forge.as_deref(),
429        repo: flags.repo.as_deref(),
430        workflow: workflow_flag.as_deref().map(Workflow::parse).transpose()?,
431        style: style_flag.as_deref().map(Style::parse).transpose()?,
432        nix: flags.nix,
433    };
434    let config: Option<&Config> = match &observation.config {
435        ConfigRead::Present { config, .. } => Some(config),
436        ConfigRead::Absent | ConfigRead::Invalid { .. } => None,
437    };
438    let record: Option<&Manifest> = match &observation.record {
439        RecordRead::Present { manifest, .. } => Some(manifest),
440        RecordRead::Absent | RecordRead::Invalid { .. } => None,
441    };
442    let sources = sources(
443        &inputs,
444        flags,
445        request.decisions,
446        config,
447        record,
448        observation,
449    );
450    let resolved = Params::resolve(
451        request.source,
452        request.target,
453        &inputs,
454        config,
455        record,
456        landing::Purpose::Preview,
457    );
458    let (params, unresolved) = match resolved {
459        Ok(params) => (Some(params), None),
460        Err(error) => (None, Some(error.to_string())),
461    };
462    let nix_withheld = match &params {
463        Some(params) if params.nix() => landing::nix_withholding(request.target, record)?
464            .map(|(set, reason)| (set.iter().map(|path| (*path).to_owned()).collect(), reason)),
465        _ => None,
466    };
467    Ok(Resolution {
468        params,
469        sources,
470        unresolved,
471        nix_withheld,
472    })
473}
474
475/// Which layer answered each parameter: `flag`, `decision`,
476/// `configuration`, `record`, `detected`, or `default`.
477fn sources(
478    inputs: &landing::Inputs<'_>,
479    flags: &Flags,
480    decisions: &BTreeMap<String, String>,
481    config: Option<&Config>,
482    record: Option<&Manifest>,
483    observation: &Observation,
484) -> BTreeMap<String, String> {
485    let mut sources = BTreeMap::new();
486    let layer = |flag: bool, configured: bool, recorded: bool, detected: bool| {
487        if flag {
488            "flag"
489        } else if configured {
490            "configuration"
491        } else if recorded {
492            "record"
493        } else if detected {
494            "detected"
495        } else {
496            "default"
497        }
498    };
499    identity_sources(&mut sources, inputs, config, record, observation, layer);
500    let decided = |id: &str, flag: bool, configured: bool, recorded: bool| {
501        if flag {
502            "flag"
503        } else if decisions.contains_key(id) {
504            "decision"
505        } else if configured {
506            "configuration"
507        } else if recorded {
508            "record"
509        } else {
510            "default"
511        }
512    };
513    sources.insert(
514        "workflow".to_owned(),
515        decided(
516            "workflow-mode",
517            flags.workflow.is_some(),
518            config.is_some_and(|c| c.landing.workflow.is_some()),
519            record.is_some(),
520        )
521        .to_owned(),
522    );
523    sources.insert(
524        "style".to_owned(),
525        decided(
526            "release-style",
527            flags.style.is_some(),
528            config.is_some_and(|c| c.landing.style.is_some()),
529            record.is_some_and(|r| r.parameters.style.is_some()),
530        )
531        .to_owned(),
532    );
533    sources.insert(
534        "nix".to_owned(),
535        layer(
536            inputs.nix.is_some(),
537            config.is_some_and(|c| c.landing.nix.is_some()),
538            record.is_some(),
539            false,
540        )
541        .to_owned(),
542    );
543    for (key, configured) in [
544        ("trunk", config.is_some_and(|c| c.project.trunk.is_some())),
545        (
546            "line_prefix",
547            config.is_some_and(|c| c.setup.line_prefix.is_some()),
548        ),
549        (
550            "security_contact",
551            config.is_some_and(|c| c.security.contact.is_some()),
552        ),
553        (
554            "security_response",
555            config.is_some_and(|c| c.security.response.is_some()),
556        ),
557    ] {
558        sources.insert(
559            key.to_owned(),
560            layer(false, configured, record.is_some(), false).to_owned(),
561        );
562    }
563    sources
564}
565
566/// The three identity parameters: flag, configuration, record, or the
567/// detection.
568fn identity_sources(
569    sources: &mut BTreeMap<String, String>,
570    inputs: &landing::Inputs<'_>,
571    config: Option<&Config>,
572    record: Option<&Manifest>,
573    observation: &Observation,
574    layer: impl Fn(bool, bool, bool, bool) -> &'static str,
575) {
576    sources.insert(
577        "tech".to_owned(),
578        layer(
579            inputs.tech.is_some(),
580            config.is_some_and(|c| !c.project.tech.is_empty()),
581            record.is_some(),
582            observation.tech.is_some(),
583        )
584        .to_owned(),
585    );
586    sources.insert(
587        "forge".to_owned(),
588        layer(
589            inputs.forge.is_some(),
590            config.is_some_and(|c| !c.project.forge.is_empty()),
591            record.is_some(),
592            observation.forge_name.is_some(),
593        )
594        .to_owned(),
595    );
596    sources.insert(
597        "repo".to_owned(),
598        layer(
599            inputs.repo.is_some(),
600            config.is_some_and(|c| !c.project.repo.is_empty()),
601            record.is_some(),
602            observation.repo.is_some(),
603        )
604        .to_owned(),
605    );
606}
607
608/// The bytes at `path`, or `None` where nothing is there.
609fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>, RkError> {
610    match std::fs::read(path) {
611        Ok(bytes) => Ok(Some(bytes)),
612        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
613        Err(error) => Err(RkError::Io(error)),
614    }
615}
616
617/// The digest of a file under the target, where it reads.
618fn files_digest(target: &Utf8Path, rel: &str) -> Option<Digest> {
619    std::fs::read(target.join(rel))
620        .ok()
621        .map(|bytes| Digest::of(&bytes))
622}
623
624/// The trunk's tip at `origin`, through one read-only git call.
625///
626/// A remote without the branch answers `None`; a remote that cannot be
627/// reached is a failure, because a forge asked and not answering is not
628/// an observation.
629fn remote_tip(target: &Utf8Path, trunk: &str) -> Result<Option<String>, RkError> {
630    let mut command = Command::new(crate::probes::git_bin());
631    for var in crate::maintenance::GIT_HOOK_VARS {
632        command.env_remove(var);
633    }
634    let out = command
635        .arg("-C")
636        .arg(target)
637        .args(["ls-remote", "--heads", "origin", trunk])
638        .output()
639        .map_err(|error| {
640            RkError::subprocess(
641                Diagnostic::new(
642                    Reason::SubprocessSpawn,
643                    format!("git could not be spawned: {error}"),
644                )
645                .expected("git on PATH, or RK_GIT_BIN naming it"),
646            )
647        })?;
648    if !out.status.success() {
649        return Err(RkError::subprocess(
650            Diagnostic::new(
651                Reason::ForgeTemporary,
652                format!(
653                    "git ls-remote could not read origin: {}",
654                    String::from_utf8_lossy(&out.stderr).trim()
655                ),
656            )
657            .expected("a reachable origin remote, or a plan without --observe forge"),
658        ));
659    }
660    Ok(String::from_utf8_lossy(&out.stdout)
661        .lines()
662        .find_map(|line| line.split_whitespace().next().map(str::to_owned)))
663}