Skip to content

Commit fa5c167

Browse files
committed
test: add folder quota integration test and bootstrap config
Stage 7 rounds out the folder quota feature with declarative bootstrap and an end-to-end smoke test that drives a real kernel NFS client. QuotaConfig gains a [quota.bootstrap] TOML block mapping first-level directory names to size strings. QuotaManager::apply_bootstrap installs each entry only when the target directory has no existing quota record, so the block is idempotent across restarts — editing it does not clobber tracked usage. The main binary applies the bootstrap after opening the FSAL and before accepting traffic; the operation is exposed through the Filesystem trait with a default no-op so non-quota backends are unaffected. tests/test_nfs_quota.sh mounts the NFS export and exercises the four quota scenarios end-to-end: under-limit write, over-limit write that must return EDQUOT, remove-then-write, and truncate-then-write. It also checks that `df` reports the quota limit as total bytes. The nfstest-factory CI workflow now patches the deployment ConfigMap to enable quota and bootstrap a 1MB limit on pvc-quota-test before running the smoke test, then runs nfstest_posix against the same mount — POSIX tests touch root-level paths only, so the two suites do not interfere. arcticwolf.example.toml documents the new [quota] section and the optional bootstrap block for operators. Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
1 parent 792437d commit fa5c167

8 files changed

Lines changed: 371 additions & 4 deletions

File tree

.github/workflows/nfstest-factory.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ jobs:
4545
sed -i '/imagePullPolicy/d' deploy/k8s/deployment.yaml
4646
# Add imagePullPolicy: Never for local image
4747
sed -i 's|image: arcticwolf:test|image: arcticwolf:test\n imagePullPolicy: Never|g' deploy/k8s/deployment.yaml
48+
# Enable folder quota with a bootstrap entry so the quota smoke
49+
# test below has something to enforce against. The entry matches
50+
# tests/test_nfs_quota.sh defaults (PVC_NAME=pvc-quota-test,
51+
# QUOTA_BYTES=1048576).
52+
python3 - <<'PY'
53+
from pathlib import Path
54+
p = Path("deploy/k8s/deployment.yaml")
55+
text = p.read_text()
56+
marker = ' [logging]\n level = "debug"'
57+
addition = (
58+
'\n'
59+
' [quota]\n'
60+
' enabled = true\n'
61+
' db_path = "/tmp/.arcticwolf-quota.db"\n'
62+
'\n'
63+
' [quota.bootstrap]\n'
64+
' "pvc-quota-test" = "1MB"\n'
65+
)
66+
assert marker in text, "deployment.yaml logging block not found"
67+
p.write_text(text.replace(marker, marker + addition))
68+
PY
4869
kubectl apply -f deploy/k8s/deployment.yaml
4970
kubectl wait --for=condition=Ready pod -l app=arcticwolf --timeout=120s
5071
@@ -68,6 +89,18 @@ jobs:
6889
echo "NFS mounted successfully"
6990
ls -la /mnt/nfs/
7091
92+
# Exercise folder quota against the live mount before running the
93+
# broader POSIX suite. The quota directory (pvc-quota-test) is
94+
# isolated from the paths nfstest_posix touches, so the two suites
95+
# do not interfere.
96+
- name: Run folder quota smoke test
97+
run: |
98+
sudo env \
99+
MOUNT_POINT=/mnt/nfs \
100+
PVC_NAME=pvc-quota-test \
101+
QUOTA_BYTES=1048576 \
102+
bash tests/test_nfs_quota.sh
103+
71104
# skip test open until we start to review the failures
72105
# read/write tests are mandatory and must pass
73106
- name: Run NFS POSIX tests

arcticwolf.example.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,21 @@ export_path = "/tmp/nfs_exports"
1919
[logging]
2020
# Log level: "error", "warn", "info", "debug", "trace" (default: "info")
2121
level = "info"
22+
23+
[quota]
24+
# Enable folder quota enforcement (default: false)
25+
# When enabled, first-level subdirectories under export_path may be
26+
# assigned a byte limit; writes past the limit return NFS3ERR_DQUOT.
27+
enabled = false
28+
# redb database storing quota limits and tracked usage.
29+
# The parent directory is created at startup if missing.
30+
db_path = "/var/lib/arcticwolf/quota.db"
31+
32+
# Optional declarative bootstrap. Each entry sets a quota on the first-
33+
# level subdirectory at startup, but only if the directory has no quota
34+
# entry yet. This keeps the bootstrap idempotent: editing it after initial
35+
# rollout will not clobber the tracked usage.
36+
#
37+
# Sizes support suffixes B, KB, MB, GB, TB (1024-based).
38+
# [quota.bootstrap]
39+
# "pvc-example" = "10GB"

