libcoreinst/
source.rs

1// Copyright 2019 CoreOS, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use anyhow::{anyhow, bail, Context, Result};
16use reqwest::{blocking, StatusCode, Url};
17use serde::Deserialize;
18use std::collections::HashMap;
19use std::fmt::{Display, Formatter};
20use std::fs::OpenOptions;
21use std::io::{Read, Seek, SeekFrom};
22use std::path::{Path, PathBuf};
23use std::thread::sleep;
24use std::time::Duration;
25
26use crate::cmdline::*;
27use crate::osmet::*;
28use crate::util::set_die_on_sigpipe;
29
30/// Completion timeout for HTTP requests (4 hours).
31const HTTP_COMPLETION_TIMEOUT: Duration = Duration::from_secs(4 * 60 * 60);
32
33/// Default base URL to Fedora CoreOS streams metadata.
34const DEFAULT_STREAM_BASE_URL: &str = "https://builds.coreos.fedoraproject.org/streams/";
35
36/// Directory in which we look for osmet files.
37const OSMET_FILES_DIR: &str = "/run/coreos-installer/osmet";
38
39pub trait ImageLocation: Display {
40    // Obtain image lengths and signatures and start fetching the images
41    fn sources(&self) -> Result<Vec<ImageSource>>;
42
43    // Whether GPG signature verification is required by default
44    fn require_signature(&self) -> bool {
45        true
46    }
47}
48
49// Local image source
50#[derive(Debug)]
51pub struct FileLocation {
52    image_path: String,
53    sig_path: String,
54}
55
56// Local osmet image source
57pub struct OsmetLocation {
58    osmet_path: PathBuf,
59    architecture: String,
60    sector_size: u32,
61    description: String,
62}
63
64// Remote image source
65#[derive(Debug)]
66pub struct UrlLocation {
67    image_url: Url,
68    sig_url: Url,
69    artifact_type: String,
70    retries: FetchRetries,
71}
72
73// Remote image source specified by Fedora CoreOS stream metadata
74#[derive(Debug)]
75pub struct StreamLocation {
76    stream_base_url: Option<Url>,
77    stream: String,
78    stream_url: Url,
79    architecture: String,
80    platform: String,
81    format: String,
82    retries: FetchRetries,
83}
84
85pub struct ImageSource {
86    pub reader: Box<dyn Read>,
87    pub length_hint: Option<u64>,
88    pub signature: Option<Vec<u8>>,
89    pub filename: String,
90    pub artifact_type: String,
91}
92
93impl FileLocation {
94    pub fn new(path: &str) -> Self {
95        Self {
96            image_path: path.to_string(),
97            sig_path: format!("{path}.sig"),
98        }
99    }
100}
101
102impl Display for FileLocation {
103    fn fmt(&self, f: &mut Formatter<'_>) -> ::std::fmt::Result {
104        write!(
105            f,
106            "Copying image from {}\nReading signature from {}",
107            self.image_path, self.sig_path
108        )
109    }
110}
111
112impl ImageLocation for FileLocation {
113    fn sources(&self) -> Result<Vec<ImageSource>> {
114        // open local file for reading
115        let mut out = OpenOptions::new()
116            .read(true)
117            .open(&self.image_path)
118            .context("opening source image file")?;
119
120        // get size
121        let length = out
122            .seek(SeekFrom::End(0))
123            .context("seeking source image file")?;
124        out.rewind().context("seeking source image file")?;
125
126        // load signature file if present
127        let signature = match OpenOptions::new().read(true).open(&self.sig_path) {
128            Ok(mut file) => {
129                let mut sig_vec = Vec::new();
130                file.read_to_end(&mut sig_vec)
131                    .context("reading signature file")?;
132                Some(sig_vec)
133            }
134            Err(err) => {
135                eprintln!("Couldn't read signature file: {err}");
136                None
137            }
138        };
139        let filename = Path::new(&self.image_path)
140            .file_name()
141            .context("extracting filename")?
142            .to_string_lossy()
143            .to_string();
144
145        Ok(vec![ImageSource {
146            reader: Box::new(out),
147            length_hint: Some(length),
148            signature,
149            filename,
150            artifact_type: "disk".to_string(),
151        }])
152    }
153}
154
155impl UrlLocation {
156    pub fn new(url: &Url, retries: FetchRetries) -> Self {
157        let mut sig_url = url.clone();
158        sig_url.set_path(&format!("{}.sig", sig_url.path()));
159        Self::new_full(url, &sig_url, "disk", retries)
160    }
161
162    fn new_full(url: &Url, sig_url: &Url, artifact_type: &str, retries: FetchRetries) -> Self {
163        Self {
164            image_url: url.clone(),
165            sig_url: sig_url.clone(),
166            artifact_type: artifact_type.to_string(),
167            retries,
168        }
169    }
170
171    /// Fetch signature content from URL.
172    fn fetch_signature(&self) -> Result<Vec<u8>> {
173        let client = new_http_client()?;
174        let mut resp =
175            http_get(client, &self.sig_url, self.retries).context("fetching signature URL")?;
176
177        let mut sig_bytes = Vec::new();
178        resp.read_to_end(&mut sig_bytes)
179            .context("reading signature content")?;
180        Ok(sig_bytes)
181    }
182}
183
184impl Display for UrlLocation {
185    fn fmt(&self, f: &mut Formatter<'_>) -> ::std::fmt::Result {
186        write!(
187            f,
188            "Downloading image from {}\nDownloading signature from {}",
189            self.image_url, self.sig_url
190        )
191    }
192}
193
194impl ImageLocation for UrlLocation {
195    fn sources(&self) -> Result<Vec<ImageSource>> {
196        let signature = self
197            .fetch_signature()
198            .map_err(|e| eprintln!("Failed to fetch signature: {e}"))
199            .ok();
200
201        // start fetch, get length
202        let client = new_http_client()?;
203        let resp = http_get(client, &self.image_url, self.retries).context("fetching image URL")?;
204        match resp.status() {
205            StatusCode::OK => (),
206            s => bail!("image fetch failed: {}", s),
207        };
208        let length_hint = resp.content_length();
209        // ignores the Content-Disposition filename
210        let filename = resp
211            .url()
212            .path_segments()
213            .context("splitting image URL")?
214            .next_back()
215            .context("walking image URL")?
216            .to_string();
217
218        Ok(vec![ImageSource {
219            reader: Box::new(resp),
220            length_hint,
221            signature,
222            filename,
223            artifact_type: self.artifact_type.clone(),
224        }])
225    }
226}
227
228impl StreamLocation {
229    pub fn new(
230        stream: &str,
231        architecture: &str,
232        platform: &str,
233        format: &str,
234        base_url: Option<&Url>,
235        retries: FetchRetries,
236    ) -> Result<Self> {
237        Ok(Self {
238            stream_base_url: base_url.cloned(),
239            stream: stream.to_string(),
240            stream_url: build_stream_url(stream, base_url)?,
241            architecture: architecture.to_string(),
242            platform: platform.to_string(),
243            format: format.to_string(),
244            retries,
245        })
246    }
247}
248
249impl Display for StreamLocation {
250    fn fmt(&self, f: &mut Formatter<'_>) -> ::std::fmt::Result {
251        if self.stream_base_url.is_some() {
252            write!(
253                f,
254                "Downloading {} {} image ({}) and signature referenced from {}",
255                self.architecture, self.platform, self.format, self.stream_url
256            )
257        } else {
258            write!(
259                f,
260                "Downloading Fedora CoreOS {} {} {} image ({}) and signature",
261                self.stream, self.architecture, self.platform, self.format
262            )
263        }
264    }
265}
266
267impl ImageLocation for StreamLocation {
268    fn sources(&self) -> Result<Vec<ImageSource>> {
269        // fetch and parse stream metadata
270        let client = new_http_client()?;
271        let stream = fetch_stream(client, &self.stream_url, self.retries)?;
272
273        // descend it
274        let artifacts = stream
275            .architectures
276            .get(&self.architecture)
277            .map(|arch| arch.artifacts.get(&self.platform))
278            .unwrap_or(None)
279            .map(|platform| platform.formats.get(&self.format))
280            .unwrap_or(None)
281            .with_context(|| {
282                format!(
283                    "couldn't find architecture {}, platform {}, format {} in stream metadata",
284                    self.architecture, self.platform, self.format
285                )
286            })?;
287
288        // build sources, letting UrlLocation handle the details
289        let mut sources: Vec<ImageSource> = Vec::new();
290        for (artifact_type, artifact) in artifacts.iter() {
291            let artifact_url = Url::parse(&artifact.location)
292                .context("parsing artifact URL from stream metadata")?;
293            let signature_url = Url::parse(&artifact.signature)
294                .context("parsing signature URL from stream metadata")?;
295            let mut artifact_sources =
296                UrlLocation::new_full(&artifact_url, &signature_url, artifact_type, self.retries)
297                    .sources()?;
298            sources.append(&mut artifact_sources);
299        }
300        sources.sort_by_key(|k| k.artifact_type.to_string());
301        Ok(sources)
302    }
303}
304
305impl OsmetLocation {
306    pub fn new(architecture: &str, sector_size: u32) -> Result<Option<Self>> {
307        let osmet_dir = Path::new(OSMET_FILES_DIR);
308        if !osmet_dir.exists() {
309            return Ok(None);
310        }
311
312        if let Some((osmet_path, description)) =
313            find_matching_osmet_in_dir(osmet_dir, architecture, sector_size)?
314        {
315            Ok(Some(Self {
316                osmet_path,
317                architecture: architecture.into(),
318                sector_size,
319                description,
320            }))
321        } else {
322            Ok(None)
323        }
324    }
325}
326
327impl Display for OsmetLocation {
328    fn fmt(&self, f: &mut Formatter<'_>) -> ::std::fmt::Result {
329        write!(
330            f,
331            "Installing {} {} ({}-byte sectors)",
332            self.description, self.architecture, self.sector_size
333        )
334    }
335}
336
337impl ImageLocation for OsmetLocation {
338    fn sources(&self) -> Result<Vec<ImageSource>> {
339        let unpacker = OsmetUnpacker::new_from_sysroot(Path::new(&self.osmet_path))?;
340
341        let filename = {
342            let stem = self.osmet_path.file_stem().with_context(|| {
343                // This really should never happen since for us to get here, we must've found a
344                // valid osmet file... But let's still just error out instead of assert in case
345                // somehow this doesn't hold true in the future and a user hits this.
346                format!(
347                    "can't create new .raw filename from osmet path {:?}",
348                    &self.osmet_path
349                )
350            })?;
351            // really we don't need to care about UTF-8 here, but ImageSource right now does
352            let mut filename: String = stem
353                .to_str()
354                .with_context(|| format!("non-UTF-8 osmet file stem: {stem:?}"))?
355                .into();
356            filename.push_str(".raw");
357            filename
358        };
359        let length = unpacker.length();
360        Ok(vec![ImageSource {
361            reader: Box::new(unpacker),
362            length_hint: Some(length),
363            signature: None,
364            filename,
365            artifact_type: "disk".to_string(),
366        }])
367    }
368
369    // For osmet, we don't require GPG verification since we trust osmet files placed in the
370    // OSMET_FILES_DIR.
371    fn require_signature(&self) -> bool {
372        false
373    }
374}
375
376/// Subcommand to list objects available in stream metadata.
377pub fn list_stream(config: ListStreamConfig) -> Result<()> {
378    #[derive(PartialEq, Eq, PartialOrd, Ord)]
379    struct Row<'a> {
380        architecture: &'a str,
381        platform: &'a str,
382        format: &'a str,
383    }
384
385    // fetch stream metadata
386    let client = new_http_client()?;
387    let stream_url = build_stream_url(&config.stream, config.stream_base_url.as_ref())?;
388    let stream = fetch_stream(client, &stream_url, FetchRetries::None)?;
389
390    // walk formats
391    let mut rows: Vec<Row> = Vec::new();
392    for (architecture_name, architecture) in stream.architectures.iter() {
393        for (platform_name, platform) in architecture.artifacts.iter() {
394            for format_name in platform.formats.keys() {
395                rows.push(Row {
396                    architecture: architecture_name,
397                    platform: platform_name,
398                    format: format_name,
399                });
400            }
401        }
402    }
403    rows.sort();
404
405    // add header row
406    rows.insert(
407        0,
408        Row {
409            architecture: "Architecture",
410            platform: "Platform",
411            format: "Format",
412        },
413    );
414
415    // calculate field widths
416    let mut widths: [usize; 2] = [0; 2];
417    for row in &rows {
418        widths[0] = widths[0].max(row.architecture.len());
419        widths[1] = widths[1].max(row.platform.len());
420    }
421
422    // report results
423    set_die_on_sigpipe()?;
424    for row in &rows {
425        println!(
426            "{:3$}  {:4$}  {}",
427            row.architecture, row.platform, row.format, widths[0], widths[1]
428        );
429    }
430    Ok(())
431}
432
433/// Generate a stream URL from a stream name and base URL, or the default
434/// base URL if none is specified.
435fn build_stream_url(stream: &str, base_url: Option<&Url>) -> Result<Url> {
436    base_url
437        .unwrap_or(&Url::parse(DEFAULT_STREAM_BASE_URL).unwrap())
438        .join(&format!("{stream}.json"))
439        .context("building stream URL")
440}
441
442/// Fetch and parse stream metadata.
443fn fetch_stream(client: blocking::Client, url: &Url, retries: FetchRetries) -> Result<Stream> {
444    // fetch stream metadata
445    let resp = http_get(client, url, retries).context("fetching stream metadata")?;
446    match resp.status() {
447        StatusCode::OK => (),
448        s => bail!("stream metadata fetch from {} failed: {}", url, s),
449    };
450
451    // parse it
452    let stream: Stream = serde_json::from_reader(resp).context("decoding stream metadata")?;
453    Ok(stream)
454}
455
456/// Customize and build a new HTTP client.
457pub fn new_http_client() -> Result<blocking::Client> {
458    blocking::ClientBuilder::new()
459        .timeout(HTTP_COMPLETION_TIMEOUT)
460        .build()
461        .context("building HTTP client")
462}
463
464/// Wrapper around Client::get() with error handling based on HTTP return code and optionally basic
465/// exponential backoff retries for transient errors.
466pub fn http_get(
467    client: blocking::Client,
468    url: &Url,
469    retries: FetchRetries,
470) -> Result<blocking::Response> {
471    // this matches `curl --retry` semantics -- see list in `curl(1)`
472    const RETRY_STATUS_CODES: [u16; 6] = [408, 429, 500, 502, 503, 504];
473
474    let mut delay = 1;
475    let (infinite, mut tries) = match retries {
476        FetchRetries::Infinite => (true, 0),
477        FetchRetries::Finite(n) => (false, n.get() + 1),
478        FetchRetries::None => (false, 1),
479    };
480
481    loop {
482        let err: anyhow::Error = match client.get(url.clone()).send() {
483            Err(err) => err.into(),
484            Ok(resp) => match resp.status().as_u16() {
485                code if RETRY_STATUS_CODES.contains(&code) => anyhow!(
486                    "HTTP {} {}",
487                    code,
488                    resp.status().canonical_reason().unwrap_or("")
489                ),
490                _ => {
491                    return resp
492                        .error_for_status()
493                        .with_context(|| format!("fetching '{url}'"));
494                }
495            },
496        };
497
498        if !infinite {
499            tries -= 1;
500            if tries == 0 {
501                return Err(err).with_context(|| format!("fetching '{url}'"));
502            }
503        }
504
505        eprintln!("Error fetching '{url}': {err}");
506        eprintln!("Sleeping {delay}s and retrying...");
507        sleep(Duration::from_secs(delay));
508        delay = std::cmp::min(delay * 2, 10 * 60); // cap to 10 mins; matches curl
509    }
510}
511
512#[derive(Debug, Deserialize)]
513struct Stream {
514    architectures: HashMap<String, Arch>,
515}
516
517#[derive(Debug, Deserialize)]
518struct Arch {
519    artifacts: HashMap<String, Platform>,
520}
521
522#[derive(Debug, Deserialize)]
523struct Platform {
524    formats: HashMap<String, HashMap<String, Artifact>>,
525}
526
527#[derive(Debug, Deserialize)]
528struct Artifact {
529    location: String,
530    signature: String,
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn test_new_http_client() {
539        let _ = new_http_client().unwrap();
540    }
541}