use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex, MutexGuard};
use anyhow::{Context, Result};
use gunnar_core::{AuthTable, ControlStore, HashKind, NewRepo, Repo, RepoCatalog, RepoRef};
use gunnar_ssh::Control;
use crate::storage::OpenRepo;
pub const CONTROL_DIR: &str = "control.git";
pub const CONTROL_DB: &str = "control.redb";
pub const REPOS_DIR: &str = "repos";
pub const TXLOG_DIR: &str = "txlog";
pub const IDENTITY_MIRROR: &str = "identity.json";
pub const CONTROL_HASH_KIND: gix_hash::Kind = gix_hash::Kind::Sha256;
pub const DEFAULT_OPEN_REPOS: usize = 256;
pub const RECLAIM_FLOOR_PERCENT: u64 = 25;
pub const RECLAIM_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
pub const DEFAULT_DELETE_RETENTION_DAYS: u32 = 30;
pub const TOMBSTONE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3600);
pub const FLOW_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TombstoneReason {
Deleted,
ReclaimFailed,
}
impl TombstoneReason {
pub fn as_str(self) -> &'static str {
match self {
TombstoneReason::Deleted => "soft-deleted",
TombstoneReason::ReclaimFailed => "reclaim failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Tombstone {
pub deleted_at_unix_ms: i64,
pub purge_after_unix_ms: Option<i64>,
#[serde(default)]
pub principal_id: String,
pub reason: TombstoneReason,
#[serde(default)]
pub repo: Option<Repo>,
#[serde(default)]
pub note: String,
}
impl Tombstone {
pub fn parse(text: &str) -> Tombstone {
if let Ok(parsed) = serde_json::from_str::<Tombstone>(text) {
return parsed;
}
Tombstone {
deleted_at_unix_ms: 0,
purge_after_unix_ms: None,
principal_id: String::new(),
reason: TombstoneReason::ReclaimFailed,
repo: None,
note: text.trim().to_owned(),
}
}
pub fn is_expired_at(&self, now_unix_ms: i64) -> bool {
match self.purge_after_unix_ms {
Some(at) => now_unix_ms >= at,
None => false,
}
}
pub fn seconds_left(&self, now_unix_ms: i64) -> Option<i64> {
self.purge_after_unix_ms
.map(|at| (at - now_unix_ms).max(0) / 1000)
}
}
#[derive(Debug, Clone)]
pub struct DeletedRepo {
pub path: RepoRef,
pub tombstone: Tombstone,
pub bytes: u64,
}
#[derive(Debug, Clone)]
pub struct OrphanStore {
pub path: RepoRef,
pub dir: PathBuf,
pub bytes: u64,
pub hash_marker: Option<String>,
pub modified_unix_ms: i64,
pub failed_reclaim: Option<String>,
}
struct HandleCache {
capacity: usize,
entries: HashMap<String, (Arc<OpenRepo>, u64)>,
clock: u64,
}
impl HandleCache {
fn new(capacity: usize) -> Self {
HandleCache {
capacity: capacity.max(1),
entries: HashMap::new(),
clock: 0,
}
}
fn set_capacity(&mut self, capacity: usize) -> usize {
let before = self.entries.len();
self.capacity = capacity.max(1);
self.evict();
before.saturating_sub(self.entries.len())
}
fn touch(&mut self, key: &str) -> Option<Arc<OpenRepo>> {
self.clock += 1;
let clock = self.clock;
let (store, used) = self.entries.get_mut(key)?;
*used = clock;
Some(Arc::clone(store))
}
fn insert(&mut self, key: String, store: Arc<OpenRepo>) -> Arc<OpenRepo> {
if let Some(existing) = self.touch(&key) {
return existing;
}
self.clock += 1;
let clock = self.clock;
self.entries.insert(key, (Arc::clone(&store), clock));
self.evict();
store
}
fn evict(&mut self) {
while self.entries.len() > self.capacity {
let victim = self
.entries
.iter()
.filter(|(_, (store, _))| Arc::strong_count(store) == 1)
.min_by_key(|(_, (_, used))| *used)
.map(|(key, _)| key.clone());
match victim {
Some(key) => {
self.entries.remove(&key);
}
None => {
tracing::warn!(
open = self.entries.len(),
capacity = self.capacity,
"every open repository handle is in use; the cache is over its bound \
until one of them is released"
);
return;
}
}
}
}
}
pub struct ServerStores {
identity: std::sync::OnceLock<std::sync::Weak<gunnar_grpc::identity::GunnarIdentity>>,
root: PathBuf,
repos_root: PathBuf,
control_dir: PathBuf,
engine: crate::storage::Engine,
secrets: Arc<dyn gunnar_store::SecretStore>,
legacy_secrets: bool,
kek: std::sync::RwLock<gunnar_core::Kek>,
control_store: Arc<dyn ControlStore>,
control_db: Arc<gunnar_core::ControlDb>,
suspended: AtomicBool,
txlog: Arc<gunnar_core::txlog::Log>,
twin: Arc<AtomicBool>,
snapshot_pins: SnapshotPins,
raw_control_store: Arc<dyn ControlStore>,
raw_secrets: Arc<dyn gunnar_store::SecretStore>,
auth: AuthTable,
catalog: RepoCatalog,
policies: gunnar_core::PolicyStore,
mirrors: gunnar_core::MirrorStore,
flow: Arc<gunnar_core::FlowLedger>,
activity: Arc<gunnar_core::PrincipalActivity>,
usage: Arc<crate::usage_ledger::UsageLedger>,
metrics: Arc<gunnar_core::observe::ServerMetrics>,
account_audit: Arc<gunnar_core::account_audit::AccountAuditLog>,
names: gunnar_core::name_filter::NameFilterStore,
settings: gunnar_core::settings::SettingsStore,
encryption_keys: gunnar_core::encryption_key::EncryptionKeys,
open: Mutex<HandleCache>,
last_reclaim_probe: Mutex<Option<std::time::Instant>>,
writes_frozen: Arc<AtomicBool>,
browse: Arc<AtomicBool>,
store_opens: Arc<AtomicU64>,
slow: crate::slow::SlowRoot,
install: crate::layout_config::Load,
plan_gates: std::sync::RwLock<Vec<Arc<dyn gunnar_ssh::PlanGate>>>,
space: std::sync::RwLock<Option<Arc<gunnar_ssh::SpaceLedger>>>,
fed_keys: std::sync::RwLock<std::collections::BTreeMap<String, String>>,
}
pub const SNAPSHOT_PIN_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
#[derive(Default)]
struct SnapshotPins {
count: Mutex<usize>,
cv: std::sync::Condvar,
}
#[must_use = "the pin holds only while the guard lives"]
pub struct SnapshotPin<'a> {
pins: &'a SnapshotPins,
}
impl Drop for SnapshotPin<'_> {
fn drop(&mut self) {
let mut n = self.pins.count.lock().unwrap_or_else(|p| p.into_inner());
*n = n.saturating_sub(1);
self.pins.cv.notify_all();
}
}
impl std::fmt::Debug for ServerStores {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerStores")
.field("root", &self.root)
.finish_non_exhaustive()
}
}
impl ServerStores {
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
Self::open_with_capacity(root, crate::budget::budgets().open_repos)
}
pub fn open_with_capacity(root: impl AsRef<Path>, capacity: usize) -> Result<Self> {
Self::open_with_engine(root, capacity, crate::storage::Engine::selected()?)
}
pub fn open_with_engine(
root: impl AsRef<Path>,
capacity: usize,
engine: crate::storage::Engine,
) -> Result<Self> {
let root = root.as_ref().to_path_buf();
let repos_root = root.join(REPOS_DIR);
std::fs::create_dir_all(&repos_root)
.with_context(|| format!("creating the repository root {}", repos_root.display()))?;
let control_dir = root.join(CONTROL_DIR);
std::fs::create_dir_all(&control_dir)
.with_context(|| format!("creating the control directory {}", control_dir.display()))?;
let install = crate::layout_config::load();
let slow = crate::slow::SlowRoot::resolve(&root, install.config());
slow.announce(
&crate::layout_config::config_path().display().to_string(),
install
.config()
.map(|c| c.authenticity().word())
.unwrap_or_default(),
);
if let Some(why) = install.absent_reason() {
tracing::info!(
target: crate::slow::TARGET_SLOW,
event = "layout.absent",
"{why}"
);
}
match slow.crash_dir() {
Ok(dir) => crate::slow::register_crash_dir(dir),
Err(err) => tracing::warn!(
target: crate::slow::TARGET_SLOW,
%err,
"no crash directory on the slow disk; a panic will reach the console but \
will not be written down"
),
}
let kek = gunnar_core::Kek::for_control_dir(&control_dir).with_context(|| {
format!(
"resolving the key-encrypting key for {}; secrets are sealed at rest",
control_dir.display()
)
})?;
let db = root.join(CONTROL_DB);
let (raw_control_store, redb_secrets, control_db) = gunnar_core::open_control_with_db(&db)
.with_context(|| format!("opening the control store {}", db.display()))?;
let txlog_dir = root.join(TXLOG_DIR);
let txlog = Arc::new(
gunnar_core::txlog::Log::open(&txlog_dir)
.with_context(|| format!("opening the transaction log {}", txlog_dir.display()))?,
);
let twin = crate::twin::is_twin_root(&root);
let restoring = crate::twin::is_restoring(&root);
if restoring && !twin {
tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
event = "twin.restoring_at_open",
"THIS BOX IS MID-RESTORE (`txlog-restoring` is present): it originates no \
transactions, so every control-plane write is refused, until a restore finishes. \
Resend the twin's log with `gunnar control twin replay`; a finished restore that \
still says this lost its marker's removal to a power cut"
);
}
let fenced = !twin && !restoring && crate::twin::is_fenced(&root);
if fenced {
tracing::error!(
target: gunnar_core::observe::TARGET_TWIN,
event = "twin.fenced_by_generation",
"THIS BOX IS FENCED: {}. Every write is refused and no twin is served its log.",
crate::twin::fenced_why(&root).unwrap_or_default()
);
}
if twin || restoring || fenced {
txlog.set_follower(true);
} else {
crate::twin::mint_log_id(&root).context("minting this log's identity")?;
if txlog.head_seq() == 0 {
let bytes = hex::decode(kek.to_hex()).context("the KEK as bytes")?;
txlog
.append(
gunnar_core::txlog::Kind::Kek,
&gunnar_core::txlog::Meta::Kek {},
&bytes,
)
.context("framing the KEK as the log's first transaction")?;
}
gunnar_core::txlog::sink::register(&root, &txlog);
}
let control_store: Arc<dyn ControlStore> =
Arc::new(gunnar_core::txlog::tee::LoggedControlStore::new(
Arc::clone(&raw_control_store),
Arc::clone(&txlog),
));
let legacy =
gunnar_core::legacy_secret_log(&control_dir, CONTROL_HASH_KIND).with_context(|| {
format!(
"checking {} for a legacy Arrow secret log to carry the SSH host key \
forward from",
control_dir.display()
)
})?;
let legacy_secrets = legacy.is_some();
let raw_secrets: Arc<dyn gunnar_store::SecretStore> =
Arc::new(gunnar_core::MigratingSecretStore::new(redb_secrets, legacy));
let just_restored = !twin && !restoring && crate::twin::reseal_pending(&root);
let kek = if just_restored {
let rows = control_db
.export()
.context("reading the secrets to re-seal")?
.secrets;
crate::twin::reseal_after_restore(
&root,
&control_dir,
&txlog,
&rows,
raw_secrets.as_ref(),
)
.context("re-sealing a restored box's secrets under its own KEK")?
} else {
kek
};
if !twin && !restoring && !fenced {
adopt_hub_conf(&root, &txlog);
}
let secrets: Arc<dyn gunnar_store::SecretStore> =
Arc::new(gunnar_core::txlog::tee::LoggedSecretStore::new(
Arc::clone(&raw_secrets),
Arc::clone(&txlog),
));
let auth =
AuthTable::new(Arc::clone(&control_store)).context("opening the identity table")?;
let catalog = RepoCatalog::new(Arc::clone(&control_store))
.context("opening the repository catalog")?;
let policies = gunnar_core::PolicyStore::new(Arc::clone(&control_store))
.context("opening the policy store")?;
let mirrors = gunnar_core::MirrorStore::new(Arc::clone(&control_store))
.context("opening the mirror registry")?;
let account_audit = Arc::new(
gunnar_core::account_audit::AccountAuditLog::new(Arc::clone(&control_store))
.context("opening the account audit logs")?,
);
let names = gunnar_core::name_filter::NameFilterStore::new(Arc::clone(&control_store))
.context("opening the name filter")?;
let settings = gunnar_core::settings::SettingsStore::new(Arc::clone(&control_store))
.context("opening the server settings")?;
let encryption_keys =
gunnar_core::encryption_key::EncryptionKeys::new(Arc::clone(&control_store))
.context("opening people's encryption keys")?;
let flow = Arc::new(gunnar_core::FlowLedger::new(
gunnar_core::FlowStore::new(Arc::clone(&control_store))
.context("opening the flow ledger")?,
));
tracing::info!(
target: "gunnar::store",
event = "store.engine_selected",
engine = engine.name(),
compiled = %crate::storage::Engine::compiled_list(),
"the storage engine for this process"
);
let role_at_open = replication_role_at_open(&root);
let frozen_at_open = role_at_open.refuses_client_writes();
if frozen_at_open {
tracing::warn!(
target: "gunnar::store",
event = "store.role_standby",
"this box is a STANDBY (replication.json) — direct client pushes are refused; \
only the replication push from its primary writes. `gunnar control replication \
promote` makes it a writable primary."
);
}
if twin {
engine.hold_no_graph_memo();
engine.hold_twin_memory_bound();
}
let activity = Arc::new(gunnar_core::PrincipalActivity::open(
Arc::clone(&control_db),
Some(control_dir.join(gunnar_core::ACTIVITY_FILE)),
Some(Arc::clone(&txlog)),
));
if let Err(e) = start_activity_flush(&activity, gunnar_core::activity::FLUSH_EVERY) {
tracing::error!("ALERT {ACTIVITY_FLUSH} no flush ({e}): the last-seen stamps are written when the record is dropped only");
}
let stores = ServerStores {
identity: std::sync::OnceLock::new(),
root,
repos_root,
control_dir,
activity,
engine,
secrets,
legacy_secrets,
kek: std::sync::RwLock::new(kek),
control_store,
control_db,
suspended: AtomicBool::new(false),
raw_control_store,
raw_secrets,
txlog,
twin: Arc::new(AtomicBool::new(twin)),
auth,
catalog,
policies,
mirrors,
flow,
usage: Arc::new(crate::usage_ledger::UsageLedger::new()),
metrics: Arc::new(gunnar_core::observe::ServerMetrics::new()),
account_audit,
names,
settings,
encryption_keys,
open: Mutex::new(HandleCache::new(capacity)),
last_reclaim_probe: Mutex::new(None),
writes_frozen: Arc::new(AtomicBool::new(frozen_at_open || twin)),
browse: Arc::new(AtomicBool::new(role_at_open.is_browse() || twin)),
store_opens: Arc::new(AtomicU64::new(0)),
slow,
install,
plan_gates: std::sync::RwLock::new(Vec::new()),
space: std::sync::RwLock::new(None),
fed_keys: std::sync::RwLock::new(std::collections::BTreeMap::new()),
snapshot_pins: SnapshotPins::default(),
};
{
let usage = Arc::clone(&stores.usage);
stores
.flow
.observe(gunnar_core::FlowObserver(Arc::new(move |ns: &str| {
usage.mark_flow(ns)
})));
}
stores.mirror_identity();
if !twin && !restoring {
crate::twin::rematerialise_audit_trail(&stores, just_restored);
}
if stores.is_twin() {
crate::twin::forget_mirrors(&stores);
crate::twin::say_if_stores_were_applied(&stores);
}
Ok(stores)
}
pub fn await_enrolment(&self, keys: std::collections::BTreeMap<String, String>) {
*self.fed_keys.write().unwrap_or_else(|p| p.into_inner()) = keys;
}
pub fn awaiting_enrolment(&self, fingerprint: &str) -> Option<String> {
self.fed_keys
.read()
.unwrap_or_else(|p| p.into_inner())
.get(fingerprint)
.cloned()
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn identity(&self) -> Option<std::sync::Arc<gunnar_grpc::identity::GunnarIdentity>> {
self.identity.get().and_then(std::sync::Weak::upgrade)
}
pub fn set_identity(&self, identity: &std::sync::Arc<gunnar_grpc::identity::GunnarIdentity>) {
let _ = self.identity.set(std::sync::Arc::downgrade(identity));
}
pub fn slow(&self) -> &crate::slow::SlowRoot {
&self.slow
}
pub fn layout_config(&self) -> &crate::layout_config::Load {
&self.install
}
pub fn audit_path(&self) -> std::path::PathBuf {
if self.is_twin() || crate::twin::is_restoring(&self.root) {
let local = std::path::Path::new(gunnar_core::txlog::AUDIT_LOCAL_REL)
.file_name()
.expect("a file name")
.to_owned();
return match self.slow.log_dir() {
Ok(dir) => dir.join(local),
Err(_) => self.control_dir.join(local),
};
}
let old = self.control_dir.join(gunnar_core::AUDIT_FILE);
match self.slow.log_dir() {
Ok(dir) => crate::slow::adopt_file(&old, &dir.join(gunnar_core::AUDIT_FILE)),
Err(err) => {
tracing::warn!(
target: crate::slow::TARGET_SLOW,
%err,
path = %old.display(),
"no audit directory on the slow disk; the audit trail stays on /data, where \
it shares a failure domain with the repositories it records"
);
old
}
}
}
pub fn mirror_identity(&self) {
if self.is_twin() {
return;
}
if let Err(err) = self.try_mirror_identity() {
tracing::warn!(
target: crate::slow::TARGET_SLOW,
%err,
"the identity metadata mirror could not be refreshed; the control store itself \
is unaffected"
);
}
}
pub fn storage_reality(&self) -> gunnar_proto::StorageReality {
let mut out = gunnar_proto::StorageReality {
data_root: self.root.display().to_string(),
slow_root: self.slow.serialdat().to_owned(),
slow_root_source: self.slow.source().word().to_owned(),
backing_device: self.slow.device().to_owned(),
backing_class: self.slow.class().to_owned(),
off_stripe: self.slow.claim().word().to_owned(),
off_stripe_health: self.slow.health(),
..Default::default()
};
match self.install.config() {
Some(config) => {
out.record_present = true;
out.record_path = config.path().display().to_string();
out.record_version = config.version();
out.record_signature = config.authenticity().word().to_owned();
out.record_signature_verified = config.authenticity().is_verified();
out.record_signature_note = config.note().to_owned();
out.record_fields = config
.fields()
.map(|(key, value)| gunnar_proto::InstallRecordField {
key: key.to_owned(),
value: value.to_owned(),
})
.collect();
}
None => {
out.record_absent_reason = self
.install
.absent_reason()
.unwrap_or("there is no layout config")
.to_owned();
}
}
let mut measured: Vec<(&str, PathBuf)> = vec![("data root", self.root.clone())];
for p in crate::slow::PLACEMENT
.iter()
.filter(|p| p.requires_off_stripe)
{
let path = self.slow.path_of(p.dir);
if !measured.iter().any(|(_, seen)| *seen == path) {
measured.push(("configured directory", path));
}
}
out.filesystems = measured
.iter()
.map(|(what, path)| filesystem_use(what, path))
.collect();
out.placement = crate::slow::PLACEMENT
.iter()
.map(|p| gunnar_proto::PlacementRow {
what: p.what.to_owned(),
path: self.slow.path_of(p.dir).display().to_string(),
requires_off_stripe: p.requires_off_stripe,
off_stripe: if p.requires_off_stripe {
self.slow.claim_of(p.dir).word().to_owned()
} else {
String::new()
},
wired: p.wired,
note: p.note.to_owned(),
})
.collect();
let config = self.install.config();
if let Some(config) = config {
out.data_shape = config.get("data.shape").unwrap_or_default().to_owned();
out.data_devices = (0..config.count("data"))
.filter_map(|i| config.get(&format!("data.{i}.dev")).map(str::to_owned))
.collect();
}
let data_label = config
.and_then(|c| c.get("data.mountpoint"))
.map(str::to_owned)
.unwrap_or_else(|| out.data_root.clone());
let serialdat_label = config
.and_then(|c| c.get("serialdat.mountpoint"))
.map(str::to_owned)
.unwrap_or_else(|| out.slow_root.clone());
let disk = crate::disktest::last();
let data_fs = out
.filesystems
.iter()
.find(|f| f.what == "data root")
.cloned();
out.glance = crate::glance::lines(&crate::glance::Facts {
record_present: out.record_present,
data_label: &data_label,
data_shape: &out.data_shape,
data_devices: &out.data_devices,
serialdat_label: &serialdat_label,
serialdat_class: &out.backing_class,
serialdat_device: &out.backing_device,
off_stripe: &out.off_stripe,
off_stripe_health: &out.off_stripe_health,
data_fs: data_fs.as_ref(),
disk: disk.as_ref(),
});
out
}
fn try_mirror_identity(&self) -> Result<()> {
let principals = self.auth.principals().context("listing principals")?;
let keys = self.auth.keys().context("listing enrolled keys")?;
let written_unix_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default();
let doc = serde_json::json!({
"written_unix_ms": written_unix_ms,
"principals": principals,
"keys": keys,
});
let bytes = serde_json::to_vec_pretty(&doc).context("encoding the identity mirror")?;
let dir = self.slow.meta_dir()?;
crate::durable::write_atomic_unframed(&dir, IDENTITY_MIRROR, &bytes)
}
pub fn engine(&self) -> crate::storage::Engine {
self.engine
}
pub fn repos_root(&self) -> &Path {
&self.repos_root
}
pub fn activity(&self) -> &Arc<gunnar_core::PrincipalActivity> {
&self.activity
}
pub fn auth(&self) -> &AuthTable {
&self.auth
}
pub fn catalog(&self) -> &RepoCatalog {
&self.catalog
}
pub fn policies(&self) -> &gunnar_core::PolicyStore {
&self.policies
}
pub fn mirrors(&self) -> &gunnar_core::MirrorStore {
&self.mirrors
}
pub fn account_audit(&self) -> &Arc<gunnar_core::account_audit::AccountAuditLog> {
&self.account_audit
}
pub fn names(&self) -> &gunnar_core::name_filter::NameFilterStore {
&self.names
}
pub fn encryption_keys(&self) -> &gunnar_core::encryption_key::EncryptionKeys {
&self.encryption_keys
}
pub fn settings(&self) -> &gunnar_core::settings::SettingsStore {
&self.settings
}
pub fn usage_ledger(&self) -> &Arc<crate::usage_ledger::UsageLedger> {
&self.usage
}
pub fn metrics(&self) -> &Arc<gunnar_core::observe::ServerMetrics> {
&self.metrics
}
pub fn flow(&self) -> &Arc<gunnar_core::FlowLedger> {
&self.flow
}
pub fn snapshot_pin(&self) -> SnapshotPin<'_> {
*self
.snapshot_pins
.count
.lock()
.unwrap_or_else(|p| p.into_inner()) += 1;
SnapshotPin {
pins: &self.snapshot_pins,
}
}
pub fn wait_unpinned(&self, what: &str) {
let pins = &self.snapshot_pins;
let mut n = pins.count.lock().unwrap_or_else(|p| p.into_inner());
if *n == 0 {
return;
}
tracing::info!(target: "gunnar::store", what, snapshots = *n, "a snapshot is streaming from its cut: {what} waits for it");
let deadline = std::time::Instant::now() + SNAPSHOT_PIN_WAIT;
while *n > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
tracing::warn!(target: "gunnar::store", what, "a snapshot still streams after {} s: {what} goes on; that snapshot fails and is asked for again", SNAPSHOT_PIN_WAIT.as_secs());
return;
}
n = pins
.cv
.wait_timeout(n, left)
.unwrap_or_else(|p| p.into_inner())
.0;
}
}
pub fn txlog(&self) -> &Arc<gunnar_core::txlog::Log> {
&self.txlog
}
pub fn is_twin(&self) -> bool {
self.twin.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn twin_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.twin)
}
pub fn mark_twin(&self) {
self.twin.store(true, std::sync::atomic::Ordering::SeqCst);
self.engine.hold_no_graph_memo();
self.engine.hold_twin_memory_bound();
let freed = self.engine.bound_open_stores_to_twin();
if freed > 0 {
tracing::info!(
target: gunnar_core::observe::TARGET_TWIN,
bytes = freed,
"unlinked the exploded read cache of the repositories this box already held: a twin holds none"
);
}
}
pub fn secrets_unframed(
&self,
) -> Result<gunnar_core::SealedSecretStore<std::sync::Arc<dyn gunnar_store::SecretStore>>> {
Ok(gunnar_core::SealedSecretStore::new(
Arc::clone(&self.raw_secrets),
self.kek(),
))
}
fn kek(&self) -> gunnar_core::Kek {
self.kek
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn adopt_kek(&self, bytes: &[u8]) -> Result<()> {
let key: [u8; 32] = bytes
.try_into()
.map_err(|_| anyhow::anyhow!("a KEK carries {} bytes, not 32", bytes.len()))?;
*self
.kek
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = gunnar_core::Kek::from_bytes(key);
Ok(())
}
pub fn kek_bytes(&self) -> Vec<u8> {
hex::decode(self.kek().to_hex()).expect("a KEK's hex decodes")
}
pub fn raw_control_store(&self) -> &Arc<dyn ControlStore> {
&self.raw_control_store
}
pub fn raw_secrets(&self) -> &Arc<dyn gunnar_store::SecretStore> {
&self.raw_secrets
}
pub fn control_store(&self) -> &Arc<dyn ControlStore> {
&self.control_store
}
pub fn control_db(&self) -> Arc<gunnar_core::ControlDb> {
Arc::clone(&self.control_db)
}
pub fn suspended(&self) -> bool {
self.suspended.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn suspend_repos(&self, why: &str) {
self.suspended
.store(true, std::sync::atomic::Ordering::SeqCst);
let dropped = {
let mut cache = self.cache();
let n = cache.entries.len();
cache.entries.clear();
n
};
crate::storage::shutdown();
tracing::warn!(
target: "gunnar::store",
event = "store.suspended",
dropped,
why,
"every repository store is closed and opens are refused: {why}"
);
}
pub fn resume_repos(&self) {
self.usage.mark_all();
if self
.suspended
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
tracing::info!(target: "gunnar::store", event = "store.resumed", "repository stores open again on demand");
}
}
pub fn control_dir(&self) -> &Path {
&self.control_dir
}
pub fn secrets(
&self,
) -> Result<gunnar_core::SealedSecretStore<std::sync::Arc<dyn gunnar_store::SecretStore>>> {
let inner = Arc::clone(&self.secrets);
Ok(if self.legacy_secrets {
gunnar_core::SealedSecretStore::migrating(inner, self.kek())
} else {
gunnar_core::SealedSecretStore::new(inner, self.kek())
})
}
pub fn transparency_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
use gunnar_store::SecretStoreExt as _;
let secrets = self.secrets()?;
let bytes = secrets
.get_or_create_with(gunnar_store::TRANSPARENCY_LOG_KEY, || {
tracing::info!(
key = gunnar_store::TRANSPARENCY_LOG_KEY,
"no transparency-log signing key stored; minting one. It is sealed under the \
KEK, and its public half is what a verifier pins to check a signed tree head"
);
gunnar_core::mint_ed25519()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))
})
.context("reading the transparency log's signing key")?;
let pem = String::from_utf8(bytes)
.context("the stored transparency-log key is not utf-8 OpenSSH PEM")?;
gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the transparency log's signing key")
}
pub fn transparency(&self) -> crate::attest::TransparencyLog {
crate::attest::TransparencyLog::under(&self.root)
}
pub fn build_log(&self) -> crate::attest::TransparencyLog {
crate::attest::TransparencyLog::over(
self.root.join(crate::attest::BUILD_LOG),
self.root.join(crate::attest::BUILD_HEADS),
)
}
pub fn forge_log(&self) -> crate::attest::TransparencyLog {
crate::attest::TransparencyLog::over(
self.root.join(crate::attest::FORGE_LOG),
self.root.join(crate::attest::FORGE_HEADS),
)
}
pub fn membership_log(&self) -> crate::attest::TransparencyLog {
crate::attest::TransparencyLog::over(
self.root.join(crate::attest::MEMBERSHIP_LOG),
self.root.join(crate::attest::MEMBERSHIP_HEADS),
)
}
pub fn provenance_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
use gunnar_store::SecretStoreExt as _;
let secrets = self.secrets()?;
let bytes = secrets
.get_or_create_with(gunnar_store::PROVENANCE_KEY, || {
tracing::info!(
key = gunnar_store::PROVENANCE_KEY,
"no provenance signing key stored; minting one. Its public half is what a \
verifier pins to check a build record"
);
gunnar_core::mint_ed25519()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))
})
.context("reading the provenance signing key")?;
let pem = String::from_utf8(bytes)
.context("the stored provenance key is not utf-8 OpenSSH PEM")?;
gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the provenance signing key")
}
pub fn webhook_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
use gunnar_store::SecretStoreExt as _;
let secrets = self.secrets()?;
let bytes = secrets
.get_or_create_with(gunnar_store::WEBHOOK_KEY, || {
tracing::info!(
key = gunnar_store::WEBHOOK_KEY,
"no webhook signing key stored; minting one. Its public half is what a \
receiver pins to verify a delivery"
);
gunnar_core::mint_ed25519()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))
})
.context("reading the webhook signing key")?;
let pem =
String::from_utf8(bytes).context("the stored webhook key is not utf-8 OpenSSH PEM")?;
gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the webhook signing key")
}
pub fn forge_attest_key(&self) -> Result<gunnar_core::ssh_key::PrivateKey> {
use gunnar_store::SecretStoreExt as _;
let secrets = self.secrets()?;
let bytes = secrets
.get_or_create_with(gunnar_store::FORGE_ATTEST_KEY, || {
tracing::info!(
key = gunnar_store::FORGE_ATTEST_KEY,
"no forge-attestation key stored; minting one. It signs a person's forge \
event made from a session that stepped up"
);
gunnar_core::mint_ed25519()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))
})
.context("reading the forge-attestation key")?;
let pem = String::from_utf8(bytes)
.context("the stored forge-attestation key is not utf-8 OpenSSH PEM")?;
gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the forge-attestation key")
}
pub fn forge_attest_public(&self) -> Result<Option<gunnar_core::ssh_key::PublicKey>> {
use gunnar_store::SecretStore as _;
let secrets = self.secrets()?;
let Some(bytes) = secrets
.get(gunnar_store::FORGE_ATTEST_KEY)
.context("reading the forge-attestation key")?
else {
return Ok(None);
};
let pem = String::from_utf8(bytes)
.context("the stored forge-attestation key is not utf-8 OpenSSH PEM")?;
Ok(Some(
gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the forge-attestation key")?
.public_key()
.clone(),
))
}
#[cfg(feature = "metered")]
pub fn sealed_slot_get(&self, name: &str) -> Result<Option<(String, Vec<u8>)>> {
use gunnar_store::SecretStore as _;
let raw = self
.secrets()?
.get(&sealed_slot::key(name))
.context("reading a sealed slot")?;
raw.map(|b| sealed_slot::decode(&b)).transpose()
}
#[cfg(feature = "metered")]
pub fn sealed_slot_put(&self, name: &str, owner: &str, value: &[u8]) -> Result<bool> {
use gunnar_store::SecretStore as _;
let secrets = self.secrets()?;
let key = sealed_slot::key(name);
let created = secrets
.get(&key)
.context("reading a sealed slot")?
.is_none();
secrets
.put(&key, &sealed_slot::encode(owner, value))
.context("writing a sealed slot")?;
Ok(created)
}
#[cfg(feature = "metered")]
pub fn actor_ticket_key(&self) -> Result<crate::actor_ticket::ActorTicketKey> {
use gunnar_store::{SecretStore as _, SecretStoreExt as _};
let secrets = self.secrets()?;
let root = self.root();
if (root.join(crate::twin::RECHARGE_REL).exists() || crate::twin::is_restoring(root))
&& secrets
.get(gunnar_store::ACTOR_TICKET_KEY)
.context("reading the actor-ticket signing key")?
.is_none()
{
anyhow::bail!("this box awaits its twin's restore (a re-laid main): the actor-ticket key comes back with it, none is minted here");
}
let bytes = secrets
.get_or_create_with(gunnar_store::ACTOR_TICKET_KEY, || {
tracing::info!(
key = gunnar_store::ACTOR_TICKET_KEY,
"no actor-ticket signing key stored; minting one. Its public half is what \
the other side of the seam pins to check what this appliance signed"
);
gunnar_core::mint_ed25519()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))
})
.context("reading the actor-ticket signing key")?;
let pem = String::from_utf8(bytes)
.context("the stored actor-ticket key is not utf-8 OpenSSH PEM")?;
let private = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
.context("parsing the actor-ticket signing key")?;
crate::actor_ticket::ActorTicketKey::from_openssh(&private)
}
pub fn pki(&self) -> Result<gunnar_core::Pki> {
let secrets: std::sync::Arc<dyn gunnar_store::SecretStore> =
std::sync::Arc::new(self.secrets()?);
Ok(gunnar_core::Pki::new(
Arc::clone(&self.control_store),
secrets,
)?)
}
pub fn pki_readonly(&self) -> Result<gunnar_core::Pki> {
Ok(gunnar_core::Pki::read_only(Arc::clone(
&self.control_store,
))?)
}
pub fn install_plan_gates(&self, gates: Vec<Arc<dyn gunnar_ssh::PlanGate>>) {
*self
.plan_gates
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = gates;
}
pub fn install_space_ledger(&self, ledger: Arc<gunnar_ssh::SpaceLedger>) {
*self
.space
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(ledger);
}
pub fn space_ledger(&self) -> Option<Arc<gunnar_ssh::SpaceLedger>> {
self.space
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn plan_gates(&self) -> Vec<Arc<dyn gunnar_ssh::PlanGate>> {
self.plan_gates
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn ssh_control(&self) -> Control {
Control::new(self.auth.clone(), self.catalog.clone())
.with_encryption_keys(self.encryption_keys.clone())
.with_write_gate(Arc::clone(&self.writes_frozen))
.with_browse_flag(Arc::clone(&self.browse))
.with_twin_flag(Arc::clone(&self.twin))
.with_enterprise(self.names.clone(), Arc::clone(&self.account_audit))
}
pub fn browse(&self) -> Arc<AtomicBool> {
Arc::clone(&self.browse)
}
pub fn writes_frozen(&self) -> Arc<AtomicBool> {
Arc::clone(&self.writes_frozen)
}
pub fn store_opens(&self) -> u64 {
self.store_opens.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn repo_dir(&self, path: &RepoRef) -> Result<PathBuf> {
path.path_under(&self.repos_root)
.with_context(|| format!("resolving the directory for {path}"))
}
fn cache(&self) -> MutexGuard<'_, HandleCache> {
self.open
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
pub fn open_repo_capacity(&self) -> usize {
self.cache().capacity
}
pub fn set_open_repo_capacity(&self, capacity: usize) -> usize {
self.cache().set_capacity(capacity)
}
pub fn repo_store(&self, repo: &Repo) -> Result<Arc<OpenRepo>> {
let path = repo.path();
let key = path.key();
if self.suspended() {
anyhow::bail!(
"the data set is off-line while it is being grown; the repository {} cannot be \
opened until /data is mounted again — retry in a few minutes",
key
);
}
if let Some(store) = self.cache().touch(&key) {
return Ok(store);
}
self.reclaim_under_pressure();
let dir = self.repo_dir(&path)?;
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("creating the namespace directory {}", parent.display())
})?;
}
self.store_opens
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.usage.mark_repo(&repo.namespace, &repo.name);
let store = self
.engine
.open_repo(&dir, repo.hash_kind.into())
.with_context(|| format!("opening the repository at {}", dir.display()))?
.logged(
Arc::clone(&self.txlog),
&repo.key(),
&repo.hash_kind.to_string(),
);
Self::point_head(&store, repo)?;
let marker = dir.join(Self::HASH_MARKER);
if !marker.exists() {
if let Err(err) = std::fs::write(&marker, format!("{}\n", repo.hash_kind)) {
tracing::warn!(
target: "gunnar::store",
repo = %repo.key(),
marker = %marker.display(),
error = %err,
"the hash marker could not be written; --adopt will refuse this store"
);
}
}
self.apply_explode_quota(&repo.namespace, &dir);
Ok(self.cache().insert(key, store))
}
pub fn apply_explode_quota(&self, namespace: &str, dir: &Path) {
match self.catalog().namespace_explode_budget(namespace) {
Ok(Some(bytes)) => {
let n = self.engine.set_explode_budget(dir, bytes);
tracing::debug!(
target: "gunnar::store",
namespace,
bytes,
stores = n,
"the account's explode budget was applied"
);
}
Ok(None) => {}
Err(err) => tracing::warn!(
target: "gunnar::store",
namespace,
error = %err,
"the account's explode budget could not be read; the server default stands"
),
}
}
fn reclaim_under_pressure(&self) {
let now = std::time::Instant::now();
{
let mut probed = self
.last_reclaim_probe
.lock()
.unwrap_or_else(|poison| poison.into_inner());
match *probed {
Some(last) if now.duration_since(last) < RECLAIM_PROBE_INTERVAL => return,
_ => *probed = Some(now),
}
}
let Some((total, available)) = crate::dashboard::probe::memory() else {
return;
};
if total == 0 || available >= total / 100 * RECLAIM_FLOOR_PERCENT {
return;
}
let mut cache = self.cache();
let before = cache.capacity;
let want = (before / 2).max(crate::limits::MIN_OPEN_REPOS);
if want >= before {
return;
}
let dropped = cache.set_capacity(want);
let held = cache.entries.len();
drop(cache);
tracing::warn!(
target: "gunnar::limits",
event = "limits.handles.reclaimed",
mem_available = available,
mem_total = total,
was = before,
now = want,
dropped,
open = held,
"memory is short ({} of {} available): the open-repository cache is cut from {} to \
{} handles and {} were dropped. Each one held five descriptors and its page caches; \
a repository touched again simply re-opens.",
crate::budget::human(available),
crate::budget::human(total),
before,
want,
dropped
);
}
pub fn point_head(store: &OpenRepo, repo: &Repo) -> Result<()> {
let moved = gunnar_core::point_head_at(store.unlogged(), &repo.default_branch)
.with_context(|| format!("pointing {}'s HEAD at its default branch", repo.key()))?;
if moved {
tracing::info!(
repo = %repo.key(),
default_branch = %repo.default_branch,
"HEAD now names the repository's default branch; it did not before, so this \
repository was not clonable if that branch was the only one with commits"
);
}
Ok(())
}
pub fn repoint_head(&self, repo: &Repo) -> Result<()> {
let store = self.repo_store(repo)?;
Self::point_head(&store, repo)
}
pub fn adopt_default_branch_if_unborn(
&self,
path: &RepoRef,
pushed: &[String],
) -> Result<Option<String>> {
let Some(repo) = self.catalog().get(path)? else {
return Ok(None);
};
let store = self.repo_store(&repo)?;
let git = store.serve();
if gunnar_wire::RefSnapshot::read(git)?.head(git)?.is_some() {
return Ok(None);
}
let Some(adopted) = pushed
.iter()
.find(|name| name.starts_with("refs/heads/"))
.cloned()
else {
return Ok(None);
};
let repo = self.catalog().set_default_branch(path, &adopted)?;
Self::point_head(&store, &repo)?;
tracing::warn!(
repo = %repo.key(),
default_branch = %adopted,
"this repository's default branch did not exist, so nothing could clone it; the \
first branch pushed has been adopted as the default. `gunnar control repo \
set-default-branch` overrides this"
);
Ok(Some(adopted))
}
pub fn create_repo(&self, spec: NewRepo, adopt: bool) -> Result<Repo> {
let path = spec.path.clone();
let adopted = self.refuse_stale_store(&path, adopt, spec.hash_kind)?;
let repo = self.catalog().create(spec)?;
match self.repo_store(&repo) {
Ok(_) => Ok(repo),
Err(err) => {
self.close_repo(&path);
if let Err(undo) = self.catalog().delete(&path) {
tracing::error!(
target: "gunnar::store",
repo = %path,
error = format!("{undo:#}"),
"rolling back the catalog row of a create whose store failed"
);
}
if adopted {
tracing::error!(
target: "gunnar::store",
event = "repo.adopt_failed",
repo = %path,
"the create that ADOPTED this store failed to open it; the catalog row is \
rolled back and the DIRECTORY IS LEFT WHERE IT IS — it was not made by \
this create and is not this create's to remove"
);
} else if let Ok(dir) = self.repo_dir(&path) {
if let Err(undo) = std::fs::remove_dir_all(&dir) {
if undo.kind() != std::io::ErrorKind::NotFound {
tracing::error!(
target: "gunnar::store",
repo = %path,
dir = %dir.display(),
error = %undo,
"rolling back the directory of a create whose store failed"
);
}
}
}
Err(err.context(if adopted {
format!(
"creating the stores of {path} over an ADOPTED directory; the row was \
rolled back and the store's bytes were left untouched"
)
} else {
format!("creating the stores of {path}; nothing was kept")
}))
}
}
}
pub const HASH_MARKER: &'static str = "OBJECT_FORMAT";
pub fn refuse_stale_store(
&self,
path: &RepoRef,
adopt: bool,
hash_kind: HashKind,
) -> Result<bool> {
let dir = self.repo_dir(path)?;
let tombstone = Self::tombstone_path(&dir);
let tombstoned = tombstone.exists();
if !dir.exists() && !tombstoned {
return Ok(false);
}
if !adopt {
let why = if tombstoned {
let record =
Tombstone::parse(&std::fs::read_to_string(&tombstone).unwrap_or_default());
match record.reason {
TombstoneReason::Deleted => {
let left = record
.seconds_left(gunnar_core::now_ms())
.map(|s| format!("{} day(s) left", s / 86_400))
.unwrap_or_else(|| "no expiry set".to_owned());
format!(
"{path} is DELETED but not purged: its bytes are being kept ({left}). \
`repo undelete {path}` puts it back as it was, `repo purge {path}` \
reclaims it now, or pass --adopt to take the store as a new \
repository and lose the old row"
)
}
TombstoneReason::ReclaimFailed => format!(
"a delete of this name could not reclaim its store ({}); `repo delete \
{path} --purge` retries the reclaim, or pass --adopt to take the store \
as it is",
record.note.trim()
),
}
} else {
format!(
"a store already exists at {}; `repo delete {path}` reclaims it, or pass \
--adopt to take it as it is",
dir.display()
)
};
anyhow::bail!("{why}");
}
let marker = dir.join(Self::HASH_MARKER);
let on_disk = std::fs::read_to_string(&marker)
.map(|text| text.trim().to_owned())
.unwrap_or_default();
if on_disk.is_empty() {
std::fs::write(&marker, format!("{hash_kind}\n")).with_context(|| {
format!(
"marking the adopted store at {} as {hash_kind}; the adopt is refused rather \
than leaving a store whose hash nothing records",
dir.display()
)
})?;
tracing::warn!(
target: "gunnar::store",
event = "repo.adopted_unmarked",
repo = %path,
dir = %dir.display(),
hash = %hash_kind,
marker = %marker.display(),
"an UNMARKED store (written before the OBJECT_FORMAT marker existed) was adopted \
at the caller's word; it is marked {} from now on and the next adopt is CHECKED",
hash_kind
);
} else {
anyhow::ensure!(
on_disk == hash_kind.to_string(),
"cannot adopt the store at {}: its objects are {} (marker {} says so), the \
create asked for {hash_kind}. Adopt it as {} or leave it alone; a {hash_kind} \
row over a {} store serves refs no client can read.",
dir.display(),
on_disk,
marker.display(),
on_disk,
on_disk
);
}
if tombstoned {
let _framed = self.frame_tombstone(&tombstone, None)?;
std::fs::remove_file(&tombstone)
.with_context(|| format!("removing the tombstone {}", tombstone.display()))?;
}
tracing::warn!(
target: "gunnar::store",
event = "repo.adopted",
repo = %path,
dir = %dir.display(),
hash = %hash_kind,
"an existing store was ADOPTED by a create with --adopt; its objects are served as they are"
);
Ok(true)
}
pub fn tombstone_path(dir: &Path) -> PathBuf {
let mut name = dir
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".tombstone");
dir.with_file_name(name)
}
pub fn is_tombstoned(&self, path: &RepoRef) -> Result<bool> {
Ok(Self::tombstone_path(&self.repo_dir(path)?).exists())
}
pub fn open_handles(&self) -> usize {
self.cache().entries.len()
}
pub fn close_repo(&self, path: &RepoRef) {
self.usage.mark_repo(path.namespace(), path.name());
self.cache().entries.remove(&path.key());
}
pub fn purge_repo(&self, path: &RepoRef) -> Result<(u64, u64)> {
self.wait_unpinned("removing a repository store");
let _usage = UsageMark::namespace(&self.usage, path.namespace());
if !self.txlog.is_follower() {
let dir = self.repo_dir(path)?;
let hash = std::fs::read_to_string(dir.join(Self::HASH_MARKER))
.map(|s| s.trim().to_string())
.unwrap_or_default();
let meta = gunnar_core::txlog::Meta::RepoStore {
repo: path.key(),
op: "purged".into(),
hash,
};
let _framed = self.txlog.mutation();
self.txlog
.append(gunnar_core::txlog::Kind::RepoStore, &meta, &[])
.with_context(|| format!("framing the purge of {path}"))?;
return self.reclaim_repo(path);
}
self.reclaim_repo(path)
}
pub fn reclaim_repo(&self, path: &RepoRef) -> Result<(u64, u64)> {
let _usage = UsageMark::namespace(&self.usage, path.namespace());
let dir = self.repo_dir(path)?;
let tombstone = Self::tombstone_path(&dir);
let before = dir_bytes(&dir);
self.engine.cancel_index(&dir);
self.close_repo(path);
match std::fs::remove_dir_all(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
let now = gunnar_core::now_ms();
let record = Tombstone {
deleted_at_unix_ms: now,
purge_after_unix_ms: None,
principal_id: String::new(),
reason: TombstoneReason::ReclaimFailed,
repo: None,
note: format!("delete asked at unix_ms {now}, reclaim failed: {e}"),
};
let note = record.note.clone();
let text = serde_json::to_string_pretty(&record)
.unwrap_or_else(|_| format!("{{\"note\":\"{note}\"}}"));
if let Err(write) = std::fs::write(&tombstone, format!("{text}\n")) {
tracing::error!(
target: "gunnar::store",
repo = %path,
tombstone = %tombstone.display(),
error = %write,
"the reclaim failed AND the tombstone could not be written"
);
}
tracing::error!(
target: "gunnar::store",
event = "repo.purge_failed",
repo = %path,
dir = %dir.display(),
error = %e,
"repo delete could not reclaim the store; the name is tombstoned and a create \
refuses it until `repo delete` is run again and succeeds"
);
return Err(anyhow::anyhow!(
"the catalog row is gone but the store at {} could not be reclaimed ({e}); \
the name is tombstoned — `repo delete {path}` retries, `repo create` refuses \
it until then",
dir.display()
));
}
}
if tombstone.exists() {
std::fs::remove_file(&tombstone)
.with_context(|| format!("removing the tombstone {}", tombstone.display()))?;
}
let after = dir_bytes(&dir);
tracing::info!(
target: "gunnar::store",
event = "repo.purged",
repo = %path,
dir = %dir.display(),
bytes_before = before,
bytes_after = after,
"repo delete reclaimed {} bytes",
before.saturating_sub(after)
);
Ok((before, after))
}
pub fn soft_delete(
&self,
path: &RepoRef,
repo: Option<&Repo>,
principal_id: &str,
retention: std::time::Duration,
) -> Result<Tombstone> {
let _usage = UsageMark::namespace(&self.usage, path.namespace());
let dir = self.repo_dir(path)?;
let marker = Self::tombstone_path(&dir);
if let Some(existing) = Self::read_tombstone(&marker) {
if existing.reason == TombstoneReason::ReclaimFailed {
anyhow::bail!(
"{path} is already tombstoned because a reclaim FAILED ({}). That is bytes \
owed, not bytes held: run `repo delete {path} --purge` to finish it. \
Soft-deleting it again would hide the failure behind a deadline.",
existing.note.trim()
);
}
}
anyhow::ensure!(
dir.is_dir(),
"{path} has nothing on disk at {}; there is nothing to keep, and a tombstone over \
no bytes would promise a restore that cannot happen",
dir.display()
);
let now = gunnar_core::now_ms();
let record = Tombstone {
deleted_at_unix_ms: now,
purge_after_unix_ms: Some(now + retention.as_millis() as i64),
principal_id: principal_id.to_owned(),
reason: TombstoneReason::Deleted,
repo: repo.cloned(),
note: format!(
"soft-deleted at unix_ms {now}; the bytes are kept until the deadline, `repo \
undelete {path}` restores it"
),
};
self.engine.cancel_index(&dir);
self.close_repo(path);
let text = serde_json::to_string_pretty(&record).context("serialising the tombstone")?;
let text = format!("{text}\n");
let _framed = self.frame_tombstone(&marker, Some(text.as_bytes()))?;
std::fs::write(&marker, &text)
.with_context(|| format!("writing the tombstone {}", marker.display()))?;
let bytes = dir_bytes(&dir);
tracing::warn!(
target: "gunnar::store",
event = "repo.soft_deleted",
repo = %path,
dir = %dir.display(),
bytes,
principal = %principal_id,
purge_after_unix_ms = record.purge_after_unix_ms.unwrap_or_default(),
"repo delete tombstoned this repository; {bytes} bytes are KEPT until the retention \
window ends, and they still count against the account"
);
Ok(record)
}
pub fn undelete_spec(&self, path: &RepoRef) -> Result<NewRepo> {
let dir = self.repo_dir(path)?;
let marker = Self::tombstone_path(&dir);
let record = Self::read_tombstone(&marker).ok_or_else(|| {
anyhow::anyhow!(
"{path} is not deleted: there is no tombstone at {}. `repo list-deleted` shows \
what can be undeleted.",
marker.display()
)
})?;
anyhow::ensure!(
record.reason == TombstoneReason::Deleted,
"{path} is tombstoned because a RECLAIM FAILED, not because it was deleted with a \
window ({}). There is nothing to undelete: the operator asked for these bytes to \
go. `repo delete {path} --purge` finishes it.",
record.note.trim()
);
anyhow::ensure!(
dir.is_dir(),
"{path} is tombstoned but its bytes are gone from {}; there is nothing to restore",
dir.display()
);
let repo = record.repo.ok_or_else(|| {
anyhow::anyhow!(
"the tombstone at {} does not record what {path} was — no hash kind, no default \
branch. Restoring it would be a GUESS, and a sha256 row over a sha1 store \
serves refs no client can read. Recreate it with `repo create --adopt --hash \
<the store's OBJECT_FORMAT>` instead.",
marker.display()
)
})?;
Ok(NewRepo {
path: path.clone(),
hash_kind: repo.hash_kind,
default_branch: Some(repo.default_branch),
visibility: repo.visibility,
description: repo.description,
})
}
pub fn clear_tombstone(&self, path: &RepoRef) -> Result<()> {
let _usage = UsageMark::namespace(&self.usage, path.namespace());
let marker = Self::tombstone_path(&self.repo_dir(path)?);
let _framed = if marker.exists() {
self.frame_tombstone(&marker, None)?
} else {
None
};
match std::fs::remove_file(&marker) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e)
.with_context(|| format!("removing the tombstone {}", marker.display()))
}
}
tracing::warn!(
target: "gunnar::store",
event = "repo.undeleted",
repo = %path,
"repo undelete put this repository back; its bytes were never reclaimed"
);
Ok(())
}
fn frame_tombstone(
&self,
marker: &Path,
bytes: Option<&[u8]>,
) -> Result<Option<gunnar_core::txlog::MutationGuard>> {
if self.txlog.is_follower() {
return Ok(None);
}
let rel = marker
.strip_prefix(&self.root)
.with_context(|| {
format!(
"the tombstone {} is not under the data root",
marker.display()
)
})?
.to_string_lossy()
.into_owned();
let guard = self.txlog.mutation();
self.txlog
.append(
gunnar_core::txlog::Kind::File,
&gunnar_core::txlog::Meta::File {
path: rel.clone(),
deleted: bytes.is_none(),
},
bytes.unwrap_or_default(),
)
.with_context(|| format!("framing the tombstone {rel}"))?;
Ok(Some(guard))
}
pub fn read_tombstone(marker: &Path) -> Option<Tombstone> {
std::fs::read_to_string(marker)
.ok()
.map(|text| Tombstone::parse(&text))
}
pub fn tombstone_of(&self, path: &RepoRef) -> Result<Option<Tombstone>> {
Ok(Self::read_tombstone(&Self::tombstone_path(
&self.repo_dir(path)?,
)))
}
pub fn tombstones(&self) -> Result<Vec<DeletedRepo>> {
let root = self.repos_root().to_path_buf();
let mut found = Vec::new();
Self::walk_tombstones(&root, &root, &mut found);
found.sort_by_key(|a| a.path.key());
Ok(found)
}
fn walk_tombstones(root: &Path, dir: &Path, out: &mut Vec<DeletedRepo>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".git.tombstone") {
let Some(record) = Self::read_tombstone(&path) else {
continue;
};
if record.reason != TombstoneReason::Deleted {
continue;
}
let store = path.with_file_name(name.trim_end_matches(".tombstone"));
let Some(repo_ref) = Self::repo_ref_of_dir(root, &store) else {
continue;
};
out.push(DeletedRepo {
path: repo_ref,
tombstone: record,
bytes: dir_bytes(&store),
});
continue;
}
if path.is_dir() && !name.ends_with(".git") {
Self::walk_tombstones(root, &path, out);
}
}
}
fn repo_ref_of_dir(root: &Path, dir: &Path) -> Option<RepoRef> {
let rel = dir.strip_prefix(root).ok()?;
let mut parts: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
let name = parts.pop()?;
let name = name.strip_suffix(".git").unwrap_or(&name).to_owned();
if parts.is_empty() {
return None;
}
RepoRef::new(&parts.join("/"), &name).ok()
}
pub fn orphans(&self) -> Result<Vec<OrphanStore>> {
let root = self.repos_root().to_path_buf();
let mut found = Vec::new();
self.walk_orphans(&root, &root, &mut found)?;
found.sort_by_key(|a| a.path.key());
Ok(found)
}
fn walk_orphans(&self, root: &Path, dir: &Path, out: &mut Vec<OrphanStore>) -> Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !path.is_dir() {
continue;
}
if !name.ends_with(".git") {
self.walk_orphans(root, &path, out)?;
continue;
}
let Some(repo_ref) = Self::repo_ref_of_dir(root, &path) else {
continue;
};
if self.catalog().get(&repo_ref)?.is_some() {
continue;
}
let record = Self::read_tombstone(&Self::tombstone_path(&path));
let failed_reclaim = match &record {
Some(t) if t.reason == TombstoneReason::Deleted => continue,
Some(t) => Some(t.note.trim().to_owned()),
None => None,
};
let hash_marker = std::fs::read_to_string(path.join(Self::HASH_MARKER))
.ok()
.map(|text| text.trim().to_owned())
.filter(|text| !text.is_empty());
out.push(OrphanStore {
path: repo_ref,
bytes: dir_bytes(&path),
modified_unix_ms: newest_mtime_unix_ms(&path),
hash_marker,
failed_reclaim,
dir: path,
});
}
Ok(())
}
pub fn is_orphan(&self, path: &RepoRef) -> Result<bool> {
if self.catalog().get(path)?.is_some() {
return Ok(false);
}
let dir = self.repo_dir(path)?;
if !dir.is_dir() {
return Ok(false);
}
Ok(!matches!(
Self::read_tombstone(&Self::tombstone_path(&dir)),
Some(record) if record.reason == TombstoneReason::Deleted
))
}
pub fn orphan_soft_delete_refusal(path: &RepoRef) -> String {
format!(
"{path} is an ORPHAN store: bytes on disk with no catalog row and no tombstone, so \
the server does not know what it was and a tombstone would promise an undelete it \
cannot honour. Three exits, and they are all one command: `repo delete {path} \
--purge` reclaims the bytes now, `repo reclaim {path}` is the same thing said \
plainly, and `repo create {path} --adopt --hash <its OBJECT_FORMAT>` takes the \
store over as a repository instead. `repo list-orphans` reports that hash."
)
}
pub fn reclaim_orphan(&self, path: &RepoRef) -> Result<u64> {
let _usage = UsageMark::namespace(&self.usage, path.namespace());
anyhow::ensure!(
self.catalog().get(path)?.is_none(),
"{path} is a LIVE repository, not an orphan: it is in the catalog. `repo delete \
{path} --purge` is the verb that deletes it."
);
let dir = self.repo_dir(path)?;
if let Some(record) = Self::read_tombstone(&Self::tombstone_path(&dir)) {
anyhow::ensure!(
record.reason != TombstoneReason::Deleted,
"{path} is SOFT-DELETED, not an orphan: its bytes are being kept on purpose and \
`repo undelete {path}` still puts it back. `repo purge {path}` ends the window."
);
}
anyhow::ensure!(
dir.is_dir(),
"there is no store at {}; `repo list-orphans` shows what there is to reclaim",
dir.display()
);
let (before, after) = self.purge_repo(path)?;
let bytes = before.saturating_sub(after);
tracing::warn!(
target: "gunnar::store",
event = "repo.orphan_reclaimed",
repo = %path,
dir = %dir.display(),
bytes,
"an ORPHAN store — no catalog row, no tombstone — was reclaimed; {bytes} bytes back \
and the name is usable again"
);
Ok(bytes)
}
pub fn sweep_expired_tombstones(&self, now_unix_ms: i64) -> Vec<(RepoRef, u64)> {
let deleted = match self.tombstones() {
Ok(deleted) => deleted,
Err(err) => {
tracing::error!(
target: "gunnar::store",
error = format!("{err:#}"),
"the tombstone sweep could not list what is deleted; nothing was reclaimed"
);
return Vec::new();
}
};
let mut reclaimed = Vec::new();
for row in deleted {
if !row.tombstone.is_expired_at(now_unix_ms) {
continue;
}
match self.purge_repo(&row.path) {
Ok((before, after)) => {
let bytes = before.saturating_sub(after);
tracing::warn!(
target: "gunnar::store",
event = "repo.tombstone_swept",
repo = %row.path,
bytes,
deleted_at_unix_ms = row.tombstone.deleted_at_unix_ms,
"the retention window closed; a soft-deleted repository was reclaimed \
and cannot be undeleted any more"
);
reclaimed.push((row.path, bytes));
}
Err(err) => {
tracing::error!(
target: "gunnar::store",
event = "repo.tombstone_sweep_failed",
repo = %row.path,
error = format!("{err:#}"),
"an expired tombstone could not be reclaimed; the name stays fenced off \
and the sweep continues"
);
}
}
}
reclaimed
}
pub fn repo_of_dir(&self, dir: &Path) -> Option<(String, String)> {
let rel = dir.strip_prefix(&self.repos_root).ok()?;
let mut parts = rel.components().filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
});
let ns = parts.next()?;
let name = parts.next()?;
Some((ns, name.strip_suffix(".git").unwrap_or(&name).to_owned()))
}
}
pub fn spawn_tombstone_sweep(stores: Arc<ServerStores>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let stores = Arc::clone(&stores);
let reclaimed = tokio::task::spawn_blocking(move || {
stores.sweep_expired_tombstones(gunnar_core::now_ms())
})
.await
.unwrap_or_default();
if !reclaimed.is_empty() {
let bytes: u64 = reclaimed.iter().map(|(_, b)| b).sum();
tracing::warn!(
target: "gunnar::store",
event = "repo.sweep",
repos = reclaimed.len(),
bytes,
"the retention window closed on {} repository(ies); {bytes} bytes were \
reclaimed and they can no longer be undeleted",
reclaimed.len()
);
}
tokio::time::sleep(TOMBSTONE_SWEEP_INTERVAL).await;
}
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlowFlush {
Wrote(usize),
Held,
Failed(String),
}
pub fn flow_flush_tick(stores: &ServerStores) -> FlowFlush {
if stores.txlog().is_released() {
return FlowFlush::Held;
}
match stores.flow().flush(gunnar_core::now_ms()) {
Ok(rows) => FlowFlush::Wrote(rows),
Err(_) if stores.txlog().is_released() => FlowFlush::Held,
Err(err) => FlowFlush::Failed(err.to_string()),
}
}
pub fn spawn_flow_flush(
stores: Arc<ServerStores>,
band: Arc<Mutex<Option<gunnar_core::Fleet>>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(FLOW_FLUSH_INTERVAL).await;
let tick = Arc::clone(&stores);
let band = Arc::clone(&band);
let written = tokio::task::spawn_blocking(move || {
let written = flow_flush_tick(&tick);
if written == FlowFlush::Held {
return written;
}
reconcile_tick(&tick, FLOW_FLUSH_INTERVAL);
*band.lock().expect("the flow band") = Some(
tick.flow()
.fleet(gunnar_core::now_ms(), crate::dashboard::SILENT_AFTER_DAYS),
);
written
})
.await
.unwrap_or_else(|err| FlowFlush::Failed(err.to_string()));
match written {
FlowFlush::Wrote(0) => {}
FlowFlush::Held => tracing::trace!(
target: "gunnar::store",
event = "flow.flush_held",
"the transaction log is released for a data-set growth; the flow ledger waits for the remount"
),
FlowFlush::Wrote(rows) => tracing::debug!(
target: "gunnar::store",
event = "flow.flushed",
rows,
"wrote {rows} namespace-day flow row(s)"
),
FlowFlush::Failed(err) => tracing::warn!(
target: "gunnar::store",
event = "flow.flush_failed",
%err,
"the flow ledger could not be written; the counters are still in memory and \
the next tick will try again"
),
}
}
})
}
pub fn reconcile_tick(stores: &ServerStores, tick: std::time::Duration) -> Option<(usize, usize)> {
count_reconcile(
stores.usage_ledger().reconcile_step(stores, tick),
stores.metrics(),
)
}
pub fn count_reconcile(
outcome: gunnar_core::Result<(usize, usize)>,
metrics: &gunnar_core::observe::ServerMetrics,
) -> Option<(usize, usize)> {
match outcome {
Ok(done) => Some(done),
Err(err) => {
metrics.add_usage_reconcile_missed(1);
tracing::warn!(
target: "gunnar::store",
event = "usage.reconcile_missed",
%err,
missed = metrics.snapshot().usage_reconcile_missed,
"the usage ledger's reconcile pass could not run ({err}): nothing was re-measured \
this tick; counted as usage_reconcile_missed, and the next tick tries again"
);
None
}
}
}
pub fn dir_bytes(dir: &Path) -> u64 {
fn walk(p: &Path, acc: &mut u64) {
let Ok(rd) = std::fs::read_dir(p) else { return };
for e in rd.flatten() {
let Ok(m) = e.metadata() else { continue };
if m.is_dir() {
walk(&e.path(), acc);
} else {
*acc += m.len();
}
}
}
let mut n = 0;
walk(dir, &mut n);
n
}
pub fn newest_mtime_unix_ms(dir: &Path) -> i64 {
fn walk(p: &Path, acc: &mut i64) {
let Ok(rd) = std::fs::read_dir(p) else { return };
for e in rd.flatten() {
let Ok(m) = e.metadata() else { continue };
if let Ok(at) = m.modified() {
if let Ok(since) = at.duration_since(std::time::UNIX_EPOCH) {
*acc = (*acc).max(since.as_millis() as i64);
}
}
if m.is_dir() {
walk(&e.path(), acc);
}
}
}
let mut newest = std::fs::metadata(dir)
.and_then(|m| m.modified())
.ok()
.and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
walk(dir, &mut newest);
newest
}
#[cfg(feature = "replication")]
fn replication_role_at_open(root: &Path) -> crate::mirror::Role {
crate::mirror::ReplicationConfig::load(root)
.map(|c| c.role)
.unwrap_or_default()
}
#[cfg(not(feature = "replication"))]
fn replication_role_at_open(_root: &Path) -> PrimaryOnly {
PrimaryOnly
}
#[cfg(not(feature = "replication"))]
struct PrimaryOnly;
#[cfg(not(feature = "replication"))]
impl PrimaryOnly {
fn refuses_client_writes(&self) -> bool {
false
}
fn is_browse(&self) -> bool {
false
}
}
#[cfg(test)]
mod tombstone_tests {
use super::*;
#[test]
fn a_free_text_tombstone_reads_as_a_failed_reclaim_and_never_expires() {
let old = "delete asked at unix_ms 1756500000000, reclaim failed: Permission denied\n";
let record = Tombstone::parse(old);
assert_eq!(record.reason, TombstoneReason::ReclaimFailed);
assert_eq!(record.purge_after_unix_ms, None);
assert!(record.note.contains("Permission denied"));
assert!(!record.is_expired_at(i64::MAX));
assert_eq!(record.seconds_left(0), None);
}
#[test]
fn a_soft_delete_round_trips_and_expires_exactly_at_its_deadline() {
let record = Tombstone {
deleted_at_unix_ms: 1_000,
purge_after_unix_ms: Some(3_000),
principal_id: "alice".into(),
reason: TombstoneReason::Deleted,
repo: None,
note: "soft-deleted".into(),
};
let text = serde_json::to_string(&record).unwrap();
let back = Tombstone::parse(&text);
assert_eq!(back, record);
assert!(!back.is_expired_at(2_999), "one ms early is not expired");
assert!(back.is_expired_at(3_000), "the deadline itself expires");
assert!(back.is_expired_at(3_001));
assert_eq!(back.seconds_left(1_000), Some(2));
assert_eq!(back.seconds_left(9_999), Some(0), "never negative");
}
#[test]
fn the_two_reasons_do_not_share_a_word() {
assert_ne!(
TombstoneReason::Deleted.as_str(),
TombstoneReason::ReclaimFailed.as_str()
);
}
}
pub(crate) fn filesystem_use(what: &str, path: &Path) -> gunnar_proto::FilesystemUse {
let mut row = gunnar_proto::FilesystemUse {
what: what.to_owned(),
path: path.display().to_string(),
..Default::default()
};
match rustix::fs::statvfs(path) {
Ok(vfs) => {
let frsize = if vfs.f_frsize == 0 {
vfs.f_bsize
} else {
vfs.f_frsize
};
row.measured = true;
row.total_bytes = vfs.f_blocks.saturating_mul(frsize);
row.free_bytes = vfs.f_bavail.saturating_mul(frsize);
}
Err(err) => row.error = err.to_string(),
}
row
}
#[cfg(test)]
mod flow_flush_tests {
use super::*;
#[test]
fn a_reconcile_pass_that_cannot_run_is_counted_on_the_servers_counters() {
let metrics = gunnar_core::observe::ServerMetrics::new();
assert_eq!(count_reconcile(Ok((3, 1)), &metrics), Some((3, 1)));
assert_eq!(
metrics.snapshot().usage_reconcile_missed,
0,
"a pass that ran is no miss"
);
let miss = Err(gunnar_core::Error::NoSuchNamespace(
"catalog unreadable".into(),
));
assert_eq!(count_reconcile(miss, &metrics), None);
assert_eq!(metrics.snapshot().usage_reconcile_missed, 1);
assert!(
metrics
.snapshot()
.render_prometheus()
.contains("gunnar_usage_reconcile_missed 1\n"),
"the miss is on /metrics: {}",
metrics.snapshot().render_prometheus()
);
}
#[test]
fn the_reconcile_tick_counts_on_the_stores_one_set_of_counters() {
let dir = tempfile::tempdir().unwrap();
let stores = ServerStores::open(dir.path()).unwrap();
assert_eq!(reconcile_tick(&stores, FLOW_FLUSH_INTERVAL), Some((0, 0)));
assert_eq!(stores.metrics().snapshot().usage_reconcile_missed, 0);
}
#[test]
fn a_released_txlog_holds_the_flush_and_the_rolled_day_lands_after_reacquire() {
let dir = tempfile::tempdir().unwrap();
let stores = ServerStores::open(dir.path()).unwrap();
let now = gunnar_core::now_ms();
let yesterday = now - 86_400_000;
let push = gunnar_core::flow::Transfer {
bytes_in: 42,
pushed: true,
principal: "p".into(),
..Default::default()
};
stores.flow().record("ns", yesterday, &push);
stores.txlog().release().unwrap();
assert!(
stores.flow().flush(now).is_err(),
"a flush wrote through a released transaction log"
);
assert_eq!(flow_flush_tick(&stores), FlowFlush::Held);
assert_eq!(
flow_flush_tick(&stores),
FlowFlush::Held,
"still held, every tick of the window"
);
stores.txlog().reacquire().unwrap();
assert_eq!(
flow_flush_tick(&stores),
FlowFlush::Wrote(1),
"yesterday's row, re-parked by the refused flush, is written after the remount"
);
assert_eq!(
flow_flush_tick(&stores),
FlowFlush::Wrote(0),
"and written once"
);
}
}
fn adopt_hub_conf(root: &Path, txlog: &Arc<gunnar_core::txlog::Log>) {
use gunnar_core::txlog::{HUB_CONF_FRAMED_REL, HUB_CONF_REL};
let path = root.join(HUB_CONF_REL);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(err) => {
tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
path = %path.display(),
%err,
"the hub declaration could not be read, so it is NOT in this box's log and a twin will not hold it"
);
return;
}
};
let digest = gunnar_core::provenance::digest_bytes(&bytes);
let mark = root.join(HUB_CONF_FRAMED_REL);
if std::fs::read_to_string(&mark)
.map(|s| s.trim() == digest)
.unwrap_or(false)
{
return;
}
let framed = txlog.append(
gunnar_core::txlog::Kind::File,
&gunnar_core::txlog::Meta::File {
path: HUB_CONF_REL.to_string(),
deleted: false,
},
&bytes,
);
match framed {
Ok(seq) => {
if let Err(err) = crate::durable::write_atomic_unframed(
mark.parent().unwrap_or(root),
HUB_CONF_FRAMED_REL
.rsplit('/')
.next()
.unwrap_or(HUB_CONF_FRAMED_REL),
format!("{digest}\n").as_bytes(),
) {
tracing::warn!(target: gunnar_core::observe::TARGET_TWIN, %err, "the hub declaration is framed at seq {seq}; the note saying so was not written, so the next boot frames it again");
}
tracing::info!(
target: gunnar_core::observe::TARGET_TWIN,
seq,
path = %path.display(),
"the site's hub declaration is a transaction: the twin holds it and a restore brings it back"
);
}
Err(err) => tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
path = %path.display(),
%err,
"the hub declaration could not be framed, so a twin will not hold it and a restored main would come up with no way onto the tunnel"
),
}
}
struct UsageMark<'a> {
ledger: &'a crate::usage_ledger::UsageLedger,
namespace: String,
}
impl<'a> UsageMark<'a> {
fn namespace(ledger: &'a crate::usage_ledger::UsageLedger, namespace: &str) -> Self {
UsageMark {
ledger,
namespace: namespace.to_owned(),
}
}
}
impl Drop for UsageMark<'_> {
fn drop(&mut self) {
self.ledger.mark_namespace(&self.namespace);
}
}
#[cfg(feature = "metered")]
pub mod sealed_slot {
use anyhow::{bail, Result};
pub const PREFIX: &str = "slot/";
const MAGIC: &[u8; 4] = b"gsl1";
pub fn key(name: &str) -> String {
format!("{PREFIX}{name}")
}
pub fn encode(owner: &str, value: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(6 + owner.len() + value.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&(owner.len() as u16).to_be_bytes());
out.extend_from_slice(owner.as_bytes());
out.extend_from_slice(value);
out
}
pub fn decode(b: &[u8]) -> Result<(String, Vec<u8>)> {
if b.len() < 6 || &b[..4] != MAGIC {
bail!("a sealed slot's row is not gsl1");
}
let n = u16::from_be_bytes([b[4], b[5]]) as usize;
if b.len() < 6 + n {
bail!("a sealed slot's row is cut short");
}
Ok((
String::from_utf8(b[6..6 + n].to_vec())?,
b[6 + n..].to_vec(),
))
}
}
#[cfg(all(test, feature = "metered"))]
mod ticket_key_recharge_tests {
use super::*;
#[test]
fn a_main_awaiting_its_twin_mints_no_ticket_key_and_reads_the_restored_one_after() {
use gunnar_store::SecretStore as _;
let dir = tempfile::tempdir().unwrap();
let stores = ServerStores::open(dir.path()).unwrap();
let marker = dir.path().join(crate::pairing::RECHARGE_REL);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "10.13.8.3:50051\n").unwrap();
let e = stores
.actor_ticket_key()
.err()
.expect("no key minted while the box awaits its twin");
assert!(format!("{e:#}").contains("awaits its twin"), "{e:#}");
assert!(
stores
.secrets()
.unwrap()
.get(gunnar_store::ACTOR_TICKET_KEY)
.unwrap()
.is_none(),
"nothing was written"
);
std::fs::remove_file(&marker).unwrap();
let k = stores
.actor_ticket_key()
.expect("restored (or never recharged): the key is there or minted");
assert_eq!(
stores.actor_ticket_key().unwrap().public_key_hex(),
k.public_key_hex()
);
}
}
pub const ACTIVITY_FLUSH: &str = "principal-activity";
pub fn start_activity_flush(
activity: &Arc<gunnar_core::PrincipalActivity>,
every: std::time::Duration,
) -> Result<Arc<gunnar_common::jobs::Job>, String> {
let weak = Arc::downgrade(activity);
let mut first = true;
let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("{l}"));
gunnar_common::jobs::start(ACTIVITY_FLUSH, every, say, move || {
if std::mem::take(&mut first) {
return gunnar_common::jobs::Round::Again;
}
let Some(activity) = weak.upgrade() else {
return gunnar_common::jobs::Round::Done;
};
if let Err(err) = activity.flush() {
tracing::warn!(error = %err, "the last-seen rows could not be written; they are kept in memory and tried again");
}
gunnar_common::jobs::Round::Again
})
}
#[cfg(test)]
mod activity_flush_tests {
use super::*;
#[test]
fn the_activity_flush_is_a_jera_job_that_ends_with_the_record() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
let db = gunnar_core::control::open_control_with_db(&path)
.expect("control store")
.2;
let a = Arc::new(gunnar_core::PrincipalActivity::open(
Arc::clone(&db),
None,
None,
));
let job = start_activity_flush(&a, std::time::Duration::from_millis(10))
.expect("the flush starts as a jera job");
assert_eq!(job.kind(), ACTIVITY_FLUSH);
a.signed_in("kim", 7);
let t = std::time::Instant::now();
let flushed = || {
gunnar_core::PrincipalActivity::open(Arc::clone(&db), None, None)
.seen("kim")
.signed_in
== Some(7)
};
while !flushed() && t.elapsed() < std::time::Duration::from_secs(10) {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
flushed(),
"the job flushed the stamp while the record lives"
);
drop(a);
let t = std::time::Instant::now();
while !job.is_stopped() && t.elapsed() < std::time::Duration::from_secs(10) {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(job.is_stopped(), "the job ended with the record");
}
}