src/config.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
use clap::Parser;
88
use serde::Deserialize;
9+
use std::collections::HashMap;
910
use std::path::PathBuf;
1011

1112
const DEFAULT_CONFIG_PATH: &str = "/etc/arcticwolf/config.toml";
@@ -57,13 +58,19 @@ pub struct QuotaConfig {
5758
pub enabled: bool,
5859
/// Path to the redb database file storing quota limits and usage
5960
pub db_path: PathBuf,
61+
/// Declarative quota bootstrap: map of first-level subdirectory name
62+
/// to a size string (e.g. "10GB"). Each entry is applied at startup
63+
/// only when the directory has no existing quota entry, so the
64+
/// bootstrap is idempotent and safe across restarts.
65+
pub bootstrap: HashMap<String, String>,
6066
}
6167

6268
impl Default for QuotaConfig {
6369
fn default() -> Self {
6470
Self {
6571
enabled: false,
6672
db_path: PathBuf::from(DEFAULT_QUOTA_DB_PATH),
73+
bootstrap: HashMap::new(),
6774
}
6875
}
6976
}
@@ -313,6 +320,31 @@ mod tests {
313320
let config: Config = toml::from_str(toml).expect("Failed to parse TOML");
314321
assert!(config.quota.enabled);
315322
assert_eq!(config.quota.db_path, PathBuf::from("/tmp/quota.db"));
323+
assert!(config.quota.bootstrap.is_empty());
324+
}
325+
326+
#[test]
327+
fn test_parse_quota_bootstrap_toml() {
328+
let toml = r#"
329+
[quota]
330+
enabled = true
331+
db_path = "/tmp/quota.db"
332+
333+
[quota.bootstrap]
334+
"pvc-a" = "10GB"
335+
"pvc-b" = "1MB"
336+
"#;
337+
338+
let config: Config = toml::from_str(toml).expect("Failed to parse TOML");
339+
assert_eq!(config.quota.bootstrap.len(), 2);
340+
assert_eq!(
341+
config.quota.bootstrap.get("pvc-a"),
342+
Some(&"10GB".to_string())
343+
);
344+
assert_eq!(
345+
config.quota.bootstrap.get("pvc-b"),
346+
Some(&"1MB".to_string())
347+
);
316348
}
317349

318350
#[test]

src/fsal/local/mod.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,22 @@ impl Filesystem for LocalFilesystem {
11181118
tracing::info!("Quota reconciliation task finished");
11191119
});
11201120
}
1121+
1122+
async fn apply_quota_bootstrap(
1123+
&self,
1124+
bootstrap: &std::collections::HashMap<String, String>,
1125+
) -> Result<()> {
1126+
let Some(ref qm) = self.quota_manager else {
1127+
if !bootstrap.is_empty() {
1128+
tracing::warn!(
1129+
"Quota bootstrap requested but quota is disabled; ignoring {} entries",
1130+
bootstrap.len()
1131+
);
1132+
}
1133+
return Ok(());
1134+
};
1135+
qm.apply_bootstrap(bootstrap).await
1136+
}
11211137
}
11221138

