Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

strategy: add new marker_file strategy #1103

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tempfile = ">= 3.7, < 4.0"
thiserror = "1.0"
tokio = { version = "1.26", features = ["signal", "rt", "rt-multi-thread"] }
tokio = { version = "1.26", features = ["fs", "signal", "rt", "rt-multi-thread"] }
toml = "0.5"
tzfile = "0.1.3"
url = { version = "2.4", features = ["serde"] }
Expand Down
24 changes: 18 additions & 6 deletions dist/tmpfiles.d/zincati.conf
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
#Type Path Mode User Group Age Argument
d /run/zincati 0775 zincati zincati - -
#Type Path Mode User Group Age Argument
d /run/zincati 0775 zincati zincati - -

# Runtime configuration fragments
d /run/zincati/config.d 0775 zincati zincati - -
d /run/zincati/config.d 0775 zincati zincati - -

# Runtime state, unstable/private implementation details
d /run/zincati/private 0770 zincati zincati - -
d /run/zincati/private 0770 zincati zincati - -

# Runtime public interfaces
d /run/zincati/public 0775 zincati zincati - -
d /run/zincati/public 0775 zincati zincati - -

# Legacy symlink to metrics socket
L+ /run/zincati/private/metrics.promsock - - - - ../public/metrics.promsock
L+ /run/zincati/private/metrics.promsock - - - - ../public/metrics.promsock

# Directory for Zincati's variable state information
d /var/lib/zincati 0775 zincati zincati - -

# Directory for admin input
d /var/lib/zincati/admin 0775 zincati zincati - -

# Directory for strategy-specific input
d /var/lib/zincati/admin/strategy 0775 zincati zincati - -

# Directory for `marker_file` strategy input
d /var/lib/zincati/admin/strategy/marker_file 0775 zincati zincati - -
58 changes: 58 additions & 0 deletions docs/usage/updates-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,64 @@ Such an approach is only recommended where nodes are already grouped into an orc
[airlock]: https://github.com/coreos/airlock
[etcd3]: https://etcd.io/

# File-based strategy
The `marker_file` strategy is a simple, low-level strategy that only allows Zincati to reboot for updates when a specific marker file exists on the local filesystem.

Similar to the `fleet_lock` strategy, the `marker_file` strategy provides a large amount of flexibility to admins, and should be used with a central controller.
Unlike `fleet_lock`, where the central controller must be a lock-manager on the network, the central controller for the `marker_file` strategy can be a containerized agent, some central task manager able to manipulate files on machines (e.g. Ansible), or even a human via SSH.

To indicate that a machine is allowed to finalize an update and reboot, a file with the following properties must be present on the machine's local filesystem:
- named `allowfinalize.json`
- under `/var/lib/zincati/admin/strategy/marker_file`
- is a valid JSON file
- not writable by others

If any of the above is not satisfied in your marker file, Zincati will not allow reboots.

`allowfinalize.json` can optionally contain an `allowUntil` key with a Unix timestamp integer as its value to indicate the expiry date and time of this marker file. If the current time timestamp is _greater than or equal to_ this timestamp, then reboots will not be allowed.
Otherwise, if the `allowUntil` key is not present, reboots will be allowed for as long as `allowfinalize.json` exists (in the right location), and it must be removed to disallow reboots.
Note that `allowfinalize.json` must still be a valid JSON file, regardless of whether the `allowUntil` key is present.

For example, if you wish to allow reboots until the end of April 2021 UTC, create a JSON file with path `/var/lib/zincati/admin/strategy/marker_file/allowfinalize.json` (Unix timestamp 1619827200 is May 01 2021 00:00:00 UTC):

```json
{
"allowUntil": 1619827200
}
```

The above JSON file can be created using `jq` by entering the following command:

```bash
echo '"2021-05-01T00:00:00Z"' | jq '{allowUntil: 'fromdateiso8601'}' \
| sudo tee /var/lib/zincati/admin/strategy/marker_file/allowfinalize.json
```

Warning: In `jq` versions `1.6` and lower, `jq` [may output incorrect Unix timestamps][jq_bug] for certain datetimes on machines with certain `localtime`s.

If you wish to allow reboots for as long as the marker file is present, create an empty JSON file with path `/var/lib/zincati/admin/strategy/marker_file/allowfinalize.json`:

```json
{}
```

An empty JSON file can be created by entering:

```bash
echo '{}' | sudo tee /var/lib/zincati/admin/strategy/marker_file/allowfinalize.json
```

For configuration purposes, such strategy is labeled `marker_file` and takes no additional configuration parameters.

This strategy can be enabled via a configuration snippet like the following:

```toml
[updates]
strategy = "marker_file"
```

[jq_bug]: https://github.com/stedolan/jq/issues/2001

# Periodic strategy

The `periodic` strategy allows Zincati to only reboot for updates during certain timeframes, also known as "maintenance windows" or "reboot windows".
Expand Down
209 changes: 209 additions & 0 deletions src/strategy/marker_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Strategy for local marker file-based updates.

