diff --git a/.travis.yml b/.travis.yml index 2c1a146..34ce3a7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,12 @@ rust: - stable - beta - nightly +services: + - docker +before_install: + - docker build -t pinger-test . matrix: allow_failures: - rust: nightly script: - - cargo test + - docker run --rm pinger-test diff --git a/Cargo.toml b/Cargo.toml index 89ebd96..bf77537 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,15 @@ env_logger = "^0.6.1" failure = "^0.1.5" liboverdrop = "^0.0.2" log = "^0.4.6" +maplit = "^1.0" serde = { version = "^1.0.91", features = ["derive"] } +serde_json = "1.0.40" +slog-scope = "~4.1" +tempfile = "3.1.0" toml = "^0.5.1" +reqwest = "0.9.22" +bincode = "1.2.0" +nix = "0.15.0" [package.metadata.release] sign-commit = true @@ -32,3 +39,10 @@ pre-release-commit-message = "cargo: Fedora CoreOS Pinger release {{version}}" pro-release-commit-message = "cargo: development version bump" tag-message = "Fedora CoreOS Pinger v{{version}}" tag-prefix = "v" + +[dependencies.slog] +version = "2.5" +features = ["max_level_trace", "release_max_level_info"] + +[dev-dependencies] +mockito = "^0.17.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..356d4bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM registry.access.redhat.com/ubi8/ubi +WORKDIR /fedora-coreos-pinger + +COPY . . +RUN yum install -y gcc openssl-devel +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y -q +RUN echo "export PATH=\"$HOME/.cargo/bin:$PATH\"" > ~/.bashrc \ + && source ~/.bashrc \ + && cargo build +CMD [ "/root/.cargo/bin/cargo", "test" ] diff --git a/dist/systemd/fedora-coreos-pinger.service b/dist/systemd/fedora-coreos-pinger.service index b6eb0b8..0f85bce 100644 --- a/dist/systemd/fedora-coreos-pinger.service +++ b/dist/systemd/fedora-coreos-pinger.service @@ -9,6 +9,7 @@ After=network-online.target DynamicUser=yes Type=oneshot RemainAfterExit=yes +LogsDirectory=fedora-coreos-pinger ExecStart=/usr/libexec/fedora-coreos-pinger [Install] diff --git a/src/agent/full/container_runtime.rs b/src/agent/full/container_runtime.rs new file mode 100644 index 0000000..1a628a4 --- /dev/null +++ b/src/agent/full/container_runtime.rs @@ -0,0 +1,156 @@ +//! Collect container runtime information +//! +//! Currently four container runtimes are considered: docker, podman, systemd-nspawn, crio +//! Following commands are called to check whether the runtime is running and +//! the number of containers run by the specific runtime. +//! +//! Podman: +//! - pgrep podman +//! - pgrep conmon +//! Docker: +//! - pgrep dockerd +//! - pgrep containerd-shim +//! Systemd-nspawn: +//! - pgrep systemd-nspawn +//! Crio: +//! - pgrep crio +//! - pgrep crictl +//! +//! Note: none of the commands require root access + +use failure::{self, bail, Fallible}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::process; + +/// wrapper struct for single container runtime +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct ContainerRTInfo { + is_running: bool, + num_containers: i32, +} + +/// struct for storing all container runtime info +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct ContainerRT { + /// is_running: pgrep podman + /// num_containers: pgrep conmon | wc -l + podman: ContainerRTInfo, + /// is_running: pgrep dockerd + /// num_containers: pgrep containerd-shim | wc -l + docker: ContainerRTInfo, + /// is_running and num_containers: pgrep systemd-nspawn | wc -l + systemd_nspawn: ContainerRTInfo, + /// is_running: pgrep crio + /// num_containers: pgrep crictl | wc -l + crio: ContainerRTInfo, +} + +/// function to run `${command} ${args}` +fn run_command(command: &str, args: &Vec<&str>) -> Result { + process::Command::new(command).args(args).output() +} + +/// spawn a child process and pass the output of previous command through pipe +fn spawn_child( + input: &str, + command: &str, + args: Vec<&str>, +) -> Result { + let mut child = process::Command::new(command) + .args(args) + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::piped()) + .spawn()?; + child + .stdin + .as_mut() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let output = child.wait_with_output()?; + Ok(output) +} + +impl ContainerRT { + pub(crate) fn new() -> ContainerRT { + ContainerRT { + podman: ContainerRTInfo { + is_running: Self::rt_is_running("podman").unwrap_or(false), + num_containers: Self::rt_count_running("podman").unwrap_or(0), + }, + docker: ContainerRTInfo { + is_running: Self::rt_is_running("docker").unwrap_or(false), + num_containers: Self::rt_count_running("docker").unwrap_or(0), + }, + systemd_nspawn: ContainerRTInfo { + is_running: Self::rt_is_running("systemd_nspawn").unwrap_or(false), + num_containers: Self::rt_count_running("systemd_nspawn").unwrap_or(0), + }, + crio: ContainerRTInfo { + is_running: Self::rt_is_running("crio").unwrap_or(false), + num_containers: Self::rt_count_running("crio").unwrap_or(0), + }, + } + } + + /// checks if the runtime is running + fn rt_is_running(container_rt: &str) -> Fallible { + let command = "pgrep"; + match container_rt { + "podman" => { + let options = vec!["podman"]; + let output = run_command(command, &options)?; + Ok(output.status.success()) + } + "docker" => { + let options = vec!["dockerd"]; + let output = run_command(command, &options)?; + Ok(output.status.success()) + } + "systemd_nspawn" => { + let options = vec!["systemd-nspawn"]; + let output = run_command(command, &options)?; + Ok(output.status.success()) + } + "crio" => { + let options = vec!["crio"]; + let output = run_command(command, &options)?; + Ok(output.status.success()) + } + _ => Ok(false), + } + } + + /// counts the number of running containers + fn rt_count_running(container_rt: &str) -> Fallible { + let command = "pgrep"; + let options = match container_rt { + "podman" => vec!["conmon"], + "docker" => vec!["containerd-shim"], + "systemd-nspawn" => vec!["systemd-nspawn"], + "crio" => vec!["crictl"], + _ => bail!("container runtime {} is not supported", container_rt), + }; + + // run `${command} ${args}` + let mut output = run_command(command, &options)?; + if !output.status.success() { + return Ok(0); + } + let mut std_out = String::from_utf8(output.stdout)?; + + // count lines of previous output + output = spawn_child(std_out.as_str(), "wc", vec!["-l"])?; + if !output.status.success() { + return Ok(0); + } + std_out = String::from_utf8(output.stdout)? + .trim() + .trim_end_matches("\n") + .to_string(); + let count: i32 = std_out.parse()?; + + Ok(count) + } +} diff --git a/src/agent/full/hardware/lsblk.rs b/src/agent/full/hardware/lsblk.rs new file mode 100644 index 0000000..6fecf40 --- /dev/null +++ b/src/agent/full/hardware/lsblk.rs @@ -0,0 +1,40 @@ +//! Module for running `lsblk --fs --json`, and storing the +//! output in the struct LsblkJSON +use failure::{bail, format_err, Fallible, ResultExt}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct LsblkJSON { + pub(crate) blockdevices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct DeviceJSON { + name: String, + fstype: Option, + label: Option, + fsavail: Option, + #[serde(rename = "fsuse%")] + fsuse_percentage: Option, + mountpoint: Option, + children: Option>>, +} + +impl LsblkJSON { + pub(crate) fn new() -> Fallible { + let mut cmd = std::process::Command::new("lsblk"); + let cmdrun = cmd + .arg("--fs") + .arg("--json") + .output() + .with_context(|e| format_err!("failed to run lsblk --fs --json: {}", e))?; + + if !cmdrun.status.success() { + bail!( + "lsblk --fs --json failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + Ok(serde_json::from_slice(&cmdrun.stdout)?) + } +} diff --git a/src/agent/full/hardware/lscpu.rs b/src/agent/full/hardware/lscpu.rs new file mode 100644 index 0000000..f702c9b --- /dev/null +++ b/src/agent/full/hardware/lscpu.rs @@ -0,0 +1,33 @@ +//! Module for running `lscpu --json`, and storing the output +//! in the struct LscpuJSON +use failure::{bail, format_err, Fallible, ResultExt}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct LscpuJSON { + pub(crate) lscpu: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct CPUInfoJSON { + field: String, + data: String, +} + +impl LscpuJSON { + pub(crate) fn new() -> Fallible { + let mut cmd = std::process::Command::new("lscpu"); + let cmdrun = cmd + .arg("--json") + .output() + .with_context(|e| format_err!("failed to run lscpu --json: {}", e))?; + + if !cmdrun.status.success() { + bail!( + "lscpu --json failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + Ok(serde_json::from_slice(&cmdrun.stdout)?) + } +} diff --git a/src/agent/full/hardware/lsmem.rs b/src/agent/full/hardware/lsmem.rs new file mode 100644 index 0000000..a1d4c3a --- /dev/null +++ b/src/agent/full/hardware/lsmem.rs @@ -0,0 +1,77 @@ +//! Module for running `lsmem --json`, and storing the output +//! in the struct LsmemJSON +use failure::{bail, format_err, Fallible, ResultExt}; +use serde::de::{self, Unexpected}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct LsmemJSON { + pub(crate) memory: Vec, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub(crate) struct MemoryJSON { + size: String, + state: String, + #[serde(deserialize_with = "deserialize_bool_or_string")] + removable: bool, + block: String, +} + +impl LsmemJSON { + pub(crate) fn new() -> Fallible { + let mut cmd = std::process::Command::new("lsmem"); + let cmdrun = cmd + .arg("--json") + .output() + .with_context(|e| format_err!("failed to run lsmem --json: {}", e))?; + + if !cmdrun.status.success() { + bail!( + "lsmem --json failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + Ok(serde_json::from_slice(&cmdrun.stdout)?) + } +} + +struct DeserializeBoolOrString; + +impl<'de> de::Visitor<'de> for DeserializeBoolOrString { + type Value = bool; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a bool or a string") + } + + fn visit_bool(self, v: bool) -> Result + where + E: de::Error, + { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + if v == "yes" { + Ok(true) + } else if v == "no" { + Ok(false) + } else { + Err(E::invalid_value(Unexpected::Str(v), &self)) + } + } +} + +/// In some version of lsmem, the field `removable` is 'yes'/'no' instead of 'true'/'false', +/// causing failure of deserialization by serde, hence adds this customized deserializing function +fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(DeserializeBoolOrString) +} diff --git a/src/agent/full/hardware/mod.rs b/src/agent/full/hardware/mod.rs new file mode 100644 index 0000000..3cda531 --- /dev/null +++ b/src/agent/full/hardware/mod.rs @@ -0,0 +1,36 @@ +//! Collect summary of hardware on bare metal machines + +pub(crate) mod lsblk; +pub(crate) mod lscpu; +pub(crate) mod lsmem; + +use failure::Fallible; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct HardwareJSON { + disk: lsblk::LsblkJSON, + cpu: lscpu::LscpuJSON, + memory: lsmem::LsmemJSON, +} + +impl HardwareJSON { + /// disk_info from: `lsblk --fs --json` + /// cpu_info from: `lscpu --json` + /// mem_info from: `lsmem --json` and `lsmem --summary` + pub(crate) fn new() -> Fallible { + let lsblk_struct = lsblk::LsblkJSON::new().unwrap_or(lsblk::LsblkJSON { + blockdevices: Vec::new(), + }); + let lscpu_struct = + lscpu::LscpuJSON::new().unwrap_or(lscpu::LscpuJSON { lscpu: Vec::new() }); + let lsmem_struct = + lsmem::LsmemJSON::new().unwrap_or(lsmem::LsmemJSON { memory: Vec::new() }); + + Ok(HardwareJSON { + disk: lsblk_struct, + cpu: lscpu_struct, + memory: lsmem_struct, + }) + } +} diff --git a/src/agent/full/mock_tests.rs b/src/agent/full/mock_tests.rs new file mode 100644 index 0000000..0a88e0a --- /dev/null +++ b/src/agent/full/mock_tests.rs @@ -0,0 +1,68 @@ +use crate::agent::full::hardware; +use crate::agent::full::container_runtime; +use hardware::lsblk; +use hardware::lscpu; +use hardware::lsmem; + +#[test] +fn test_lscpu() { + let lscpu_result = lscpu::LscpuJSON::new().unwrap(); + let expected_result: lscpu::LscpuJSON = { + let mut cmd = std::process::Command::new("lscpu"); + let cmdrun = cmd + .arg("--json") + .output() + .expect("failed to run lscpu --json"); + + if !cmdrun.status.success() { + panic!( + "lscpu --json failed with error:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + serde_json::from_slice(&cmdrun.stdout).unwrap() + }; + + assert_eq!(lscpu_result, expected_result); +} + +#[test] +fn test_lsblk() { + let lsblk_result = lsblk::LsblkJSON::new().unwrap(); + let expected_result: lsblk::LsblkJSON = { + let mut cmd = std::process::Command::new("lsblk"); + let cmdrun = cmd + .arg("--fs") + .arg("--json") + .output() + .expect("failed to run lsblk --fs --json"); + + if !cmdrun.status.success() { + panic!( + "lsblk --fs --json failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + serde_json::from_slice(&cmdrun.stdout).unwrap() + }; + + assert_eq!(lsblk_result, expected_result); +} + +#[test] +fn test_lsmem() { + let lsmem_result = lsmem::LsmemJSON::new().unwrap(); + println!("{:?}", lsmem_result); +} + +#[test] +fn test_get_hardware_info() { + let hw_struct = hardware::HardwareJSON::new(); + println!("{:?}", hw_struct.unwrap()); +} + +#[test] +fn test_container_runtime() { + let rt = container_runtime::ContainerRT::new(); + println!("{:?}", rt); +} diff --git a/src/agent/full/mod.rs b/src/agent/full/mod.rs new file mode 100644 index 0000000..cd0f895 --- /dev/null +++ b/src/agent/full/mod.rs @@ -0,0 +1,45 @@ +//! Module to collect data under `full` level + +pub(crate) mod container_runtime; +pub(crate) mod hardware; +pub(crate) mod network; + +#[cfg(test)] +mod mock_tests; + +use crate::agent::minimal; +use failure::Fallible; +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Serialize)] +pub(crate) struct IdentityFull { + hardware: Option, + network: HashMap, + container_rt: container_runtime::ContainerRT, +} + +impl IdentityFull { + pub(crate) fn new() -> Fallible { + // only collect hardware information on bare-metal systems + let platform = minimal::platform::get_platform(minimal::KERNEL_ARGS_FILE) + .unwrap_or("metal".to_string()); + let network = network::get_network().unwrap_or(HashMap::new()); + let container_rt = container_runtime::ContainerRT::new(); + match platform.as_str() { + "metal" => { + let hw = hardware::HardwareJSON::new()?; + Ok(IdentityFull { + hardware: Some(hw), + network, + container_rt, + }) + } + _ => Ok(IdentityFull { + hardware: None, + network, + container_rt, + }), + } + } +} diff --git a/src/agent/full/network.rs b/src/agent/full/network.rs new file mode 100644 index 0000000..ac0f91c --- /dev/null +++ b/src/agent/full/network.rs @@ -0,0 +1,39 @@ +//! Module to obtain network information from `nmcli device show`, +//! parse the output, and store the output in a HashMap +use failure::{bail, format_err, Fallible, ResultExt}; +use std::collections::HashMap; + +// Parse key-value output from `nmcli device show` +// separated by whitespaces followed by newline +fn parse_nmcli_output(content: &str) -> HashMap { + // split the contents into elements and keep key-value tuples only. + let mut hashmap = HashMap::new(); + let iter = content.split("\n"); + for e in iter { + let kv: Vec<&str> = e.splitn(2, " ").collect(); + if kv.len() < 2 { + continue; + } + let key: String = kv[0].trim().trim_end_matches(":").to_string(); + let value: String = kv[1].trim().to_string(); + hashmap.insert(key, value); + } + hashmap +} + +pub(crate) fn get_network() -> Fallible> { + let mut cmd = std::process::Command::new("nmcli"); + let cmdrun = cmd + .arg("device") + .arg("show") + .output() + .with_context(|e| format_err!("failed to run nmclli device show: {}", e))?; + + if !cmdrun.status.success() { + bail!( + "nmcli device show failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + Ok(parse_nmcli_output(&String::from_utf8_lossy(&cmdrun.stdout))) +} diff --git a/src/agent/minimal/instance_type.rs b/src/agent/minimal/instance_type.rs new file mode 100644 index 0000000..14d1818 --- /dev/null +++ b/src/agent/minimal/instance_type.rs @@ -0,0 +1,42 @@ +//! Cloud instance type parsing - utility functions + +use crate::util; +use failure::{bail, format_err, Fallible, ResultExt}; +use std::io::Read; +use std::{fs, io}; + +/// Read instance type from cloud metadata file created by Afterburn +/// reference: https://github.com/coreos/afterburn/pull/278 +pub(crate) fn read_instance_type(cmdline_path: T, platform_id: &str) -> Fallible +where + T: AsRef, +{ + let flag = match platform_id { + "aliyun" => "AFTERBURN_ALIYUN_INSTANCE_TYPE", + "aws" => "AFTERBURN_AWS_INSTANCE_TYPE", + "azure" => "AFTERBURN_AZURE_VMSIZE", + "gcp" => "AFTERBURN_GCP_MACHINE_TYPE", + "openstack" => "AFTERBURN_OPENSTACK_INSTANCE_TYPE", + _ => bail!("platform id not supported"), + }; + // open the cmdline file + let fpath = cmdline_path.as_ref(); + let file = fs::File::open(fpath) + .with_context(|e| format_err!("failed to open metadata file {}: {}", fpath, e))?; + + // read content + let mut bufrd = io::BufReader::new(file); + let mut contents = String::new(); + bufrd + .read_to_string(&mut contents) + .with_context(|e| format_err!("failed to read metadata file {}: {}", fpath, e))?; + + // lookup flag by key name + match util::find_flag_value(flag, &contents, "\n") { + Some(platform) => { + log::trace!("found platform id: {}", platform); + Ok(platform) + } + None => bail!("could not find flag '{}' in {}", flag, fpath), + } +} diff --git a/src/agent/minimal/mock_tests.rs b/src/agent/minimal/mock_tests.rs new file mode 100644 index 0000000..235c68d --- /dev/null +++ b/src/agent/minimal/mock_tests.rs @@ -0,0 +1,76 @@ +use crate::agent::minimal; +use std::io::Write; +use tempfile; + +fn mock_cmdline() -> String { + r#"BOOT_IMAGE=(hd0,gpt1)/ostree/fedora-coreos-ea8c6e88611854b872ef062fa8cab93d8f8e49f6d74c12f99420e14293019eee/vmlinuz-5.2.15-200.fc30.x86_64 root=/dev/disk/by-label/root r +ootflags=defaults,prjquota rw ignition.firstboot rd.neednet=1 ip=dhcp mitigations=auto,nosmt console=tty0 console=ttyS0,115200n8 ignition.platform.id=aws ostree=/ostree +/boot.1/fedora-coreos/ea8c6e88611854b872ef062fa8cab93d8f8e49f6d74c12f99420e14293019eee/0"#.to_string() +} + +fn mock_original_os_version() -> String { + r#"{ + "build": "30.20190923.dev.2-2", + "ref": "fedora/x86_64/coreos/testing-devel", + "ostree-commit": "93244e2568e83f26fe6ab40bb85788dc066d5d18fce2d0c4a773b6ea193b13c5", + "imgid": "fedora-coreos-30.20190923.dev.2-2-qemu.qcow2" +}"# + .to_string() +} + +fn mock_metadata() -> String { + r#"AFTERBURN_AWS_INSTANCE_TYPE=m4.large +AFTERBURN_AWS_REGION=us-east-1 +AFTERBURN_AWS_AVAILABILITY_ZONE=us-east-1c +AFTERBURN_AWS_IPV4_LOCAL=123.45.67.8 +AFTERBURN_AWS_PUBLIC_HOSTNAME=ec2-123-45-678-901.compute-1.amazonaws.com +AFTERBURN_AWS_IPV4_PUBLIC=123.45.678.901 +AFTERBURN_AWS_HOSTNAME=ip-123-45-67-8.ec2.internal +AFTERBURN_AWS_INSTANCE_ID=i-0e12a3451176181c4"# + .to_string() +} + +#[test] +fn test_minimal_with_file() { + let cmdline = mock_cmdline(); + let original_os_version = mock_original_os_version(); + let metadata = mock_metadata(); + + let mut karg_file = + tempfile::NamedTempFile::new().expect("Unable to create temporary karg file"); + let mut aleph_version_file = + tempfile::NamedTempFile::new().expect("Unable to create temporary aleph version file"); + let mut metadata_file = + tempfile::NamedTempFile::new().expect("Unable to create temporary metadata file"); + + writeln!(karg_file, "{}", cmdline).expect("Unable to write to temporary karg file"); + writeln!(aleph_version_file, "{}", original_os_version) + .expect("Unable to write to temporary aleph version file"); + writeln!(metadata_file, "{}", metadata).expect("Unable to write to temporary metadata file"); + + let minimal_id = minimal::IdentityMin::collect_minimal_data( + karg_file.path().to_str().unwrap(), + aleph_version_file.path().to_str().unwrap(), + metadata_file.path().to_str().unwrap(), + ) + .expect("Failed to collect test data"); + + let expected_result = minimal::IdentityMin { + platform: "aws".to_string(), + original_os_version: "30.20190923.dev.2-2".to_string(), + current_os_version: "30.20190924.dev.0".to_string(), + instance_type: Some("m4.large".to_string()), + }; + + assert_eq!(minimal_id, expected_result); + + karg_file + .close() + .expect("Unable to close the temporary karg file"); + aleph_version_file + .close() + .expect("Unable to close the temporary aleph version file"); + metadata_file + .close() + .expect("Unable to close the temporary metadata file"); +} diff --git a/src/agent/minimal/mod.rs b/src/agent/minimal/mod.rs new file mode 100644 index 0000000..0508fc8 --- /dev/null +++ b/src/agent/minimal/mod.rs @@ -0,0 +1,137 @@ +//! Module to collect data under `minimal` level + +mod instance_type; +#[cfg(test)] +mod mock_tests; +mod os_release; +pub(crate) mod platform; + +#[cfg(not(test))] +use crate::rpm_ostree; +use failure::{Fallible, ResultExt}; +#[cfg(test)] +use maplit; +use serde::Serialize; +#[cfg(test)] +use std::collections::HashMap; + +/// Kernel arguments location +pub(crate) static KERNEL_ARGS_FILE: &str = "/proc/cmdline"; +/// aleph version file +pub static OS_ALEPH_VERSION_FILE: &str = "/.coreos-aleph-version.json"; +/// Afterburn cloud metadata location +pub static AFTERBURN_METADATA: &str = "/run/metadata/afterburn"; + +/// Agent identity. +#[derive(Debug, Serialize, PartialEq)] +pub(crate) struct IdentityMin { + /// OS platform + pub(crate) platform: String, + /// Original OS version + pub(crate) original_os_version: String, + /// Current OS version + pub(crate) current_os_version: String, + /// Instance type if on cloud platform + pub(crate) instance_type: Option, +} + +impl IdentityMin { + pub(crate) fn new() -> Fallible { + Ok( + Self::collect_minimal_data(KERNEL_ARGS_FILE, OS_ALEPH_VERSION_FILE, AFTERBURN_METADATA) + .context(format!("failed to build 'minimal' identity"))?, + ) + } + + /// Trys to fetch data in minimal level and + /// takes three arguments: cmdline, aleph_version, and metadata, + /// representing the path to the files containing the corresponding information + pub fn collect_minimal_data( + cmdline: &str, + aleph_version: &str, + metadata: &str, + ) -> Fallible { + let platform = platform::get_platform(cmdline).unwrap_or("".to_string()); + let original_os_version = + os_release::read_original_os_version(aleph_version).unwrap_or("".to_string()); + #[cfg(not(test))] + let current_os_version = rpm_ostree::booted() + .unwrap_or(rpm_ostree::Release { + version: "".to_string(), + checksum: "".to_string(), + }) + .version; + #[cfg(test)] + let current_os_version = "30.20190924.dev.0".to_string(); + let instance_type: Option = match platform.as_str() { + "aliyun" | "aws" | "azure" | "gcp" | "openstack" => Some( + instance_type::read_instance_type(metadata, platform.as_str()) + .unwrap_or("".to_string()), + ), + _ => None, + }; + + Ok(Self { + platform, + original_os_version, + current_os_version, + instance_type, + }) + } + + #[cfg(test)] + /// Getter for collected data, returned as a HashMap + fn get_data(&self) -> HashMap { + maplit::hashmap! { + "platform".to_string() => self.platform.clone(), + "original_os_version".to_string() => self.original_os_version.clone(), + "current_os_version".to_string() => self.current_os_version.clone(), + "instance_type".to_string() => match &self.instance_type { + Some(v) => v.clone(), + None => "".to_string(), + }, + } + } + + #[cfg(test)] + pub(crate) fn mock_default() -> Self { + Self { + platform: "mock-qemu".to_string(), + original_os_version: "30.20190923.dev.2-2".to_string(), + current_os_version: "mock-os-version".to_string(), + instance_type: Some("mock-instance-type".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_without_file() { + let id = IdentityMin::mock_default(); + let vars = id.get_data(); + + // check if the keys exist + assert!(vars.contains_key("platform")); + assert!(vars.contains_key("original_os_version")); + assert!(vars.contains_key("current_os_version")); + assert!(vars.contains_key("instance_type")); + + // check if the values match + assert_eq!(vars.get("platform"), Some(&"mock-qemu".to_string())); + assert_eq!( + vars.get("original_os_version"), + Some(&"30.20190923.dev.2-2".to_string()) + ); + assert_eq!( + vars.get("current_os_version"), + Some(&"mock-os-version".to_string()) + ); + assert_eq!( + vars.get("instance_type"), + Some(&"mock-instance-type".to_string()) + ); + } +} diff --git a/src/agent/minimal/os_release.rs b/src/agent/minimal/os_release.rs new file mode 100644 index 0000000..31b105a --- /dev/null +++ b/src/agent/minimal/os_release.rs @@ -0,0 +1,27 @@ +//! OS version parsing - utility functions + +use failure::{format_err, Fallible, ResultExt}; +use serde_json; +use std::fs; + +/// Read aleph version info from os version json file. +pub(crate) fn read_original_os_version(file_path: T) -> Fallible +where + T: AsRef, +{ + // open the os release file + let fpath = file_path.as_ref(); + let file = fs::File::open(fpath) + .with_context(|e| format_err!("failed to open aleph version file {}: {}", fpath, e))?; + + // parse the content + let json: serde_json::Value = + serde_json::from_reader(file).expect("failed to parse aleph version file as JSON"); + let build: String = json + .get("build") + .expect("aleph version file does not contain 'build' key") + .to_string(); + + // remove the leading and trailing quotes, \" + Ok(build[1..build.len() - 1].to_string()) +} diff --git a/src/agent/minimal/platform.rs b/src/agent/minimal/platform.rs new file mode 100644 index 0000000..e9f0f87 --- /dev/null +++ b/src/agent/minimal/platform.rs @@ -0,0 +1,26 @@ +//! Kernel cmdline parsing - utility functions +//! +//! NOTE(lucab): this is not a complete/correct cmdline parser, as it implements +//! just enough logic to extract the platform ID value. In particular, it does not +//! handle separator quoting/escaping, list of values, and merging of repeated +//! flags. Logic is taken from Afterburn, please backport any bugfix there too: +//! https://github.com/coreos/afterburn/blob/v4.1.0/src/util/cmdline.rs + +use crate::util; +use failure::Fallible; + +/// Platform key. +#[cfg(not(feature = "cl-legacy"))] +const CMDLINE_PLATFORM_FLAG: &str = "ignition.platform.id"; +/// Platform key (CL and RHCOS legacy name: "OEM"). +#[cfg(feature = "cl-legacy")] +const CMDLINE_PLATFORM_FLAG: &str = "coreos.oem.id"; + +/// Read platform value from cmdline file. +pub(crate) fn get_platform(cmdline_path: T) -> Fallible +where + T: AsRef, +{ + let fpath = cmdline_path.as_ref(); + util::get_value_by_flag(CMDLINE_PLATFORM_FLAG, fpath, " ") +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..7088bf2 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,60 @@ +//! Agent module for level `minimal` and `full` + +pub mod full; +pub mod minimal; + +use failure::{bail, Fallible}; +use serde::Serialize; + +#[derive(Serialize, Debug)] +pub(crate) struct Agent { + /// Collecting level + level: String, + /// Minimal data + minimal: minimal::IdentityMin, + /// Full data + full: Option, +} + +impl Agent { + pub(crate) fn new(collecting_level: &str) -> Fallible { + match collecting_level { + "minimal" => { + return Ok(Agent { + level: "minimal".to_string(), + minimal: minimal::IdentityMin::new()?, + full: None, + }) + } + "full" => { + return Ok(Agent { + level: "full".to_string(), + minimal: minimal::IdentityMin::new()?, + full: Some(full::IdentityFull::new()?), + }) + } + _ => bail!("Invalid collecting level: {}", collecting_level), + } + } +} + +#[test] +fn test_print_minimal() { + use crate::config::inputs; + use clap::crate_name; + + let cfg: inputs::ConfigInput = + inputs::ConfigInput::read_configs(vec!["tests/minimal/".to_string()], crate_name!()) + .unwrap(); + println!("{:?}", Agent::new(&cfg.collecting.level)); +} + +#[test] +fn test_print_full() { + use crate::config::inputs; + use clap::crate_name; + + let cfg: inputs::ConfigInput = + inputs::ConfigInput::read_configs(vec!["tests/full/".to_string()], crate_name!()).unwrap(); + println!("{:?}", Agent::new(&cfg.collecting.level)); +} diff --git a/src/config/fragments.rs b/src/config/fragments.rs index 90d79a5..9983ca0 100644 --- a/src/config/fragments.rs +++ b/src/config/fragments.rs @@ -23,18 +23,26 @@ pub(crate) struct ReportingFragment { pub(crate) enabled: Option, } +#[cfg(test)] +pub(crate) fn mock_config() -> ConfigFragment { + use std::io::Read; + + let fp = + std::fs::File::open("tests/minimal/fedora-coreos-pinger/config.d/10-default-enable.toml") + .unwrap(); + let mut bufrd = std::io::BufReader::new(fp); + let mut content = vec![]; + bufrd.read_to_end(&mut content).unwrap(); + toml::from_slice(&content).unwrap() +} + #[cfg(test)] mod tests { use super::*; - use std::io::Read; #[test] fn basic_dist_config_default() { - let fp = std::fs::File::open("dist/config.d/10-default-enable.toml").unwrap(); - let mut bufrd = std::io::BufReader::new(fp); - let mut content = vec![]; - bufrd.read_to_end(&mut content).unwrap(); - let cfg: ConfigFragment = toml::from_slice(&content).unwrap(); + let cfg: ConfigFragment = mock_config(); let expected = ConfigFragment { collecting: Some(CollectingFragment { diff --git a/src/config/inputs.rs b/src/config/inputs.rs index 2848720..b17fe65 100644 --- a/src/config/inputs.rs +++ b/src/config/inputs.rs @@ -1,6 +1,5 @@ //! Configuration input (reading snippets from filesystem and merging). /// Modified source from zincati: https://github.com/coreos/zincati/blob/60f3a9144b34ebfa7f7a0fe98f8d641a760ee8f0/src/config/inputs.rs. - use crate::config::fragments; use failure::{bail, ResultExt}; @@ -15,15 +14,11 @@ pub(crate) struct ConfigInput { impl ConfigInput { /// Read config fragments and merge them into a single config. - pub(crate) fn read_configs( - dirs: Vec, - app_name: &str - ) -> failure::Fallible { + pub(crate) fn read_configs(dirs: Vec, app_name: &str) -> failure::Fallible { let common_path = format!("{}/config.d", app_name); - let extensions = vec![ - String::from("toml"), - ]; - let od_cfg = liboverdrop::FragmentScanner::new(dirs, common_path.as_str(), true, extensions); + let extensions = vec![String::from("toml")]; + let od_cfg = + liboverdrop::FragmentScanner::new(dirs, common_path.as_str(), true, extensions); let fragments = od_cfg.scan(); @@ -36,7 +31,7 @@ impl ConfigInput { /// Merge multiple fragments into a single configuration. fn merge_fragments( - fragments: collections::BTreeMap + fragments: collections::BTreeMap, ) -> failure::Fallible { use std::io::Read; @@ -70,9 +65,7 @@ impl ConfigInput { Ok(cfg) } - fn validate_input( - &self - ) -> failure::Fallible<()> { + fn validate_input(&self) -> failure::Fallible<()> { if self.reporting.enabled == None { bail!("Required configuration key `reporting.enabled` not specified."); } @@ -112,9 +105,7 @@ pub(crate) struct ReportingInput { impl ReportingInput { /// Convert fragments into input config for reporting group. fn from_fragments(fragments: Vec) -> Self { - let mut cfg = Self { - enabled: None, - }; + let mut cfg = Self { enabled: None }; for snip in fragments { /* Option is directly passed so that the setting being given diff --git a/src/config/mod.rs b/src/config/mod.rs index 85ebd4e..b27eae3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,3 @@ -mod fragments; +pub(crate) mod fragments; pub(crate) mod inputs; diff --git a/src/main.rs b/src/main.rs index 3322270..2260321 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,87 @@ +//! Telemetry service for FCOS. + +extern crate nix; +extern crate slog; +#[macro_use] +extern crate slog_scope; + +#[cfg(test)] +extern crate tempfile; + +/// agent module +mod agent; +/// Collect config from files. mod config; +/// rpm-ostree client. +mod rpm_ostree; +/// utility functions +mod util; -use clap::{Arg, crate_authors, crate_description, crate_name, crate_version}; +use clap::{crate_authors, crate_description, crate_name, crate_version, Arg}; use config::inputs; -use failure::{bail, ResultExt}; +use failure::{bail, Fallible, ResultExt}; use log::LevelFilter; +#[cfg(test)] +use mockito; +use nix::unistd::{fork, ForkResult}; +use serde_json::json; +use std::thread; +use std::time::Duration; /// Parse the reporting.enabled and collecting.level keys from config fragments, /// and check that the keys are set to a valid telemetry setting. If not, /// or in case of other error, return non-zero. -fn check_config(config: inputs::ConfigInput) -> failure::Fallible<()> { +fn check_config(config: &inputs::ConfigInput) -> Fallible { if config.reporting.enabled.unwrap() { println!("Reporting enabled."); - let collecting_level = config.collecting.level; + let collecting_level = &config.collecting.level; match collecting_level.as_str() { "minimal" | "full" => println!("Collection set at level '{}'.", collecting_level), _ => bail!("invalid collection level '{}'", collecting_level), } + + Ok(true) } else { println!("Reporting disabled."); + + Ok(false) } +} + +fn send_data(agent: &agent::Agent) -> Fallible<()> { + // TODO: Send data to remote endpoint + // Currently only prints the Agent struct + println!("{:?}", json!(agent)); + + #[cfg(test)] + { + let url = mockito::server_url(); + let client = reqwest::Client::new(); + client.post(url.as_str()).json(agent).send()?; + }; Ok(()) } -fn main() -> failure::Fallible<()> { +fn main() -> Fallible<()> { + match fork() { + Ok(ForkResult::Parent { child, .. }) => { + println!("New child has pid: {}", child); + return Ok(()); + } + Ok(ForkResult::Child) => (), + Err(_) => panic!("Fork failed in main()"), + }; + + // continues running in child process let matches = clap::app_from_crate!() - .arg(Arg::with_name("v") - .short("v") - .multiple(true) - .help("Sets log verbosity level")) + .arg( + Arg::with_name("v") + .short("v") + .multiple(true) + .help("Sets log verbosity level"), + ) .get_matches(); let log_level = match matches.occurrences_of("v") { @@ -52,7 +104,79 @@ fn main() -> failure::Fallible<()> { let config = inputs::ConfigInput::read_configs(dirs, crate_name!()) .context("failed to read configuration input")?; - check_config(config)?; + let is_enabled = check_config(&config)?; + let collecting_level: String = config.collecting.level; + + // Collect the data if enabled + if is_enabled { + let collecting_level_copy_daily = collecting_level.clone(); + let collecting_level_copy_monthly = collecting_level.clone(); + + // spawn thread for monitoring timestamp and sending report daily + let daily_thread = thread::spawn(move || -> Fallible<()> { + const DAILY_TIMESTAMP_FILE: &str = r#"/var/log/fedora-coreos-pinger/timestamp_daily"#; + const SECS_PER_12_HOURS: Duration = Duration::from_secs(12 * 60 * 60); + loop { + let clock = util::Clock::read_timestamp(DAILY_TIMESTAMP_FILE)?; + if clock.if_need_update("daily")? { + println!("Collecting and sending daily report..."); + let agent = agent::Agent::new(collecting_level_copy_daily.as_str())?; + // Send to the remote endpoint + send_data(&agent)?; + // Update the timestamp + clock.write_timestamp(DAILY_TIMESTAMP_FILE)?; + } + thread::sleep(SECS_PER_12_HOURS); + } + }); + + // spawn thread for monitoring timestamp and sending report monthly + let monthly_thread = thread::spawn(move || -> Fallible<()> { + const MONTHLY_TIMESTAMP_FILE: &str = + r#"/var/log/fedora-coreos-pinger/timestamp_monthly"#; + const SECS_PER_15_DAYS: Duration = Duration::from_secs(15 * 24 * 60 * 60); + loop { + let clock = util::Clock::read_timestamp(MONTHLY_TIMESTAMP_FILE)?; + if clock.if_need_update("monthly")? { + println!("Collecting and sending monthly report..."); + let agent = agent::Agent::new(collecting_level_copy_monthly.as_str())?; + // Send to the remote endpoint + send_data(&agent)?; + // Update the timestamp + clock.write_timestamp(MONTHLY_TIMESTAMP_FILE)?; + } + thread::sleep(SECS_PER_15_DAYS); + } + }); + + println!("Waiting for threads..."); + + daily_thread + .join() + .expect("Thread for daily reporting failed")?; + monthly_thread + .join() + .expect("Thread for monthly reporting failed")?; + } Ok(()) } + +#[test] +fn test_send_data() { + use crate::agent::Agent; + use crate::config::inputs; + use clap::crate_name; + + let mock = mockito::mock("POST", "/") + .match_header("content-type", "application/json") + .with_status(200) + .create(); + + let cfg: inputs::ConfigInput = + inputs::ConfigInput::read_configs(vec!["tests/full/".to_string()], crate_name!()).unwrap(); + let agent = Agent::new(&cfg.collecting.level).unwrap(); + send_data(&(agent)).unwrap(); + + mock.assert(); +} diff --git a/src/rpm_ostree/cli_status.rs b/src/rpm_ostree/cli_status.rs new file mode 100644 index 0000000..d02f79b --- /dev/null +++ b/src/rpm_ostree/cli_status.rs @@ -0,0 +1,150 @@ +//! Interface to `rpm-ostree status --json`. + +#![allow(unused)] + +use super::Release; +use failure::{bail, ensure, format_err, Fallible, ResultExt}; +use serde::Deserialize; + +/// JSON output from `rpm-ostree status --json` +#[derive(Debug, Deserialize)] +pub struct StatusJSON { + deployments: Vec, +} + +/// Partial deployment object (only fields relevant to zincati). +#[derive(Debug, Deserialize)] +pub struct DeploymentJSON { + booted: bool, + #[serde(rename = "base-checksum")] + base_checksum: Option, + #[serde(rename = "base-commit-meta")] + base_metadata: BaseCommitMetaJSON, + checksum: String, + version: String, +} + +/// Metadata from base commit (only fields relevant to zincati). +#[derive(Debug, Deserialize)] +struct BaseCommitMetaJSON { + #[serde(rename = "coreos-assembler.basearch")] + basearch: String, + #[serde(rename = "fedora-coreos.stream")] + stream: String, +} + +impl DeploymentJSON { + /// Convert into `Release`. + pub fn into_release(self) -> Release { + Release { + checksum: self.base_revision(), + version: self.version, + } + } + + /// Return the deployment base revision. + pub fn base_revision(&self) -> String { + self.base_checksum + .clone() + .unwrap_or_else(|| self.checksum.clone()) + } +} + +/// Return base architecture for booted deployment. +pub fn basearch() -> Fallible { + let status = status_json(true)?; + let json = booted_json(status)?; + Ok(json.base_metadata.basearch) +} + +/// Find the booted deployment. +pub fn booted() -> Fallible { + let status = status_json(true)?; + let json = booted_json(status)?; + Ok(json.into_release()) +} + +/// Return local deployments. +/// Note: Returns vector instead of btree as in Afterburn +pub fn local_deployments() -> Fallible> { + let status = status_json(false)?; + let mut deployments = Vec::::new(); + for entry in status.deployments { + let release = entry.into_release(); + deployments.push(release); + } + Ok(deployments) +} + +/// Return updates stream for booted deployment. +pub fn updates_stream() -> Fallible { + let status = status_json(true)?; + let json = booted_json(status)?; + ensure!(!json.base_metadata.stream.is_empty(), "empty stream value"); + Ok(json.base_metadata.stream) +} + +/// Return JSON object for booted deployment. +fn booted_json(status: StatusJSON) -> Fallible { + let booted = status + .deployments + .into_iter() + .find(|d| d.booted) + .ok_or_else(|| format_err!("no booted deployment found"))?; + + ensure!(!booted.base_revision().is_empty(), "empty base revision"); + ensure!(!booted.version.is_empty(), "empty version"); + ensure!(!booted.base_metadata.basearch.is_empty(), "empty basearch"); + Ok(booted) +} + +/// Introspect deployments (rpm-ostree status). +fn status_json(booted_only: bool) -> Fallible { + let mut cmd = std::process::Command::new("rpm-ostree"); + cmd.arg("status"); + + // Try to request the minimum scope we need. + if booted_only { + cmd.arg("--booted"); + } + + let cmdrun = cmd + .arg("--json") + .output() + .with_context(|e| format_err!("failed to run rpm-ostree: {}", e))?; + + if !cmdrun.status.success() { + bail!( + "rpm-ostree status failed:\n{}", + String::from_utf8_lossy(&cmdrun.stderr) + ); + } + let status: StatusJSON = serde_json::from_slice(&cmdrun.stdout)?; + Ok(status) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_status() -> Fallible { + let fp = std::fs::File::open("tests/fixtures/rpm-ostree-status.json").unwrap(); + let mut bufrd = std::io::BufReader::new(fp); + let status: StatusJSON = serde_json::from_reader(bufrd)?; + Ok(status) + } + + #[test] + fn mock_booted_basearch() { + let status = mock_status().unwrap(); + let booted = booted_json(status).unwrap(); + assert_eq!(booted.base_metadata.basearch, "x86_64"); + } + + #[test] + fn mock_booted_updates_stream() { + let status = mock_status().unwrap(); + let booted = booted_json(status).unwrap(); + assert_eq!(booted.base_metadata.stream, "testing-devel"); + } +} diff --git a/src/rpm_ostree/mod.rs b/src/rpm_ostree/mod.rs new file mode 100644 index 0000000..f200ecc --- /dev/null +++ b/src/rpm_ostree/mod.rs @@ -0,0 +1,15 @@ +//! Use rpm-ostree commands to extract system info + +mod cli_status; + +pub use cli_status::{basearch, booted, updates_stream}; + +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Release { + /// OS version. + pub version: String, + /// Image base checksum. + pub checksum: String, +} diff --git a/src/util/clock.rs b/src/util/clock.rs new file mode 100644 index 0000000..6c31936 --- /dev/null +++ b/src/util/clock.rs @@ -0,0 +1,78 @@ +//! Clock for tracking report intervals +//! currently implemented with two levels of intervals: daily and monthly +use bincode::{deserialize_from, serialize_into}; +use failure::{bail, Fallible}; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{BufReader, BufWriter}; +use std::path::Path; +use std::time::SystemTime; + +#[derive(Deserialize, Serialize, Debug)] +pub(crate) struct Clock { + timestamp: SystemTime, +} + +impl Clock { + /// reads previous timestamp from file + /// located in `/var/lib/fedora-coreos-pinger` + pub(crate) fn read_timestamp(path: &str) -> Fallible { + let f = open_file(path)?; + let mut reader = BufReader::new(f); + let clock: Clock = deserialize_from(&mut reader)?; + + Ok(clock) + } + + /// writes current timestamp into file + /// located in `/var/lib/fedora-coreos-pinger` + pub(crate) fn write_timestamp(&self, path: &str) -> Fallible<()> { + let mut f = BufWriter::new(File::create(path)?); + serialize_into(&mut f, self)?; + Ok(()) + } + + /// checks if the timestamp needs an update + /// mode = 'daily' | 'monthly' + pub(crate) fn if_need_update(&self, mode: &str) -> Fallible { + let secs_per_day = 24 * 60 * 60; + let secs_per_month = 31 * secs_per_day; + + let now = SystemTime::now(); + let elapsed_seconds = now.duration_since(self.timestamp)?.as_secs(); + + match mode { + "daily" => { + let elapsed_days = elapsed_seconds / secs_per_day; + if elapsed_days >= 1 { + Ok(true) + } else { + Ok(false) + } + } + "monthly" => { + let elapsed_months = elapsed_seconds / secs_per_month; + if elapsed_months >= 1 { + Ok(true) + } else { + Ok(false) + } + } + _ => bail!("Clock mode is not supported"), + } + } +} + +/// open a file in read-only mode and create the file if it doesn't exist +/// with current timestamp and then returns the File +fn open_file(path: &str) -> Fallible { + if !Path::new(path).exists() { + let file = File::create(path)?; + let clock = Clock { + timestamp: SystemTime::now(), + }; + let mut writer = BufWriter::new(file); + serialize_into(&mut writer, &clock)?; + } + Ok(File::open(path)?) +} diff --git a/src/util/cmdline.rs b/src/util/cmdline.rs new file mode 100644 index 0000000..315459d --- /dev/null +++ b/src/util/cmdline.rs @@ -0,0 +1,98 @@ +// Copyright 2018 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Kernel cmdline parsing - utility functions +//! +//! NOTE(lucab): this is not a complete/correct cmdline parser, as it implements +//! just enough logic to extract the OEM ID value. In particular, it doesn't +//! handle separator quoting/escaping, list of values, and merging of repeated +//! flags. + +use failure::{bail, format_err, Fallible, ResultExt}; +use std::io::Read; +use std::{fs, io}; + +// Get value of `flag` from key-value pairs in the file `fpath` +pub fn get_value_by_flag(flag: &str, fpath: &str, delimiter: &str) -> Fallible { + // open the cmdline file + let file = fs::File::open(fpath) + .with_context(|e| format_err!("Failed to open file {}: {}", fpath, e))?; + + // read the contents + let mut bufrd = io::BufReader::new(file); + let mut contents = String::new(); + bufrd + .read_to_string(&mut contents) + .with_context(|e| format_err!("Failed to read file {}: {}", fpath, e))?; + + match find_flag_value(flag, &contents, delimiter) { + Some(platform) => { + trace!("found '{}' flag: {}", flag, platform); + Ok(platform) + } + None => bail!("Couldn't find flag '{}' in file ({})", flag, fpath), + } +} + +// Find flag value in cmdline string. +pub fn find_flag_value(flagname: &str, cmdline: &str, delimiter: &str) -> Option { + // split the contents into elements and keep key-value tuples only. + let params: Vec<(&str, &str)> = cmdline + .split(delimiter) + .filter_map(|s| { + let kv: Vec<&str> = s.splitn(2, '=').collect(); + match kv.len() { + 2 => Some((kv[0], kv[1])), + _ => None, + } + }) + .collect(); + + // find the oem flag + for (key, val) in params { + if key != flagname { + continue; + } + let bare_val = val.trim(); + if !bare_val.is_empty() { + return Some(bare_val.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_find_flag() { + let flagname = "coreos.oem.id"; + let tests = vec![ + ("", None), + ("foo=bar", None), + ("coreos.oem.id", None), + ("coreos.oem.id=", None), + ("coreos.oem.id=\t", None), + ("coreos.oem.id=ec2", Some("ec2".to_string())), + ("coreos.oem.id=\tec2", Some("ec2".to_string())), + ("coreos.oem.id=ec2\n", Some("ec2".to_string())), + ("foo=bar coreos.oem.id=ec2", Some("ec2".to_string())), + ("coreos.oem.id=ec2 foo=bar", Some("ec2".to_string())), + ]; + for (tcase, tres) in tests { + let res = find_flag_value(flagname, tcase, " "); + assert_eq!(res, tres, "failed testcase: '{}'", tcase); + } + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..2d04fc6 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,21 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! utility functions + +mod clock; +mod cmdline; + +pub(crate) use self::clock::*; +pub(crate) use self::cmdline::*; diff --git a/tests/fixtures/rpm-ostree-status.json b/tests/fixtures/rpm-ostree-status.json new file mode 100644 index 0000000..972bbb5 --- /dev/null +++ b/tests/fixtures/rpm-ostree-status.json @@ -0,0 +1,64 @@ +{ + "deployments" : [ + { + "unlocked" : "none", + "requested-local-packages" : [ + ], + "base-commit-meta" : { + "coreos-assembler.config-gitrev" : "092f680a5c75324e3efcd5e50374a2c1b5d4b057", + "coreos-assembler.config-dirty" : "false", + "rpmostree.inputhash" : "aa52f85a4a464330df69baa9e0f2b184ab90e81fcff0c3f63e0d7ccb67f977f2", + "coreos-assembler.basearch" : "x86_64", + "fedora-coreos.stream" : "testing-devel", + "version" : "30.20190924.dev.0", + "rpmostree.rpmmd-repos" : [ + { + "id" : "fedora-coreos-pool", + "timestamp" : 1569088054 + }, + { + "id" : "fedora", + "timestamp" : 1556236181 + }, + { + "id" : "fedora-updates", + "timestamp" : 1569286043 + }, + { + "id" : "fedora-modular", + "timestamp" : 1556236050 + }, + { + "id" : "fedora-updates-modular", + "timestamp" : 1569115808 + } + ] + }, + "base-removals" : [ + ], + "gpg-enabled" : true, + "origin" : "fedora:fedora/x86_64/coreos/testing-devel", + "osname" : "fedora-coreos", + "pinned" : false, + "requested-base-local-replacements" : [ + ], + "checksum" : "aeb18e6708382ac5b44069b969e16b43719f84579b35c0dc432cbe82d24d7244", + "regenerate-initramfs" : false, + "id" : "fedora-coreos-aeb18e6708382ac5b44069b969e16b43719f84579b35c0dc432cbe82d24d7244.0", + "version" : "30.20190924.dev.0", + "requested-packages" : [ + ], + "requested-base-removals" : [ + ], + "serial" : 0, + "base-local-replacements" : [ + ], + "timestamp" : 1569356813, + "booted" : true, + "packages" : [ + ] + } + ], + "transaction" : null, + "cached-update" : null +} \ No newline at end of file diff --git a/tests/full/fedora-coreos-pinger/config.d/10-default-enable.toml b/tests/full/fedora-coreos-pinger/config.d/10-default-enable.toml new file mode 100644 index 0000000..a9db493 --- /dev/null +++ b/tests/full/fedora-coreos-pinger/config.d/10-default-enable.toml @@ -0,0 +1,9 @@ +# fedora-coreos-pinger default configuration + +[collecting] +# Default collecting.level is `minimal`. May be set to `"minimal"` or `"full"`. +level = "full" + +[reporting] +# Required. May be set to `true` or `false`. +enabled = true diff --git a/tests/minimal/fedora-coreos-pinger/config.d/10-default-enable.toml b/tests/minimal/fedora-coreos-pinger/config.d/10-default-enable.toml new file mode 100644 index 0000000..dbb7bb2 --- /dev/null +++ b/tests/minimal/fedora-coreos-pinger/config.d/10-default-enable.toml @@ -0,0 +1,9 @@ +# fedora-coreos-pinger default configuration + +[collecting] +# Default collecting.level is `minimal`. May be set to `"minimal"` or `"full"`. +level = "minimal" + +[reporting] +# Required. May be set to `true` or `false`. +enabled = true