sp_variant/
lib.rs

1/*
2 * SPDX-FileCopyrightText: 2021 - 2024  StorPool <support@storpool.com>
3 * SPDX-License-Identifier: BSD-2-Clause
4 */
5//! Detect the OS distribution and version.
6
7#![warn(missing_docs)]
8// We do not want to expose the whole of the autogenerated data module.
9#![allow(clippy::pub_use)]
10
11use std::clone::Clone;
12use std::collections::HashMap;
13use std::fs;
14use std::io::{Error as IoError, ErrorKind};
15
16use regex::RegexBuilder;
17use serde_derive::{Deserialize, Serialize};
18use thiserror::Error;
19
20use yai::YAIError;
21
22mod data;
23
24pub mod yai;
25
26#[cfg(test)]
27pub mod tests;
28
29pub use data::VariantKind;
30
31/// An error that occurred while determining the Linux variant.
32#[derive(Debug, Error)]
33#[non_exhaustive]
34pub enum VariantError {
35    /// An invalid variant name was specified.
36    #[error("Unknown variant '{0}'")]
37    BadVariant(String),
38
39    /// A file to be examined could not be read.
40    #[error("Checking for {0}: could not read {1}")]
41    FileRead(String, String, #[source] IoError),
42
43    /// Unexpected error parsing the /etc/os-release file.
44    #[error("Could not parse the /etc/os-release file")]
45    OsRelease(#[source] YAIError),
46
47    /// None of the variants matched.
48    #[error("Could not detect the current host's build variant")]
49    UnknownVariant,
50
51    /// Something went really, really wrong.
52    #[error("Internal sp-variant error: {0}")]
53    Internal(String),
54}
55
56/// The version of the variant definition format data.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct VariantFormatVersion {
60    /// The version major number.
61    pub major: u32,
62    /// The version minor number.
63    pub minor: u32,
64}
65
66/// The internal format of the variant definition format data.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct VariantFormat {
70    /// The version of the metadata format.
71    pub version: VariantFormatVersion,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75struct VariantFormatTop {
76    format: VariantFormat,
77}
78
79/// Check whether this host is running this particular OS variant.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct Detect {
83    /// The name of the file to read.
84    pub filename: String,
85    /// The regular expression pattern to look for in the file.
86    pub regex: String,
87    /// The "ID" field in the /etc/os-release file.
88    pub os_id: String,
89    /// The regular expression pattern for the "VERSION_ID" os-release field.
90    pub os_version_regex: String,
91}
92
93/// The aspects of the StorPool operation supported for this build variant.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct Supported {
97    /// Is there a StorPool third-party packages repository?
98    pub repo: bool,
99}
100
101/// Debian package repository data.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct DebRepo {
105    /// The distribution codename (e.g. "buster").
106    pub codename: String,
107    /// The distribution vendor ("debian", "ubuntu", etc.).
108    pub vendor: String,
109    /// The APT sources list file to copy to /etc/apt/sources.list.d/.
110    pub sources: String,
111    /// The GnuPG keyring file to copy to /usr/share/keyrings/.
112    pub keyring: String,
113    /// OS packages that need to be installed before `apt-get update` is run.
114    pub req_packages: Vec<String>,
115}
116
117/// Yum/DNF package repository data.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[non_exhaustive]
120pub struct YumRepo {
121    /// The *.repo file to copy to /etc/yum.repos.d/.
122    pub yumdef: String,
123    /// The keyring file to copy to /etc/pki/rpm-gpg/.
124    pub keyring: String,
125}
126
127/// OS package repository data.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(untagged)]
130#[non_exhaustive]
131pub enum Repo {
132    /// Debian/Ubuntu repository data.
133    Deb(DebRepo),
134    /// CentOS/Oracle repository data.
135    Yum(YumRepo),
136}
137
138/// StorPool builder data.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct Builder {
142    /// The builder name.
143    pub alias: String,
144    /// The base Docker image that the builder is generated from.
145    pub base_image: String,
146    /// The branch used by the sp-pkg tool to specify the variant.
147    pub branch: String,
148    /// The base kernel OS package.
149    pub kernel_package: String,
150    /// The name of the locale to use for clean UTF-8 output.
151    pub utf8_locale: String,
152}
153
154/// A single StorPool build variant with all its options.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[non_exhaustive]
157pub struct Variant {
158    /// Which variant is that?
159    #[serde(rename = "name")]
160    pub kind: VariantKind,
161    /// The human-readable description of the variant.
162    pub descr: String,
163    /// The OS "family" that this distribution belongs to.
164    pub family: String,
165    /// The name of the variant that this one is based on.
166    pub parent: String,
167    /// The ways to check whether we are running this variant.
168    pub detect: Detect,
169    /// The aspects of StorPool operation supported for this build variant.
170    pub supported: Supported,
171    /// The OS commands to execute for particular purposes.
172    pub commands: HashMap<String, HashMap<String, Vec<String>>>,
173    /// The minimum Python version that we can depend on.
174    pub min_sys_python: String,
175    /// The StorPool repository files to install.
176    pub repo: Repo,
177    /// The names of the packages to be used for this variant.
178    pub package: HashMap<String, String>,
179    /// The name of the directory to install systemd unit files to.
180    pub systemd_lib: String,
181    /// The filename extension of the OS packages ("deb", "rpm", etc.).
182    pub file_ext: String,
183    /// The type of initramfs-generating tools.
184    pub initramfs_flavor: String,
185    /// The data specific to the StorPool builder containers.
186    pub builder: Builder,
187}
188
189/// The internal variant format data: all build variants, some more info.
190#[derive(Debug, Serialize, Deserialize)]
191pub struct VariantDefTop {
192    format: VariantFormat,
193    order: Vec<VariantKind>,
194    variants: HashMap<VariantKind, Variant>,
195    version: String,
196}
197
198/// Get the list of StorPool variants from the internal `data` module.
199#[inline]
200#[must_use]
201pub fn build_variants() -> &'static VariantDefTop {
202    data::get_variants()
203}
204
205/// Detect the variant that this host is currently running.
206///
207/// # Errors
208/// Propagates any errors from [`detect_from()`].
209#[inline]
210pub fn detect() -> Result<Variant, VariantError> {
211    detect_from(build_variants()).cloned()
212}
213
214/// Detect the current host's variant from the supplied data.
215///
216/// # Errors
217/// May return a [`VariantError`], either "unknown variant" or a wrapper around
218/// an underlying error condition:
219/// - any `os-release` parse errors from [`crate::yai::parse()`] other than "file not found"
220/// - I/O errors from reading the distribution-specific version files (e.g. `/etc/redhat-release`)
221#[allow(clippy::missing_inline_in_public_items)]
222pub fn detect_from(variants: &VariantDefTop) -> Result<&Variant, VariantError> {
223    match yai::parse("/etc/os-release") {
224        Ok(data) => {
225            if let Some(os_id) = data.get("ID") {
226                if let Some(version_id) = data.get("VERSION_ID") {
227                    for kind in &variants.order {
228                        let var = &variants.variants.get(kind).ok_or_else(|| {
229                            VariantError::Internal(format!(
230                                "Internal error: unknown variant {kind} in the order",
231                                kind = kind.as_ref()
232                            ))
233                        })?;
234                        if var.detect.os_id != *os_id {
235                            continue;
236                        }
237                        let re_ver = RegexBuilder::new(&var.detect.os_version_regex)
238                            .ignore_whitespace(true)
239                            .build()
240                            .map_err(|err| {
241                                VariantError::Internal(format!(
242                                    "Internal error: {kind}: could not parse '{regex}': {err}",
243                                    kind = kind.as_ref(),
244                                    regex = var.detect.regex
245                                ))
246                            })?;
247                        if re_ver.is_match(version_id) {
248                            return Ok(var);
249                        }
250                    }
251                }
252            }
253            // Fall through to the PRETTY_NAME processing.
254        }
255        Err(YAIError::FileRead(io_err)) if io_err.kind() == ErrorKind::NotFound => (),
256        Err(err) => return Err(VariantError::OsRelease(err)),
257    }
258
259    for kind in &variants.order {
260        let var = &variants.variants.get(kind).ok_or_else(|| {
261            VariantError::Internal(format!(
262                "Internal error: unknown variant {kind} in the order",
263                kind = kind.as_ref()
264            ))
265        })?;
266        let re_line = RegexBuilder::new(&var.detect.regex)
267            .ignore_whitespace(true)
268            .build()
269            .map_err(|err| {
270                VariantError::Internal(format!(
271                    "Internal error: {kind}: could not parse '{regex}': {err}",
272                    kind = kind.as_ref(),
273                    regex = var.detect.regex
274                ))
275            })?;
276        match fs::read(&var.detect.filename) {
277            Ok(file_bytes) => {
278                if let Ok(contents) = String::from_utf8(file_bytes) {
279                    {
280                        if contents.lines().any(|line| re_line.is_match(line)) {
281                            return Ok(var);
282                        }
283                    }
284                }
285            }
286            Err(err) => {
287                if err.kind() != ErrorKind::NotFound {
288                    return Err(VariantError::FileRead(
289                        var.kind.as_ref().to_owned(),
290                        var.detect.filename.clone(),
291                        err,
292                    ));
293                }
294            }
295        };
296    }
297    Err(VariantError::UnknownVariant)
298}
299
300/// Get the variant with the specified name from the supplied data.
301///
302/// # Errors
303/// - [`VariantKind`] name parse errors, e.g. invalid name
304/// - an internal error if there is no data about a recognized variant name
305#[inline]
306pub fn get_from<'defs>(
307    variants: &'defs VariantDefTop,
308    name: &str,
309) -> Result<&'defs Variant, VariantError> {
310    let kind: VariantKind = name.parse()?;
311    variants
312        .variants
313        .get(&kind)
314        .ok_or_else(|| VariantError::Internal(format!("No data for the {name} variant")))
315}
316
317/// Get the variant with the specified builder alias from the supplied data.
318///
319/// # Errors
320/// May fail if the argument does not specify a recognized variant builder alias.
321#[inline]
322pub fn get_by_alias_from<'defs>(
323    variants: &'defs VariantDefTop,
324    alias: &str,
325) -> Result<&'defs Variant, VariantError> {
326    variants
327        .variants
328        .values()
329        .find(|var| var.builder.alias == alias)
330        .ok_or_else(|| VariantError::Internal(format!("No variant with the {alias} alias")))
331}
332
333/// Get information about all variants.
334#[inline]
335#[must_use]
336pub fn get_all_variants() -> &'static HashMap<VariantKind, Variant> {
337    get_all_variants_from(build_variants())
338}
339
340/// Get information about all variants defined in the specified structure.
341#[inline]
342#[must_use]
343pub const fn get_all_variants_from(variants: &VariantDefTop) -> &HashMap<VariantKind, Variant> {
344    &variants.variants
345}
346
347/// Get information about all variants in the order of inheritance between them.
348#[inline]
349pub fn get_all_variants_in_order() -> impl Iterator<Item = &'static Variant> {
350    get_all_variants_in_order_from(build_variants())
351}
352
353/// Get information about all variants defined in the specified structure in order.
354///
355/// # Panics
356/// May panic if the variants data is inconsistent and the variants order array
357/// includes a [`VariantKind`] that is not present in the actual hashmap.
358/// This should hopefully never ever happen, and there is a unit test for that.
359#[inline]
360#[allow(clippy::indexing_slicing)]
361pub fn get_all_variants_in_order_from(variants: &VariantDefTop) -> impl Iterator<Item = &Variant> {
362    variants.order.iter().map(|kind| &variants.variants[kind])
363}
364
365/// Get the metadata format version of the variant data.
366#[inline]
367#[must_use]
368pub fn get_format_version() -> (u32, u32) {
369    get_format_version_from(build_variants())
370}
371
372/// Get the metadata format version of the supplied variant data structure.
373#[inline]
374#[must_use]
375pub const fn get_format_version_from(variants: &VariantDefTop) -> (u32, u32) {
376    (variants.format.version.major, variants.format.version.minor)
377}
378
379/// Get the program version from the variant data.
380#[inline]
381#[must_use]
382pub fn get_program_version() -> &'static str {
383    get_program_version_from(build_variants())
384}
385
386/// Get the program version from the supplied variant data structure.
387#[inline]
388#[must_use]
389pub fn get_program_version_from(variants: &VariantDefTop) -> &str {
390    &variants.version
391}