Skip to content
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
33 changes: 33 additions & 0 deletions .github/workflows/nfstest-factory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ jobs:
sed -i '/imagePullPolicy/d' deploy/k8s/deployment.yaml
# Add imagePullPolicy: Never for local image
sed -i 's|image: arcticwolf:test|image: arcticwolf:test\n imagePullPolicy: Never|g' deploy/k8s/deployment.yaml
# Enable folder quota with a bootstrap entry so the quota smoke
# test below has something to enforce against. The entry matches
# tests/test_nfs_quota.sh defaults (PVC_NAME=pvc-quota-test,
# QUOTA_BYTES=1048576).
python3 - <<'PY'
from pathlib import Path
p = Path("deploy/k8s/deployment.yaml")
text = p.read_text()
marker = ' [logging]\n level = "debug"'
addition = (
'\n'
' [quota]\n'
' enabled = true\n'
' db_path = "/tmp/.arcticwolf-quota.db"\n'
'\n'
' [quota.bootstrap]\n'
' "pvc-quota-test" = "1MB"\n'
)
assert marker in text, "deployment.yaml logging block not found"
p.write_text(text.replace(marker, marker + addition))
PY
kubectl apply -f deploy/k8s/deployment.yaml
kubectl wait --for=condition=Ready pod -l app=arcticwolf --timeout=120s

Expand All @@ -68,6 +89,18 @@ jobs:
echo "NFS mounted successfully"
ls -la /mnt/nfs/

# Exercise folder quota against the live mount before running the
# broader POSIX suite. The quota directory (pvc-quota-test) is
# isolated from the paths nfstest_posix touches, so the two suites
# do not interfere.
- name: Run folder quota smoke test
run: |
sudo env \
MOUNT_POINT=/mnt/nfs \
PVC_NAME=pvc-quota-test \
QUOTA_BYTES=1048576 \
bash tests/test_nfs_quota.sh

# skip test open until we start to review the failures
# read/write tests are mandatory and must pass
- name: Run NFS POSIX tests
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ clap = { version = "4", features = ["derive"] }
# XDR serialization (runtime)
xdr-codec = "0.4"

# Embedded KV store for quota persistence
redb = "3"

[dev-dependencies]
tempfile = "3"

Expand Down
18 changes: 18 additions & 0 deletions arcticwolf.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,21 @@ export_path = "/tmp/nfs_exports"
[logging]
# Log level: "error", "warn", "info", "debug", "trace" (default: "info")
level = "info"

[quota]
# Enable folder quota enforcement (default: false)
# When enabled, first-level subdirectories under export_path may be
# assigned a byte limit; writes past the limit return NFS3ERR_DQUOT.
enabled = false
# redb database storing quota limits and tracked usage.
# The parent directory is created at startup if missing.
db_path = "/var/lib/arcticwolf/quota.db"

# Optional declarative bootstrap. Each entry sets a quota on the first-
# level subdirectory at startup, but only if the directory has no quota
# entry yet. This keeps the bootstrap idempotent: editing it after initial
# rollout will not clobber the tracked usage.
#
# Sizes support suffixes B, KB, MB, GB, TB (1024-based).
# [quota.bootstrap]
# "pvc-example" = "10GB"
171 changes: 171 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

use clap::Parser;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

const DEFAULT_CONFIG_PATH: &str = "/etc/arcticwolf/config.toml";
const DEFAULT_QUOTA_DB_PATH: &str = "/var/lib/arcticwolf/quota.db";

#[derive(Parser, Debug)]
#[command(name = "arcticwolf")]
Expand All @@ -25,6 +27,7 @@ pub struct Config {
pub server: ServerConfig,
pub fsal: FsalConfig,
pub logging: LoggingConfig,
pub quota: QuotaConfig,
}