use anyhow::{Context, Error, Result};
use fn_error_context::context;
use futures::future;
use futures::prelude::*;
use log::trace;
use serde::{Deserialize, Serialize};
use std::os::unix::fs::PermissionsExt;
use std::pin::Pin;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs as tokio_fs;

/// Struct to parse finalization marker file's JSON content into.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FinalizationMarker {
/// Unix timestamp of expiry time.
allow_until: Option<u64>,
}

/// Strategy for immediate updates.
#[derive(Clone, Debug, Default, Serialize)]
pub(crate) struct StrategyMarkerFile {}

impl StrategyMarkerFile {
/// Strategy label/name.
pub const LABEL: &'static str = "marker_file";
/// Local filesystem path to finalization marker file.
pub const FINALIZATION_MARKER_FILE_PATH: &'static str =
"/var/lib/zincati/admin/strategy/marker_file/allowfinalize.json";

/// Check if finalization is allowed.
pub(crate) fn can_finalize(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {
Box::pin(Self::marker_file_allow_finalization(
Self::FINALIZATION_MARKER_FILE_PATH,
))
}

/// Try to report steady state.
pub(crate) fn report_steady(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>>>> {
trace!("marker_file strategy, report steady: {}", true);

let res = future::ok(true);
Box::pin(res)
}

/// Asynchronous helper function that returns a future indicating whether
/// finalization is allowed, depending on the presence of a marker file.
async fn marker_file_allow_finalization(
finalization_marker_path: &'static str,
) -> Result<bool> {
if !verify_file_metadata(finalization_marker_path).await? {
return Ok(false);
}

if is_expired(finalization_marker_path).await? {
return Ok(false);
}

Ok(true)
}
}

/// Verify that finalization marker file exists, is a regular file,
/// and has the correct permissions.
#[context("failed to verify finalization marker file metadata")]
async fn verify_file_metadata(path: &str) -> Result<bool> {
let attr = tokio_fs::metadata(path).await;
let attr = match attr {
Ok(attr) => attr,
// If `path` doesn't exist, return false early.
Err(_) => return Ok(false),
};

if !attr.is_file() {
anyhow::bail!("file is not regular file");
}

let mode = attr.permissions().mode();
if mode & 0o2 != 0 {
anyhow::bail!("file should not be writable by other");
}

Ok(true)
}

/// Check whether the finalization marker file has expired, if `allowUntil` key
/// exists.
async fn is_expired(path: &'static str) -> Result<bool> {
match parse_expiry_timestamp(path).await? {
Some(expiry_timestamp) => {
// We can `unwrap()` since we're certain `UNIX_EPOCH` is in the past.
let current_timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if current_timestamp >= expiry_timestamp {
Ok(true)
} else {
Ok(false)
}
}
None => Ok(false),
}
}

#[context("failed to parse expiry timestamp from marker file")]
async fn parse_expiry_timestamp(path: &'static str) -> Result<Option<u64>> {
let marker_json: Result<FinalizationMarker> = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let json = serde_json::from_reader(reader).context("failed to parse JSON content")?;
Ok(json)
})
.await?;

Ok(marker_json?.allow_until)
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use std::io::BufWriter;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::tempdir;
use tokio::runtime as rt;

#[test]
fn test_marker_file_allow_finalization() {
lazy_static::lazy_static! {
static ref TEMPDIR_MARKER_FILE_PATH: String = {
let p: PathBuf = tempdir().unwrap().into_path().join("allowfinalize.json");
p.into_os_string().into_string().unwrap()
};
}
let json = json!({});
let f = fs::File::create(&*TEMPDIR_MARKER_FILE_PATH).unwrap();
serde_json::to_writer(BufWriter::new(f), &json).unwrap();

// This should pass since default file permissions are 644 and we don't check
// for ownership by root.
let runtime = rt::Runtime::new().unwrap();
let can_finalize =
StrategyMarkerFile::marker_file_allow_finalization(&TEMPDIR_MARKER_FILE_PATH);
let can_finalize = runtime.block_on(can_finalize).unwrap();
assert!(can_finalize);

// Set permissions to writable by other; expect an error.
fs::set_permissions(
&*TEMPDIR_MARKER_FILE_PATH,
fs::Permissions::from_mode(0o777),
)
.unwrap();
let can_finalize =
StrategyMarkerFile::marker_file_allow_finalization(&TEMPDIR_MARKER_FILE_PATH);
runtime
.block_on(can_finalize)
.expect_err("file with incorrect permissions unexpectedly allowed finalization");
}

#[test]
fn test_parse_finalization_marker() {
lazy_static::lazy_static! {
static ref TEMPDIR_MARKER_FILE_PATH: String = {
let p: PathBuf = tempdir().unwrap().into_path().join("allowfinalize.json");
p.into_os_string().into_string().unwrap()
};
}
// 1619640863 is Apr 28 2021 20:14:23 UTC.
// Expect this to be expired.
let json = json!({
"allowUntil": 1619640863
});
let f = fs::File::create(&*TEMPDIR_MARKER_FILE_PATH).unwrap();
serde_json::to_writer(BufWriter::new(f), &json).unwrap();
let expired = is_expired(&TEMPDIR_MARKER_FILE_PATH);
let runtime = rt::Runtime::new().unwrap();
let expired = runtime.block_on(expired).unwrap();
assert!(expired);

// Expect timepstamp with value `u64::MAX` to not be expired.
let json = json!({ "allowUntil": u64::MAX });
let f = fs::File::create(&*TEMPDIR_MARKER_FILE_PATH).unwrap();
serde_json::to_writer(BufWriter::new(f), &json).unwrap();
let expired = is_expired(&TEMPDIR_MARKER_FILE_PATH);
let expired = runtime.block_on(expired).unwrap();
assert!(!expired);

// If no `allowUntil` field, marker file should not expire.
let json = json!({});
let f = fs::File::create(&*TEMPDIR_MARKER_FILE_PATH).unwrap();
serde_json::to_writer(BufWriter::new(f), &json).unwrap();
let expired = is_expired(&TEMPDIR_MARKER_FILE_PATH);
let expired = runtime.block_on(expired).unwrap();
assert!(!expired);

// Improper JSON.
let json = "allowUntil=1619640863";
fs::write(&*TEMPDIR_MARKER_FILE_PATH, json).unwrap();
let expired = is_expired(&TEMPDIR_MARKER_FILE_PATH);
runtime
.block_on(expired)
.expect_err("improper JSON unexpectedly parsed without error");
}
}
15 changes: 15 additions & 0 deletions src/strategy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub(crate) use immediate::StrategyImmediate;
mod periodic;
pub(crate) use periodic::StrategyPeriodic;

