1use std::collections::{BTreeMap, BTreeSet};
6use std::iter;
7use std::panic;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::thread;
10
11use serde::{Deserialize, Serialize};
12
13use crate::digest::DigestWriter;
14use crate::errors::StoreError;
15use crate::jigsaw::plan::JigsawPlan;
16use crate::jigsaw::surface::{PinApps, PlanSurfaces};
17use crate::model::names::{ApplicationName, CommitSha, ProjectName, StoreDigest, vocabulary};
18use crate::model::pin::Pin;
19use crate::model::snapshot::{Snapshot, SnapshotHeader, state};
20use crate::snapshot::freshness::{ExpectedExtraction, StaleReason, extraction_freshness};
21use crate::snapshot::parser;
22use crate::store::{ReadOnly, SnapshotStore};
23
24vocabulary!(
25 pub enum SnapshotGap: "snapshot gap" {
26 Missing => "missing",
27 Stale => "stale",
28 CommitMismatch => "commit_mismatch",
29 }
30);
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct StalePin {
34 pub pin: Pin,
35 pub reason: StaleReason,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CommitMismatch {
40 pub pin: Pin,
41 pub expected: CommitSha,
42 pub found: CommitSha,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct UnstockedPlan {
47 pub missing: Vec<Pin>,
48 pub stale: Vec<StalePin>,
49 pub commit_mismatch: Vec<CommitMismatch>,
50}
51
52impl UnstockedPlan {
53 #[must_use]
54 pub fn is_empty(&self) -> bool {
55 self.missing.is_empty() && self.stale.is_empty() && self.commit_mismatch.is_empty()
56 }
57
58 #[must_use]
60 pub fn detail(&self, pin: &Pin) -> Option<String> {
61 let stale = self
62 .stale
63 .iter()
64 .find(|stale| &stale.pin == pin)
65 .map(|stale| stale.reason.to_string());
66 stale.or_else(|| {
67 self.commit_mismatch
68 .iter()
69 .find(|mismatch| &mismatch.pin == pin)
70 .map(|mismatch| format!("expected {}, found {}", mismatch.expected, mismatch.found))
71 })
72 }
73
74 #[must_use]
75 pub fn gaps(&self) -> Vec<(Pin, SnapshotGap)> {
76 let missing = self
77 .missing
78 .iter()
79 .map(|pin| (pin.clone(), SnapshotGap::Missing));
80 let stale = self
81 .stale
82 .iter()
83 .map(|s| (s.pin.clone(), SnapshotGap::Stale));
84 let mismatched = self
85 .commit_mismatch
86 .iter()
87 .map(|m| (m.pin.clone(), SnapshotGap::CommitMismatch));
88 let mut gaps: Vec<_> = missing.chain(stale).chain(mismatched).collect();
89 gaps.sort_by(|a, b| a.0.cmp(&b.0));
90 gaps
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum StoreStatus {
96 Stocked,
97 Unstocked(UnstockedPlan),
98}
99
100enum Gap {
101 Missing,
102 Stale(StaleReason),
103 CommitMismatch {
104 expected: CommitSha,
105 found: CommitSha,
106 },
107}
108
109impl UnstockedPlan {
110 fn record(&mut self, pin: Pin, gap: Gap) {
111 match gap {
112 Gap::Missing => self.missing.push(pin),
113 Gap::Stale(reason) => self.stale.push(StalePin { pin, reason }),
114 Gap::CommitMismatch { expected, found } => self.commit_mismatch.push(CommitMismatch {
115 pin,
116 expected,
117 found,
118 }),
119 }
120 }
121}
122
123fn check_header(
124 plan: &JigsawPlan,
125 pin: &Pin,
126 header: &SnapshotHeader,
127 expected: Option<ExpectedExtraction>,
128) -> Option<Gap> {
129 let Some(expected) = expected else {
130 return Some(Gap::Missing);
131 };
132 if let Some(reason) = extraction_freshness(header, &expected) {
133 return Some(Gap::Stale(reason));
134 }
135 if &pin.project != plan.host_project() {
136 return None;
137 }
138 if header.host_facts.as_ref().map(|facts| &facts.digest) != Some(plan.host_facts()) {
139 return Some(Gap::Stale(StaleReason::HostFacts));
140 }
141 let release = plan
142 .releases()
143 .iter()
144 .find(|release| release.tag.tag() == &pin.tag)?;
145 (release.commit != header.commit).then(|| Gap::CommitMismatch {
146 expected: release.commit.clone(),
147 found: header.commit.clone(),
148 })
149}
150
151fn unreadable(error: &StoreError) -> Gap {
152 match error {
153 StoreError::SnapshotNotFound { .. } => Gap::Missing,
154 _ => Gap::Stale(StaleReason::Unreadable),
155 }
156}
157
158impl JigsawPlan {
159 pub fn store_status(
160 &self,
161 store: &SnapshotStore<ReadOnly>,
162 expected: impl Fn(&ProjectName) -> Option<ExpectedExtraction>,
163 ) -> StoreStatus {
164 let mut unstocked = UnstockedPlan::default();
165 for pin in self.required() {
166 let gap = match store.read_header(&pin.project, &pin.tag) {
167 Ok(header) => check_header(self, &pin, &header, expected(&pin.project)),
168 Err(error) => Some(unreadable(&error)),
169 };
170 if let Some(gap) = gap {
171 unstocked.record(pin, gap);
172 }
173 }
174 if unstocked.is_empty() {
175 StoreStatus::Stocked
176 } else {
177 StoreStatus::Unstocked(unstocked)
178 }
179 }
180
181 #[must_use]
182 pub fn nothing_stored(&self) -> StoreStatus {
183 StoreStatus::Unstocked(self.all_missing())
184 }
185
186 #[must_use]
188 pub fn all_missing(&self) -> UnstockedPlan {
189 UnstockedPlan {
190 missing: self.required().into_iter().collect(),
191 ..UnstockedPlan::default()
192 }
193 }
194
195 pub fn load(
196 self,
197 store: &SnapshotStore<ReadOnly>,
198 expected: impl Fn(&ProjectName) -> Option<ExpectedExtraction> + Sync,
199 ) -> Result<LoadedPlan, UnstockedPlan> {
200 let pins: Vec<Pin> = self.required().into_iter().collect();
201 let outcomes = read_all(&self, store, &expected, &pins);
202 let mut unstocked = UnstockedPlan::default();
203 let mut snapshots = BTreeMap::new();
204 for (pin, outcome) in pins.into_iter().zip(outcomes) {
205 match outcome {
206 Ok(snapshot) => {
207 snapshots.insert(pin, snapshot);
208 }
209 Err(gap) => unstocked.record(pin, gap),
210 }
211 }
212 if !unstocked.is_empty() {
213 return Err(unstocked);
214 }
215 let surfaces = Surfaces::build(&self, &snapshots)?;
216 let store_digest = store_digest(&snapshots);
217 Ok(LoadedPlan {
218 plan: self,
219 snapshots,
220 store_digest,
221 surfaces,
222 })
223 }
224}
225
226fn read_one(
227 plan: &JigsawPlan,
228 store: &SnapshotStore<ReadOnly>,
229 expected: Option<ExpectedExtraction>,
230 pin: &Pin,
231) -> Result<Snapshot<state::Canonical>, Gap> {
232 let text = store
233 .read_text(&pin.project, &pin.tag)
234 .map_err(|e| unreadable(&e))?;
235 let header = parser::parse_header(&text).map_err(|_| Gap::Stale(StaleReason::Unreadable))?;
236 if let Some(gap) = check_header(plan, pin, &header, expected) {
237 return Err(gap);
238 }
239 parser::parse(&text).map_err(|_| Gap::Stale(StaleReason::Unreadable))
240}
241
242fn read_all(
244 plan: &JigsawPlan,
245 store: &SnapshotStore<ReadOnly>,
246 expected: &(impl Fn(&ProjectName) -> Option<ExpectedExtraction> + Sync),
247 pins: &[Pin],
248) -> Vec<Result<Snapshot<state::Canonical>, Gap>> {
249 let workers = thread::available_parallelism()
250 .map_or(1, usize::from)
251 .min(pins.len().max(1));
252 let next = AtomicUsize::new(0);
253 let mut outcomes = Vec::with_capacity(pins.len());
254 thread::scope(|scope| {
255 let handles: Vec<_> = (0..workers)
256 .map(|_| {
257 scope.spawn(|| {
258 let mut done = Vec::new();
259 loop {
260 let index = next.fetch_add(1, Ordering::Relaxed);
261 let Some(pin) = pins.get(index) else {
262 break done;
263 };
264 done.push((index, read_one(plan, store, expected(&pin.project), pin)));
265 }
266 })
267 })
268 .collect();
269 for handle in handles {
270 match handle.join() {
271 Ok(done) => outcomes.extend(done),
272 Err(payload) => panic::resume_unwind(payload),
273 }
274 }
275 });
276 outcomes.sort_by_key(|(index, _)| *index);
277 outcomes.into_iter().map(|(_, outcome)| outcome).collect()
278}
279
280fn store_digest(snapshots: &BTreeMap<Pin, Snapshot<state::Canonical>>) -> StoreDigest {
281 let mut writer = DigestWriter::new("jigsaw store");
282 writer.count(snapshots.len());
283 for (pin, snapshot) in snapshots {
284 writer
285 .text(pin.project.as_str())
286 .text(pin.tag.as_str())
287 .text(snapshot.header().commit.as_str())
288 .text(&snapshot.header().extractor_version);
289 }
290 StoreDigest::from_digest(writer.finish())
291}
292
293#[derive(Debug)]
294struct Surfaces {
295 build_app_names: BTreeSet<ApplicationName>,
296 pin_apps: BTreeMap<Pin, PinApps>,
297}
298
299impl Surfaces {
300 fn build(
301 plan: &JigsawPlan,
302 snapshots: &BTreeMap<Pin, Snapshot<state::Canonical>>,
303 ) -> Result<Self, UnstockedPlan> {
304 let mut unstocked = UnstockedPlan::default();
305 let mut pin_apps = BTreeMap::new();
306 for (pin, snapshot) in snapshots {
307 match PinApps::of(snapshot) {
308 Some(apps) => {
309 pin_apps.insert(pin.clone(), apps);
310 }
311 None => unstocked.record(pin.clone(), Gap::Stale(StaleReason::MissingApp)),
312 }
313 }
314 if !unstocked.is_empty() {
315 return Err(unstocked);
316 }
317 let build = plan.releases().build();
318 let build_pins = iter::once(Pin::new(
319 plan.host_project().clone(),
320 build.tag.tag().clone(),
321 ))
322 .chain(
323 build
324 .pins
325 .iter()
326 .map(|(project, tag)| Pin::new(project.clone(), tag.clone())),
327 )
328 .chain(plan.otp_lines().iter().flat_map(|line| {
329 line.tags()
330 .iter()
331 .map(|tag| Pin::new(plan.otp_project().clone(), tag.tag().clone()))
332 }));
333 let build_app_names = build_pins
334 .filter_map(|pin| snapshots.get(&pin))
335 .flat_map(|snapshot| snapshot.apps().iter().map(|app| app.name.clone()))
336 .collect();
337 Ok(Self {
338 build_app_names,
339 pin_apps,
340 })
341 }
342}
343
344#[derive(Debug)]
345pub struct LoadedPlan {
346 plan: JigsawPlan,
347 snapshots: BTreeMap<Pin, Snapshot<state::Canonical>>,
348 store_digest: StoreDigest,
349 surfaces: Surfaces,
350}
351
352impl LoadedPlan {
353 #[must_use]
354 pub fn plan(&self) -> &JigsawPlan {
355 &self.plan
356 }
357
358 #[must_use]
359 pub fn snapshot(&self, pin: &Pin) -> Option<&Snapshot<state::Canonical>> {
360 self.snapshots.get(pin)
361 }
362
363 #[must_use]
364 pub fn snapshots(&self) -> &BTreeMap<Pin, Snapshot<state::Canonical>> {
365 &self.snapshots
366 }
367
368 #[must_use]
372 pub fn surfaces(&self) -> PlanSurfaces<'_> {
373 PlanSurfaces::new(&self.plan, &self.snapshots, &self.surfaces.pin_apps)
374 }
375
376 #[must_use]
377 pub fn store_digest(&self) -> &StoreDigest {
378 &self.store_digest
379 }
380
381 #[must_use]
385 pub fn build_app_names(&self) -> &BTreeSet<ApplicationName> {
386 &self.surfaces.build_app_names
387 }
388}