#[derive(Debug, Clone, Deserialize)]
Expand All @@ -48,6 +51,71 @@ pub struct LoggingConfig {
pub level: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct QuotaConfig {
/// Enable quota enforcement
pub enabled: bool,
/// Path to the redb database file storing quota limits and usage
pub db_path: PathBuf,
/// Declarative quota bootstrap: map of first-level subdirectory name
/// to a size string (e.g. "10GB"). Each entry is applied at startup
/// only when the directory has no existing quota entry, so the
/// bootstrap is idempotent and safe across restarts.
pub bootstrap: HashMap<String, String>,
}

impl Default for QuotaConfig {
fn default() -> Self {
Self {
enabled: false,
db_path: PathBuf::from(DEFAULT_QUOTA_DB_PATH),
bootstrap: HashMap::new(),
}
}
}

/// Parse a human-readable size string into bytes
///
/// Supported suffixes (case-insensitive): B, KB, MB, GB, TB
/// Uses 1024-based units (KiB/MiB/GiB/TiB semantics). Plain numbers are treated as bytes.
///
/// # Examples
/// - "1024" -> 1024
/// - "10KB" -> 10240
/// - "5MB" -> 5242880
/// - "2GB" -> 2147483648
#[allow(dead_code)]
pub fn parse_size(s: &str) -> anyhow::Result<u64> {
let trimmed = s.trim();
if trimmed.is_empty() {
anyhow::bail!("Empty size string");
}

let upper = trimmed.to_uppercase();
let (num_part, multiplier): (&str, u64) = if let Some(prefix) = upper.strip_suffix("TB") {
(prefix, 1024u64.pow(4))
} else if let Some(prefix) = upper.strip_suffix("GB") {
(prefix, 1024u64.pow(3))
} else if let Some(prefix) = upper.strip_suffix("MB") {
(prefix, 1024u64.pow(2))
} else if let Some(prefix) = upper.strip_suffix("KB") {
(prefix, 1024)
} else if let Some(prefix) = upper.strip_suffix('B') {
(prefix, 1)
} else {
(upper.as_str(), 1)
};

let num_str = num_part.trim();
let num: u64 = num_str
.parse()
.map_err(|e| anyhow::anyhow!("Invalid size '{}': {}", s, e))?;

num.checked_mul(multiplier)
.ok_or_else(|| anyhow::anyhow!("Size overflow: {}", s))
}

impl Default for ServerConfig {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -227,4 +295,107 @@ mod tests {
let result: Result<Config, _> = toml::from_str("this is not valid toml [[[");
assert!(result.is_err());
}

#[test]
fn test_quota_config_default() {
let config = QuotaConfig::default();
assert!(!config.enabled);
assert_eq!(config.db_path, PathBuf::from(DEFAULT_QUOTA_DB_PATH));
}

#[test]
fn test_config_default_includes_quota() {
let config = Config::default();
assert!(!config.quota.enabled);
}

#[test]
fn test_parse_quota_toml() {
let toml = r#"
[quota]
enabled = true
db_path = "/tmp/quota.db"
"#;

let config: Config = toml::from_str(toml).expect("Failed to parse TOML");
assert!(config.quota.enabled);
assert_eq!(config.quota.db_path, PathBuf::from("/tmp/quota.db"));
assert!(config.quota.bootstrap.is_empty());
}

#[test]
fn test_parse_quota_bootstrap_toml() {
let toml = r#"
[quota]
enabled = true
db_path = "/tmp/quota.db"

[quota.bootstrap]
"pvc-a" = "10GB"
"pvc-b" = "1MB"
"#;

let config: Config = toml::from_str(toml).expect("Failed to parse TOML");
assert_eq!(config.quota.bootstrap.len(), 2);
assert_eq!(
config.quota.bootstrap.get("pvc-a"),
Some(&"10GB".to_string())
);
assert_eq!(
config.quota.bootstrap.get("pvc-b"),
Some(&"1MB".to_string())
);
}

#[test]
fn test_parse_size_bytes() {
assert_eq!(parse_size("0").unwrap(), 0);
assert_eq!(parse_size("1").unwrap(), 1);
assert_eq!(parse_size("1024").unwrap(), 1024);
assert_eq!(parse_size("512B").unwrap(), 512);
}

#[test]
fn test_parse_size_kb() {
assert_eq!(parse_size("1KB").unwrap(), 1024);
assert_eq!(parse_size("10KB").unwrap(), 10 * 1024);
assert_eq!(parse_size("1kb").unwrap(), 1024); // case-insensitive
}

#[test]
fn test_parse_size_mb() {
assert_eq!(parse_size("1MB").unwrap(), 1024 * 1024);
assert_eq!(parse_size("5MB").unwrap(), 5 * 1024 * 1024);
}

#[test]
fn test_parse_size_gb() {
assert_eq!(parse_size("1GB").unwrap(), 1024u64.pow(3));
assert_eq!(parse_size("10GB").unwrap(), 10 * 1024u64.pow(3));
}

#[test]
fn test_parse_size_tb() {
assert_eq!(parse_size("1TB").unwrap(), 1024u64.pow(4));
assert_eq!(parse_size("2TB").unwrap(), 2 * 1024u64.pow(4));
}

#[test]
fn test_parse_size_whitespace() {
assert_eq!(parse_size(" 10MB ").unwrap(), 10 * 1024 * 1024);
}

#[test]
fn test_parse_size_invalid() {
assert!(parse_size("").is_err());
assert!(parse_size("abc").is_err());
assert!(parse_size("10XB").is_err());
assert!(parse_size("-5MB").is_err());
}

#[test]
fn test_parse_size_overflow() {
// u64::MAX / 1024^4 is ~16 million TB
assert!(parse_size("99999999999999999TB").is_err());
}
}
Loading
Loading