11231139
/// Logically normalize a path: collapse `.` and `..` components and
@@ -1609,6 +1625,7 @@ mod tests {
16091625
let quota_cfg = QuotaConfig {
16101626
enabled: true,
16111627
db_path: db_dir.path().join("quota.db"),
1628+
bootstrap: std::collections::HashMap::new(),
16121629
};
16131630
let fs = LocalFilesystem::new(export_dir.path(), Some(&quota_cfg))
16141631
.expect("Failed to create filesystem with quota");
@@ -1631,6 +1648,7 @@ mod tests {
16311648
let quota_cfg = QuotaConfig {
16321649
enabled: false,
16331650
db_path: db.path().join("quota.db"),
1651+
bootstrap: std::collections::HashMap::new(),
16341652
};
16351653
let fs = LocalFilesystem::new(export.path(), Some(&quota_cfg)).unwrap();
16361654
assert!(
@@ -1654,6 +1672,7 @@ mod tests {
16541672
let cfg = QuotaConfig {
16551673
enabled: true,
16561674
db_path: export.path().join("quota.db"),
1675+
bootstrap: std::collections::HashMap::new(),
16571676
};
16581677
match LocalFilesystem::new(export.path(), Some(&cfg)) {
16591678
Ok(_) => panic!("LocalFilesystem::new should reject db inside export"),
@@ -1672,6 +1691,7 @@ mod tests {
16721691
let cfg = QuotaConfig {
16731692
enabled: true,
16741693
db_path: nested.join("quota.db"),
1694+
bootstrap: std::collections::HashMap::new(),
16751695
};
16761696
match LocalFilesystem::new(export.path(), Some(&cfg)) {
16771697
Ok(_) => panic!("nested-under-root db path should be rejected"),
@@ -1690,6 +1710,7 @@ mod tests {
16901710
let cfg = QuotaConfig {
16911711
enabled: true,
16921712
db_path: db_dir.path().join("quota.db"),
1713+
bootstrap: std::collections::HashMap::new(),
16931714
};
16941715
let fs = LocalFilesystem::new(export.path(), Some(&cfg))
16951716
.expect("db outside export should be accepted");
@@ -1707,6 +1728,7 @@ mod tests {
17071728
let cfg = QuotaConfig {
17081729
enabled: true,
17091730
db_path: sneaky,
1731+
bootstrap: std::collections::HashMap::new(),
17101732
};
17111733
match LocalFilesystem::new(export.path(), Some(&cfg)) {
17121734
Ok(_) => panic!("dotdot-relative db inside export should be rejected"),
@@ -2209,6 +2231,30 @@ mod tests {
22092231
fs.start_quota_reconciliation();
22102232
}
22112233

2234+
#[tokio::test]
2235+
async fn test_apply_quota_bootstrap_seeds_new_entries() {
2236+
let (fs, _export, _db) = create_test_fs_with_quota();
2237+
let mut bootstrap = std::collections::HashMap::new();
2238+
bootstrap.insert("pvc-a".to_string(), "1MB".to_string());
2239+
2240+
fs.apply_quota_bootstrap(&bootstrap).await.unwrap();
2241+
assert_eq!(
2242+
fs.quota_manager().unwrap().get_quota_info("pvc-a").await,
2243+
Some((1024 * 1024, 0))
2244+
);
2245+
}
2246+
2247+
#[tokio::test]
2248+
async fn test_apply_quota_bootstrap_noop_when_quota_disabled() {
2249+
let (fs, _export) = create_test_fs();
2250+
let mut bootstrap = std::collections::HashMap::new();
2251+
bootstrap.insert("pvc-a".to_string(), "1MB".to_string());
2252+
2253+
// Should not error even though no quota manager is configured;
2254+
// the entries are logged and discarded.
2255+
fs.apply_quota_bootstrap(&bootstrap).await.unwrap();
2256+
}
2257+
22122258
#[tokio::test]
22132259
async fn test_rename_across_quota_dirs_rejected_when_target_full() {
22142260
let (fs, _export, _db) = create_test_fs_with_quota();

src/fsal/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod quota;
1818
use crate::config::QuotaConfig;
1919
use anyhow::Result;
2020
use async_trait::async_trait;
21+
use std::collections::HashMap;
2122
use std::path::PathBuf;
2223

2324
#[allow(unused_imports)]
@@ -346,6 +347,17 @@ pub trait Filesystem: Send + Sync {
346347
/// The default implementation does nothing. Backends that support
347348
/// quotas override this to spawn their own task.
348349
fn start_quota_reconciliation(&self) {}
350+
351+
/// Apply a declarative quota bootstrap at startup. Entries install
352+
/// a quota only when the target directory has none yet; already
353+
/// tracked directories are left alone so the bootstrap is
354+
/// idempotent across restarts.
355+
///
356+
/// The default implementation does nothing. Backends that support
357+
/// quotas override this.
358+
async fn apply_quota_bootstrap(&self, _bootstrap: &HashMap<String, String>) -> Result<()> {
359+
Ok(())
360+
}
349361
}
350362

351363
/// Filesystem backend types

0 commit comments

Comments
 (0)