service: Mark .applied with checksum

At some envs like openshift machine-config-operator moving configuration
files can break in place upgrades. This chane use checksums storead at
the .applied files to check for changes.

Signed-off-by: Enrique Llorente <ellorent@redhat.com>
This commit is contained in:
Enrique Llorente 2023-12-18 11:11:53 +01:00 committed by Enrique Llorente Pastora
parent dbd6eca4b6
commit dbb98e1d30
4 changed files with 127 additions and 28 deletions

View File

@ -9,8 +9,8 @@ nmstate\&.service
nmstate\&.service invokes \fBnmstatectl service\fR command which
apply all network state files ending with \fB.yml\fR in
\fB/etc/nmstate\fR folder.
The applied network state file will be renamed with postfix \fB.applied\fR
to prevent repeated applied on next service start.
The applied network state file sha256 digest will be stored at \fB.applied\fR
file to prevent repeated applied on next service start.
.SH BUG REPORTS
Report bugs on nmstate GitHub issues <https://github.com/nmstate/nmstate>.
.SH COPYRIGHT

View File

@ -28,6 +28,7 @@ ctrlc = { version = "3.2.1", optional = true }
uuid = { version = "1.1", features = ["v4"] }
chrono = "0.4"
nispor = { version = "1.2", optional = true }
ring = "0.17.7"
[features]
default = ["query_apply", "gen_conf", "gen_revert"]

View File