mod marker_file;
pub(crate) use marker_file::StrategyMarkerFile;

/// Label for allow responses from querying strategy's `can_finalize` function.
pub static CAN_FINALIZE_ALLOW_LABEL: &str = "allow";

Expand Down Expand Up @@ -51,6 +54,7 @@ pub(crate) enum UpdateStrategy {
FleetLock(StrategyFleetLock),
Immediate(StrategyImmediate),
Periodic(StrategyPeriodic),
MarkerFile(StrategyMarkerFile),
}

impl UpdateStrategy {
Expand All @@ -62,6 +66,7 @@ impl UpdateStrategy {
StrategyFleetLock::LABEL => UpdateStrategy::new_fleet_lock(cfg, identity)?,
StrategyImmediate::LABEL => UpdateStrategy::new_immediate(),
StrategyPeriodic::LABEL => UpdateStrategy::new_periodic(cfg)?,
StrategyMarkerFile::LABEL => UpdateStrategy::new_marker_file(),
"" => UpdateStrategy::default(),
x => anyhow::bail!("unsupported strategy '{}'", x),
};
Expand Down Expand Up @@ -97,6 +102,7 @@ impl UpdateStrategy {
UpdateStrategy::FleetLock(_) => StrategyFleetLock::LABEL,
UpdateStrategy::Immediate(_) => StrategyImmediate::LABEL,
UpdateStrategy::Periodic(_) => StrategyPeriodic::LABEL,
UpdateStrategy::MarkerFile(_) => StrategyMarkerFile::LABEL,
}
}

Expand All @@ -108,6 +114,7 @@ impl UpdateStrategy {
UpdateStrategy::Periodic(p) => {
format!("{}, {}", self.configuration_label(), p.calendar_summary(),)
}
UpdateStrategy::MarkerFile(_) => self.configuration_label().to_string(),
}
}

Expand All @@ -117,6 +124,7 @@ impl UpdateStrategy {
UpdateStrategy::FleetLock(s) => s.can_finalize(),
UpdateStrategy::Immediate(s) => s.can_finalize(),
UpdateStrategy::Periodic(s) => s.can_finalize(),
UpdateStrategy::MarkerFile(s) => s.can_finalize(),
};

async {
Expand Down Expand Up @@ -150,6 +158,7 @@ impl UpdateStrategy {
UpdateStrategy::FleetLock(s) => s.report_steady(),
UpdateStrategy::Immediate(s) => s.report_steady(),
UpdateStrategy::Periodic(s) => s.report_steady(),
UpdateStrategy::MarkerFile(s) => s.report_steady(),
};

async {
Expand Down Expand Up @@ -177,6 +186,12 @@ impl UpdateStrategy {
let periodic = StrategyPeriodic::new(cfg)?;
Ok(UpdateStrategy::Periodic(periodic))
}

/// Build a new "filesystem" strategy.
fn new_marker_file() -> Self {
let marker_file = StrategyMarkerFile::default();
UpdateStrategy::MarkerFile(marker_file)
}
}

impl Default for UpdateStrategy {
Expand Down
Loading