@ -1,12 +1,42 @@
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
use crate::{apply::apply, error::CliError};
use ring::digest::{Context, SHA256};
const CONFIG_FILE_EXTENTION: &str = "yml";
const RELOCATE_FILE_EXTENTION: &str = "applied";
const CHECKSUM_FILE_EXTENTION: &str = "applied";
#[derive(Eq, Hash, PartialEq, Clone, PartialOrd, Ord)]
struct FileChecksum {
path: PathBuf,
checksum: String,
}
impl FileChecksum {
fn new(path: PathBuf, checksum: String) -> Self {
Self { path, checksum }
}
}
struct HexSlice<'a>(&'a [u8]);
impl<'a> fmt::Display for HexSlice<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for &byte in self.0 {
write!(f, "{:0>2x}", byte)?;
}
Ok(())
}
}
pub(crate) fn ncl_service(
matches: &clap::ArgMatches,
@ -27,7 +57,7 @@ pub(crate) fn ncl_service(
};
if config_files.is_empty() {
log::info!(
"No nmstate config(end with .{}) found in config folder {}",
"No new nmstate config(end with .{}) found in config folder {}",
CONFIG_FILE_EXTENTION,
folder
);
@ -39,24 +69,29 @@ pub(crate) fn ncl_service(
// We sleep for 2 seconds here to avoid meaningless retry.
std::thread::sleep(std::time::Duration::from_secs(2));
for file_path in config_files {
let mut fd = match std::fs::File::open(&file_path) {
for config_file in config_files {
let mut fd = match std::fs::File::open(&config_file.path) {
Ok(fd) => fd,
Err(e) => {
log::error!(
"Failed to read config file {}: {e}",
file_path.display()
config_file.path.display()
);
continue;
}
};
match apply(&mut fd, matches) {
Ok(_) => {
log::info!("Applied nmstate config: {}", file_path.display());
if let Err(e) = relocate_file(&file_path) {
log::info!(
"Applied nmstate config: {}",
config_file.path.display()
);
if let Err(e) =
write_checksum(&config_file.path, &config_file.checksum)
{
log::error!(
"Failed to rename applied state file: {} {}",
file_path.display(),
"Failed to generate checksum file: {} {}",
config_file.path.display(),
e
);
}
@ -64,7 +99,7 @@ pub(crate) fn ncl_service(
Err(e) => {
log::error!(
"Failed to apply state file {}: {}",
file_path.display(),
config_file.path.display(),
e
);
}
@ -74,28 +109,72 @@ pub(crate) fn ncl_service(
Ok("".to_string())
}
// All file ending with `.yml` will be included.
fn get_config_files(folder: &str) -> Result<Vec<PathBuf>, CliError> {
// All file ending with `.yml` that do not have a sha256 checksum stored at
// a `.applied` file or the checksum stored differs.
fn get_config_files(folder: &str) -> Result<Vec<FileChecksum>, CliError> {
let folder = Path::new(folder);
let mut ret = Vec::new();
let mut yml_files = HashSet::<FileChecksum>::new();
let mut applied_files = HashSet::<FileChecksum>::new();
for entry in folder.read_dir()? {
let file = entry?.path();
if file.extension() == Some(OsStr::new(CONFIG_FILE_EXTENTION)) {
ret.push(folder.join(file));
let digest = sha256_digest(&file)?;
yml_files.insert(FileChecksum::new(
folder.join(file).with_extension(""),
digest,
));
} else if file.extension() == Some(OsStr::new(CHECKSUM_FILE_EXTENTION))
{
let digest = fs::read_to_string(&file)?;
applied_files.insert(FileChecksum::new(
folder.join(file).with_extension(""),
digest,
));
}
}
ret.sort_unstable();
let mut ret: Vec<_> = yml_files
.difference(&applied_files)
.cloned()
.map(|f| {
FileChecksum::new(
f.path.with_extension(CONFIG_FILE_EXTENTION),
f.checksum,
)
})
.collect();
ret.sort_by_key(|f| f.path.clone());
Ok(ret)
}
// rename file by adding a suffix `.applied`.
pub(crate) fn relocate_file(file_path: &Path) -> Result<(), CliError> {
let new_path = file_path.with_extension(RELOCATE_FILE_EXTENTION);
std::fs::rename(file_path, &new_path)?;
// Dump state checksum to `.applied` file.
pub(crate) fn write_checksum(
file_path: &Path,
checksum: &str,
) -> Result<(), CliError> {
let checksum_file_path = file_path.with_extension(CHECKSUM_FILE_EXTENTION);
fs::write(&checksum_file_path, checksum)?;
log::info!(
"Renamed applied config {} to {}",
"Checksum for config {} stored at {}",
file_path.display(),
new_path.display()
checksum_file_path.display(),
);
Ok(())
}
fn sha256_digest(file_path: &PathBuf) -> Result<String, CliError> {
let mut context = Context::new(&SHA256);
let mut buffer = [0; 1024];
let input = File::open(file_path)?;
let mut reader = BufReader::new(input);
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
context.update(&buffer[..count]);
}
Ok(format!("{}", HexSlice(context.finish().as_ref())))
}

View File

@ -2,6 +2,8 @@
import os
import shutil
import hashlib
from pathlib import Path
import yaml
import pytest
@ -73,6 +75,13 @@ TEST_CONFIG3_APPLIED_FILE_PATH = f"{CONFIG_DIR}/03-nmstate-policy-test.applied"
DUMMY1 = "dummy1"
def sha256sum(filename):
with open(filename, "rb", buffering=0) as f:
digest = hashlib.sha256()
digest.update(f.read())
return digest.hexdigest()
@pytest.fixture
def nmstate_etc_config():
if not os.path.isdir(CONFIG_DIR):
@ -103,6 +112,8 @@ def nmstate_etc_config():
)
os.remove(TEST_CONFIG1_APPLIED_FILE_PATH)
os.remove(TEST_CONFIG2_APPLIED_FILE_PATH)
os.remove(TEST_CONFIG1_FILE_PATH)
os.remove(TEST_CONFIG2_FILE_PATH)
def test_nmstate_service_apply(nmstate_etc_config):
@ -111,10 +122,14 @@ def test_nmstate_service_apply(nmstate_etc_config):
desire_state = yaml.load(TEST_YAML2_CONTENT, Loader=yaml.SafeLoader)
assert_state_match(desire_state)
assert not os.path.exists(TEST_CONFIG1_FILE_PATH)
assert os.path.isfile(TEST_CONFIG1_APPLIED_FILE_PATH)
assert not os.path.exists(TEST_CONFIG2_FILE_PATH)
assert os.path.isfile(TEST_CONFIG2_APPLIED_FILE_PATH)
assert os.path.isfile(TEST_CONFIG1_FILE_PATH)
assert Path(TEST_CONFIG1_APPLIED_FILE_PATH).read_text() == sha256sum(
TEST_CONFIG1_FILE_PATH
)
assert os.path.isfile(TEST_CONFIG2_FILE_PATH)
assert Path(TEST_CONFIG2_APPLIED_FILE_PATH).read_text() == sha256sum(
TEST_CONFIG2_FILE_PATH
)
@pytest.fixture
@ -153,9 +168,13 @@ def test_nmstate_service_apply_nmpolicy(dummy1_up):
try:
exec_cmd("systemctl restart nmstate".split(), check=True)
assert_absent(DUMMY1)
assert os.path.isfile(TEST_CONFIG3_APPLIED_FILE_PATH)
assert os.path.isfile(TEST_CONFIG3_FILE_PATH)
assert Path(TEST_CONFIG3_APPLIED_FILE_PATH).read_text() == sha256sum(
TEST_CONFIG3_FILE_PATH
)
finally:
os.remove(TEST_CONFIG3_APPLIED_FILE_PATH)
os.remove(TEST_CONFIG3_FILE_PATH)
def test_nmstate_service_without_etc_folder():